简介:面向需要完成知识图谱相关Python大作业或毕业设计的高校学生,这套基于中医药领域知识图谱的智能问答系统提供了从领域知识建模到自然语言问答的完整实现。资源围绕“知识图谱构建—实体识别—问答匹配”主线,包含可运行的后端代码、流程示意图与说明文档,能够帮助读者快速搭建一个中医药领域的智能问答原型。
压缩包共11个文件,以9个Python脚本为主,覆盖知识图谱构建、命名实体识别、实体链接、路径特征提取、问答响应等关键模块;另含1张问答流程示意图和1份Markdown说明文档,用于辅助理解整体架构与运行逻辑。整套资源仅123KB,代码紧凑、逻辑清晰,便于二次开发与学习研究。
该资源已有147人学习下载,特别适合作为毕业设计参考或课程大作业的实践项目。读者可从中获取一套完整的中医药知识图谱问答实现思路与核心代码,减少重复踩坑,快速上手属于自己的智能问答系统。
1. 中医药知识图谱问答,难的不是大模型而是知识组织
如果只把《伤寒论》《本草纲目》扔给大模型做问答,你会得到一份看起来流畅、但经不起任何专业推敲的“幻觉方剂”。真正的中医药智能问答系统,核心不在生成模型,而在于把“药-方-证-症-病”之间的网状关系落成可查询、可推理的结构化知识。知识图谱在这里不是噱头,而是让系统能回答“这个方剂为什么能治这个证”“哪些药和这味药相畏”这类关系型问题的前提。本文按“本体设计 → 图谱构建 → 存储检索 → 问答实现 → 调优验证”这条路径展开,全程给出可复现代码和参数说明,面向需要落地知识图谱问答(KBQA)的工程师与产品技术负责人。标题里的“智能问答”四个字,最后会落到一条 Cypher 查询和一段候选答案排序逻辑上,而不是一个聊天框。
2. 中医药本体设计:先把实体、关系和属性边界划清楚
2.1 为什么中医药领域不能直接套通用知识图谱 Schema
通用知识图谱(如 DBpedia、CN-DBpedia)的实体类型以人物、地点、机构、作品为主,关系多是“出生于”“位于”“作者是”。中医药领域的核心实体是中药材、方剂、证候、症状、疾病、治法、药性、归经,关系则充满领域特有的语义约束,例如“方剂由药材组成”“药材归某经”“证候表现为某症状”“治法针对某证候”。更关键的是属性类型差异:一味药材有“性味”“归经”“毒性”“炮制方法”,一个方剂有“君臣佐使”“煎服法”“禁忌”。这些如果用通用的“实体-关系-三元组”存,会丢大量约束信息。常见做法是先定义一层薄的领域本体(ontology),再基于本体做知识抽取,而不是拿现成通用图谱来凑。
2.2 用 Protégé 或 Python 定义中医药本体
轻量团队可以跳过 Protégé 的可视化编辑,直接用 Python 的owlready2库以代码方式定义类、属性和关系。这一步的价值是让后续抽取出的每个三元组都有类型约束,避免把“黄连-性味-苦”和“黄连-产地-四川”两类性质完全不同的边混在一张表里。
from owlready2 import * onto = get_ontology("http://example.org/tcm_onto.owl") with onto: class Herb(Thing): # 中药材 pass class Formula(Thing): # 方剂 pass class Syndrome(Thing): # 证候 pass class Symptom(Thing): # 症状 pass class Disease(Thing): # 疾病 pass class Treatment(Thing): # 治法 pass class has_component(Formula >> Herb): # 方剂含药材 pass class belongs_to_meridian(Herb >> Treatment): # 归经,这里用Treatment代指经络需按需求调整 pass class manifests_as(Syndrome >> Symptom): # 证候表现为症状 pass class treats(Formula >> Syndrome): # 方剂主治证候 pass # 属性:以数据属性方式挂在类上 class nature(Herb >> str): # 性味 pass class toxicity(Herb >> str): # 毒性 pass逻辑说明:has_component的定义域是Formula,值域是Herb,这保证了“黄连-组成-桂枝汤”这种把药材和方剂搞反的三元组在入库时就会触发类型校验。第 5 章会讲如何利用这个校验做错误三元组过滤。nature、toxicity这类数据属性建议用字符串而非枚举,原因是中医典籍里的表述差异极大(“微苦”“苦而微寒”),枚举会丢失原始语义。
提示:如果你的语料只覆盖某个子领域(比如只做中药-方剂,不做经络),本体定义可以只保留 4~5 个类,不要为“完整性”硬扩。本体越大,后续人工校验成本越高。
2.3 从非结构化文本抽三元组:规则与模型结合
中医药文本(如《药典》条目)有较强的句式规律:“【性味】味苦,性寒”“【归经】归心、肝经”“【功能主治】清热燥湿,泻火解毒”。正则抽取对这类结构化条目效果就已经不错,不必一上来就用大模型。下面这段抽取逻辑是项目里常用做法,适合单条目的属性抽取,能直接产出与 2.2 节本体对齐的三元组。
import re entry = "黄连,味苦,性寒,归心、肝经。功能主治:清热燥湿,泻火解毒。" def extract_herb_attrs(text): herb_name = text.split(",")[0] nature_match = re.search(r"味(.+?),性(.+?)", text) meridian_match = re.search(r"归([\u4e00-\u9fa5、]+?)经", text) function_match = re.search(r"功能主治:(.+)", text) triples = [] if nature_match: triples.append((herb_name, "nature", nature_match.group(1))) triples.append((herb_name, "nature", nature_match.group(2))) if meridian_match: meridians = [m for m in re.split("、", meridian_match.group(1)) if m] for m in meridians: triples.append((herb_name, "belongs_to_meridian", m)) if function_match: funcs = re.split(r"[,。;]", function_match.group(1)) for f in funcs: if f: triples.append((herb_name, "treats", f)) return triples print(extract_herb_attrs(entry)) # 输出示例: # [('黄连', 'nature', '苦'), ('黄连', 'nature', '寒'), # ('黄连', 'belongs_to_meridian', '心'), ('黄连', 'belongs_to_meridian', '肝'), # ('黄连', 'treats', '清热燥湿'), ('黄连', 'treats', '泻火解毒')]正则方案的缺点很快会出现:同一文本里“功能主治”后的内容往往同时包含症状和治法,treats关系无法区分。如果预算允许,建议在规则抽取之后接一个序列标注模型(如 BERT-BiLSTM-CRF)做关系分类,把“清热燥湿”标为treats还是manifests_as。但第一版系统用规则完全够跑通,后续可以用模型产出的高置信度三元组反过来扩充规则。参数层面,re.split的分隔符集合需要看语料实际标点调整,有些文本用空格或英文分号。
3. 知识图谱存储与检索:Neo4j 的建模、导入和索引调优
3.1 为什么选图数据库而不是关系型数据库
知识图谱问答的核心查询是多跳关系检索,例如“治疗风寒感冒且不含甘草的药方有哪些”,这条查询如果在 MySQL 里做,需要把方剂、药材、功效、禁忌拆成 5~6 张表,再用 3 次 JOIN。图数据库把关系作为一等公民,上述查询在 Neo4j 中只需沿边遍历两层,响应时间在毫秒级(几万节点规模下)。中医药知识图谱的节点数通常在几千到几万,Neo4j 完全能承载,不需要引入分布式图存储。
3.2 实体与关系的 CSV 导入设计
假设 2.3 节产出了三类文件:herbs.csv、formulas.csv、relations.csv。导入前先定好节点 ID 策略——用领域内唯一标识(如药材拼音名或药典编号)做id属性,不要用自增数字,否则后续实体链接时的别名映射会很难做。
# herbs.csv 示意 id,name,nature,toxicity huanglian,黄连,苦寒,小毒 gancao,甘草,甘平,无毒 # relations.csv 示意(列:start_id,end_id,rel_type,source) xiaochaihu_tang,huanglian,has_component,伤寒论Cypher 导入语句:
LOAD CSV WITH HEADERS FROM 'file:///herbs.csv' AS row MERGE (h:Herb {id: row.id}) SET h.name = row.name, h.nature = row.nature, h.toxicity = row.toxicity; LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row MATCH (s {id: row.start_id}) MATCH (e {id: row.end_id}) CALL apoc.merge.relationship(s, row.rel_type, {}, {}, e, {source: row.source}) YIELD rel RETURN count(rel);逻辑说明:MERGE比CREATE更稳,避免重复执行脚本时产生重复节点;apoc.merge.relationship保证同一对节点之间不会产生重复的关系边。这里的{source: row.source}是把语料出处(如《伤寒论》《本草纲目》)作为关系属性记录,后续做溯源展示和置信度过滤时可以用到。
3.3 提升检索速度的两个索引配置
图谱只有几千节点时索引无所谓,但到了“中药 2000 + 方剂 5000 + 症状/证候 8000”这个规模,不带索引的查询会把 2~3 秒变成常态。以下是两个必建索引。
CREATE INDEX herb_id_index FOR (h:Herb) ON (h.id); CREATE INDEX formula_name_index FOR (f:Formula) ON (f.name); CREATE INDEX rel_composite_index FOR ()-[r:has_component]-() ON (r.source);参数说明:herb_id_index和formula_name_index支撑实体链接阶段“名称转节点”的精确匹配;rel_composite_index在按来源过滤语料时会有明显加速。注意 Neo4j 的关系索引只能在属性上建,不能跨类型建联合索引,所以“方剂-药材”这种高频边如果查询条件复杂,可以用 APOC 物化一个冗余的query_key属性,而不是硬建复合索引。
3.4 一条核心查询示例:方剂-药材-证候-症状的四跳查询
实现问答系统前,先手工验证图谱的连通性。以下查询找“包含黄连且用于治疗湿热的方剂”:
MATCH (f:Formula)-[:has_component]->(h:Herb {name: '黄连'}) MATCH (f)-[:treats]->(sy:Syndrome {name: '湿热'}) RETURN f.name AS formula, collect(h.name) AS herbs LIMIT 20;如果返回结果为空,优先检查treats关系的方向。很多初版图谱把“方剂-主治-证候”建成(sy)-[:treats]->(f),导致查询方向与建模方向相反。这套查询最终会被问答模块动态拼接,所以建模阶段就要统一关系方向并写进设计文档。
4. 智能问答实现:意图识别、实体链接与查询生成的三段式管线
4.1 先分类问题,再决定走图谱还是向量检索
不是所有问题都适合用知识图谱回答。“黄连的性味是什么”是单点属性查询,走图谱;但“阴虚体质的人冬天适合吃什么”既涉及体质判断又不依赖具体实体,需要结合规则和向量检索。常见做法是把问题先做意图分类,再用条件分支决定下游。
| 意图类型 | 示例问题 | 查询策略 |
|---|---|---|
| 单实体属性 | 黄连的性味是什么 | 图谱单跳属性查询 |
| 多实体关系 | 哪些方剂包含黄连并治疗湿热 | 图谱多跳路径查询 |
| 证候顺推 | 风寒感冒该用什么方剂 | 图谱逆向查询+规则排序 |
| 开放推荐 | 秋天干燥适合泡什么喝 | 向量检索 + 规则过滤 |
意图分类用一个轻量的文本分类模型即可,100~200 条标注数据就能把四分类准确率做到 85% 以上。不建议直接用大模型做意图分类,时延和成本在小规模系统里都不划算。下面代码演示了用scikit-learn的 TF-IDF + 线性 SVM 做分类的基准实现,训练数据为自带的键值对样本。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC from sklearn.pipeline import make_pipeline train_texts = [ "黄连的性味是什么", # single_attr "含甘草并且治咳嗽的方剂", # relation_query "风寒感冒该吃什么", # syndrome_forward "秋天干燥喝什么茶", # open_recommend ] train_labels = ["single_attr", "relation_query", "syndrome_forward", "open_recommend"] vectorizer = TfidfVectorizer(analyzer="char", ngram_range=(1,2)) clf = LinearSVC() pipe = make_pipeline(vectorizer, clf) pipe.fit(train_texts, train_labels) print(pipe.predict(["治疗湿热并且含黄连的方剂"])) # 输出: ['relation_query']参数说明:analyzer="char"是按字切分,适合中文短文本——按词切分时未登录词会被切开,而字 n-gram 能覆盖绝大多数实体名称前缀;ngram_range=(1,2)控制上下文窗口,1-gram 保留单字信息,2-gram 捕获“性味”“方剂”这类常见组合。这个基准模型在真实数据上通常有 80% 以上的准确率,适合作为线上兜底,产出后再用置信度阈值决定是否交给大模型兜底。
4.2 实体链接:别名表 + 模糊匹配,不要一上来就训练模型
实体链接的目标是把用户问题中的“连翘”“银花”映射到图谱标准节点。中医药别名极多(金银花又名忍冬、二花、双花),常见方案是维护一张别名表,配合编辑距离做容错。下面代码演示如何用rapidfuzz做高效模糊匹配。
from rapidfuzz import process, fuzz alias_map = { "金银花": "金银花", "忍冬": "金银花", "二花": "金银花", "双花": "金银花", "连翘": "连翘", "黄连": "黄连", } def link_entity(mention): if mention in alias_map: return alias_map[mention], 1.0 # 候选匹配:只取前5个候选取最高分 result = process.extract( mention, list(alias_map.keys()), scorer=fuzz.WRatio, limit=5 ) if not result: return None, 0.0 best, score, _ = result[0] if score < 75: return None, score return alias_map[best], score print(link_entity("双花")) # 输出: ('金银花', 1.0) print(link_entity("黄莲")) # 用户错别字 # 输出: ('黄连', 89.2) # 实际分数视版本略有差异说明:WRatio对中文短词的容错效果比ratio好,因为它做了大小写和部分比例归一;阈值 75 是经验值,实际项目需要根据测试集统计调整——阈值太高会漏召回,太低会误链接,建议拉取 200 条人工标注问题做阈值扫描。
4.3 查询生成与答案兜底
实体链接完成后,把 4.1 的意图和 4.2 的实体传给一个查询生成器,根据意图模板动态拼 Cypher,而不是让模型直接生成 Cypher。直接让大模型生成 Cypher 在复杂图谱上出错率很高,模板拼接的方式更可控:
def generate_cypher(intent, entities): if intent == "single_attr": herb = entities.get("herb") return ( f"MATCH (h:Herb {{name: '{herb}'}}) " f"RETURN h.nature AS nature" ) elif intent == "relation_query": herb = entities.get("herb") syndrome = entities.get("syndrome") return ( f"MATCH (f:Formula)-[:has_component]->" f"(h:Herb {{name: '{herb}'}})" f"MATCH (f)-[:treats]->(sy:Syndrome {{name: '{syndrome}'}})" f"RETURN f.name AS formula" ) else: return fallback_query # 向量检索代替图谱这段逻辑的边界要特别说明:entities必须做白名单校验,比如herb键的值必须是 4.2 中link_entity返回的标准名,否则用户输入“'; DETACH DELETE ALL; //”会把 Cypher 注入进查询。中医药问答同样面临注入风险,参数化查询是硬要求:Neo4j 驱动支持$param传参,不要用 Python 字符串拼接。
# 正确做法:参数化查询 query = ( "MATCH (f:Formula)-[:has_component]->(h:Herb {name: $herb_name}) " "MATCH (f)-[:treats]->(sy:Syndrome {name: $syndrome_name}) " "RETURN f.name AS formula" ) result = session.run(query, herb_name=herb, syndrome_name=syndrome)查询得到的节点列表还不能直接作为答案输出。常见做法是加一层排序,例如:按关系属性source的典籍权重排序(《伤寒论》高于地方医案),或按图谱度中心性排序(被引用次数多的方剂优先)。这部分逻辑相对独立,可以在服务端用 Python 实现,也可以在 Cypher 里用ORDER BY size((f)<--())完成,取决于图谱规模。
5. 调优落地:5 个高频问题和一套可执行的验证方法
5.1 实体歧义:同名异药与异名同药并存的解法
中医药领域,同名为“独活”的药材在不同典籍中可能指不同基原;同一“白术”还有“生白术”“炒白术”的炮制品差异。只按名称建节点一定会在问答时串答案。常见做法是给节点增加canonical_id(标准药品编码)和source_text(原始出处)两个属性,查询时如果source_text与问题上下文匹配,优先返回该出处对应的节点。如果系统不支持上下文,退而求其次在答案后展示出处,人工判断。
5.2 查询性能退化:深度优先导致的全图扫描
Cypher 在多跳查询时如果起始节点没走索引,会发生全图扫描。排查方法是EXPLAIN看算子:
EXPLAIN MATCH (f:Formula)-[:has_component]->(h:Herb {name: '黄连'}) RETURN f.name;观察输出是否有NodeByLabelScan而非NodeIndexSeek。有前者就说明缺索引或索引没匹配上。另一个隐藏的坑是关系方向不统一导致扫描翻倍——同一模型中(:Formula)-[:has_component]->(:Herb)和(:Herb)<-[:has_component]-(:Formula)是等价的,但如果你同时存在两种方向的边,查询时一定要显式声明方向,否则统计信息会失真。
5.3 知识覆盖率低:用户问题命中空图的应对
质控时用 200 条真实用户问题做测试,最常见的问题是“实体识别出来了,但图上没这个关系”。这本质是三元组抽取阶段就有遗漏,不是问答层能解决的。务实方案是把未命中问题写入日志,定期回灌到 2.3 节的抽取规则里,配合人工标注做增量更新。未命中日志的记录字段建议至少包含:问题、识别出的实体、期望的返回类型(来自意图分类)、实际图谱返回行数。
5.4 一个可复现的评估脚本
问答系统的评估不能只看准确率,要拆成召回、排序和可解释性三部分。下面脚本对一批测试问句依次执行:关系图谱查询 → 得到候选列表 → 与标准答案比对:
python evaluate_kbqa.py \ --question_file test_questions.json \ --graph_uri bolt://localhost:7687 \ --min_hit_score 0.7evaluate_kbqa.py内部逻辑大致为:对每条测试问题执行generate_cypher()→session.run()→ 计算hit@1(首个答案是否命中标准答案)和MRR(倒数排名均值)。评测结果出来后,如果hit@1低于 0.5,优先检查实体链接(错别字容错阈值)而非查询模板;如果hit@1正常但用户满意度低,则问题大概率出在答案排序上,按 4.3 提到的来源优先级与图谱出度加权处理。
5.5 知识更新:图谱会过时,别做成一次性工程
中医药知识图谱会随着语料扩充而变化。建议设计一个简单的版本管理机制:每次构建导出一份graph_version.json,记录数据源列表、抽取规则版本和构建时间。问答日志分析时,如果发现某个问题的答案版本不同,可以快速回溯是哪一版数据引入的变更。实际维护中,我一般按“月”级别重新构建索引,但第 3 章的索引调优参数(如apoc.merge.relationship)不需要改动,这部分是相对稳定的。
最后补一个操作习惯建议:所有 Cypher 查询模板单独存成 YAML 或 JSON 文件,不写在业务代码里。这样意图分类新增时只需加一个模板,不用发版,也方便非工程团队评审查询逻辑。
本文还有配套的精品资源,点击获取