简介面向需要完成知识图谱相关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这类数据属性建议用字符串而非枚举原因是中医典籍里的表述差异极大“微苦”“苦而微寒”枚举会丢失原始语义。提示如果你的语料只覆盖某个子领域比如只做中药-方剂不做经络本体定义可以只保留 45 个类不要为“完整性”硬扩。本体越大后续人工校验成本越高。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 里做需要把方剂、药材、功效、禁忌拆成 56 张表再用 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”这个规模不带索引的查询会把 23 秒变成常态。以下是两个必建索引。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 先分类问题再决定走图谱还是向量检索不是所有问题都适合用知识图谱回答。“黄连的性味是什么”是单点属性查询走图谱但“阴虚体质的人冬天适合吃什么”既涉及体质判断又不依赖具体实体需要结合规则和向量检索。常见做法是把问题先做意图分类再用条件分支决定下游。意图类型示例问题查询策略单实体属性黄连的性味是什么图谱单跳属性查询多实体关系哪些方剂包含黄连并治疗湿热图谱多跳路径查询证候顺推风寒感冒该用什么方剂图谱逆向查询规则排序开放推荐秋天干燥适合泡什么喝向量检索 规则过滤意图分类用一个轻量的文本分类模型即可100200 条标注数据就能把四分类准确率做到 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(analyzerchar, ngram_range(1,2)) clf LinearSVC() pipe make_pipeline(vectorizer, clf) pipe.fit(train_texts, train_labels) print(pipe.predict([治疗湿热并且含黄连的方剂])) # 输出: [relation_query]参数说明analyzerchar是按字切分适合中文短文本——按词切分时未登录词会被切开而字 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()), scorerfuzz.WRatio, limit5 ) 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 ( fMATCH (h:Herb {{name: {herb}}}) fRETURN h.nature AS nature ) elif intent relation_query: herb entities.get(herb) syndrome entities.get(syndrome) return ( fMATCH (f:Formula)-[:has_component]- f(h:Herb {{name: {herb}}}) fMATCH (f)-[:treats]-(sy:Syndrome {{name: {syndrome}}}) fRETURN 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_nameherb, syndrome_namesyndrome)查询得到的节点列表还不能直接作为答案输出。常见做法是加一层排序例如按关系属性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()→ 计算hit1首个答案是否命中标准答案和MRR倒数排名均值。评测结果出来后如果hit1低于 0.5优先检查实体链接错别字容错阈值而非查询模板如果hit1正常但用户满意度低则问题大概率出在答案排序上按 4.3 提到的来源优先级与图谱出度加权处理。5.5 知识更新图谱会过时别做成一次性工程中医药知识图谱会随着语料扩充而变化。建议设计一个简单的版本管理机制每次构建导出一份graph_version.json记录数据源列表、抽取规则版本和构建时间。问答日志分析时如果发现某个问题的答案版本不同可以快速回溯是哪一版数据引入的变更。实际维护中我一般按“月”级别重新构建索引但第 3 章的索引调优参数如apoc.merge.relationship不需要改动这部分是相对稳定的。最后补一个操作习惯建议所有 Cypher 查询模板单独存成 YAML 或 JSON 文件不写在业务代码里。这样意图分类新增时只需加一个模板不用发版也方便非工程团队评审查询逻辑。本文还有配套的精品资源点击获取