ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于知识图谱的中医药智能问答系统:从Neo4j建模到Cypher查询实践

基于知识图谱的中医药智能问答系统:从Neo4j建模到Cypher查询实践 简介面向中医药领域知识图谱与智能问答系统的学习者这份资源是一套完整的Python项目实现覆盖知识图谱构建、实体识别、实体链接、路径过滤与推理等关键流程适合作为相关大作业或毕业设计的参考。包内共11个文件包含9个Python脚本、1张问答流程示意图和1份说明文档压缩包大小仅123KB脚本按功能划分成图谱构建、命名实体识别、实体链接、路径特征提取与问答应答等模块结构清晰便于快速定位与二次开发。项目从中医药知识出发通过实体识别与关系抽取构建知识图谱并借助路径特征完成问题解析与答案生成直观展示了知识图谱在智能问答中的落地方式。已有147人学习下载对希望掌握知识图谱项目实战方法的初学者及开发者具有较好的参考价值。1. 中医药领域知识图谱的智能问答系统先别急着写模型做基于中医药领域知识图谱的智能问答系统最容易犯的错是先把知识图谱堆起来再用一个通用 BERT 模型去“猜答案”。实际落地时你会发现用户问“连花清瘟适合治什么”和“哪些药能治失眠”背后是两种完全不同的查询结构前者是单实体查属性后者是实体加条件约束。把“答案长什么样”列清楚再倒推实体、关系和查询模板才能避免图谱建完却答非所问。本文面向要交付可用 API 的后端和 NLP 工程师按知识图谱建模、Neo4j 入库、自然语言到 Cypher、多跳答案生成这条链路展开最后给出一套可执行的精准评测方法。2. 知识图谱构建先定实体关系再抽文本2.1 实体类型选少了问答模板后面就要返工中医药领域知识图谱的实体类型不能只给“中药、疾病”两类。用户在问答里的表达很细可能是症状“失眠”、证候“肝郁脾虚”也可能是穴位“足三里”和方剂“四君子汤”。我一般会先设七类核心实体中药、方剂、疾病、症状、证候、经络、穴位。药材的“产地”“炮制品”先不建模等有明确查询需求再加因为每多一类实体词典匹配的歧义、Neo4j 索引的维护、问句意图的分类成本都会上升。实体类型最小属性集典型问答用途中药名称、别名、性味、归经“黄连是温性还是寒性”方剂名称、出处、功效“参苓白术散包含哪些药”疾病名称、别名“什么药治不寐”症状名称、部位“咳嗽应该用什么方”证候名称、辨证要点“肝郁脾虚怎么调理”经络名称“胃经上有哪些穴位”穴位名称、定位“足三里属于哪条经络”这张表是后面所有查询模板的主键。实体类型决定节点标签属性决定RETURN的结果字段。如果后期发现“方剂”和“中成药”经常被用户混着问不要急着拆成两类先加一个drug_form属性更划算否则同一问题要查两套标签。2.2 关系方向是问答查询的“谓语”实体的关系列表要和问答句式一一对应。用户问“什么药治失眠”知识库中就必须有“中药”到“疾病”的TREATS关系用户问“四君子汤有什么药”就必须有“方剂”到“中药”的CONTAINS关系用户问“足三里在哪个经”就必须有“穴位”到“经络”的BELONGS_TO关系。关系不要过度抽象成 “关联”“相关” 这类泛化词否则答解释时没法给出确定路径。抽取关系时我最常用的是先跑“关系清单”再写抽取器。比如从药典条目“黄连主治心烦、失眠归心、肝经”里需要抽出(黄连, TREATS, 失眠)和(黄连, GOES_TO, 心经)。注意“心烦”和“失眠”之间是顿号分隔同一个主体可以沿着同一个关系产出多个对象所以三元组数量往往远大于原始行数。2.3 用 Python 从半结构化文本中抽三元组中医数据源大量是半结构化的表格或条文。一段常见的药材条目长这样黄连主治心烦、失眠、口疮归心经、肝经。最小抽取脚本可以按行匹配前缀抽成三元组def extract_triples(entry: str): triples [] lines entry.strip().split(\n) herb lines[0].split()[0] for line in lines[1:]: if line.startswith(主治): targets line[2:].replace(。, ).split(、) relation TREATS elif line.startswith(归): targets line[1:].replace(。, ).split(、) relation GOES_TO else: continue for target in targets: if not target: continue triples.append((herb, relation, target)) return triples这段代码先把第一行“黄连”当作主体再逐行判断关系名和对象列表。参数上要注意两个点一是line[2:]的切片位置依赖前缀长度前缀“主治”是两个字所以从第 2 个索引截取二是“归心经”里的“归”是一句话的核心动词不能写成“归经”。实际药典里还有“功效”“禁忌”等字段需要在if分支里继续加但结构不变。抽取完成后建议先把三元组写成 CSV不要急着入库。字段设为subject,relation,object,subject_type,object_type后续无论是做唯一约束还是在 Neo4j 里排重都会方便很多。2.4 别名和同义词在抽取阶段就要归一“不寐”和“失眠”、“拉肚子”和“泄泻”、“胸口痛”和“胸痹”在临床上是同一件事。实体归一最好在进入图谱前完成而不是问答时临时做映射。我一般会在项目里维护一个alias_map.json把所有别名指向标准名alias_map {不寐: 失眠, 拉肚子: 泄泻, 腰疼: 腰痛} def normalize_name(name: str) - str: return alias_map.get(name, name)在extract_triples的对象处理里调用normalize_name保证写入 Neo4j 的实体名称永远是标准名。这样查询端只需要面对一套实体 IDCypher 里不用做OR匹配索引命中率和答案去重都会更稳定。如果同义词太多再考虑用 SimHash 或向量召回做自动归一但词典方案足够支撑第一版上线。3. 用 Neo4j 建库并验证知识图谱绕开“只显示25个标签”的误判3.1 先建唯一约束再批量导入把三元组导入 Neo4j 前先要确定实体唯一性。常见的坑是一个实体同时被两个关系创建产生重名节点导致问题“黄连到底是一个还是两个”。如果统一使用MERGE配合唯一约束可以避免大部分重复。在 Neo4j 5.x 中可以这样建约束CREATE CONSTRAINT entity_name_type_unique IF NOT EXISTS FOR (n:Entity) REQUIRE (n.name, n.type) IS UNIQUE;这条约束保证同一个name和type组合只能存在一个Entity节点。type在这里是属性不是 Neo4j 的 label好处是可以动态写入缺点是查询时无法直接用(:Herb)语法必须写(:Entity {type:中药})这属于清晰度换灵活性的取舍。导入时我把 CSV 按关系类型分组分别执行静态 MERGEREL_MAP { TREATS: TREATS, GOES_TO: GOES_TO, CONTAINS: CONTAINS, BELONGS_TO: BELONGS_TO, } def import_triples_by_relation(driver, relation, rows): rel_type REL_MAP[relation] with driver.session() as session: for subj, obj, stype, otype in rows: session.run( f MERGE (s:Entity {{name: $subj}}) SET s.type $stype MERGE (o:Entity {{name: $obj}}) SET o.type $otype MERGE (s)-[:{rel_type}]-(o) , subjsubj, objobj, stypestype, otypeotype )关系类型不能由用户直接拼接进 Cypher所以relation必须取REL_MAP的白名单值。每次session.run在一个事务里完成两组MERGE和一组关系创建小数据量完全够用。如果数据量到几十万条建议把 500 条一批用UNWIND写入或者直接用LOAD CSV WITH HEADERS减少 Python 和数据库之间的往返次数。3.2 用 count 语句检查数据不让图可视化干扰判断很多同事第一次看到 Neo4j Browser 里的图发现画布上只有 25 个带标签的节点就以为导入失败。这不是数据丢了而是 Neo4j Browser 的图渲染默认只显示有限的标签和节点。判断知识图谱有没有建对应该用 count 和 sample 查询而不是肉眼看画布。MATCH (n:Entity) RETURN n.type AS type, count(*) AS cnt ORDER BY cnt DESC;返回结果能直接看到每类实体的数量占比。如果“中药”数量为 0那问题一定出在导入脚本的subject_type字段如果某类实体数量过多则可能分词把“心”和“心烦”抽成了两个对象。提示Neo4j Browser 中“只显示 25 个标签”不影响数据查询结果数据完整性用count(*)判定不要被可视化画布干扰。为了快速定位异常可以整理一张检查清单检查目标查询语句失败时常见原因全库实体是否存在MATCH (n:Entity) RETURN count(n)导入事务未提交关系是否重复MATCH (s)-[r]-(o) RETURN count(r)MERGE 使用了动态属性未去重孤立节点数量MATCH (n:Entity) WHERE NOT (n)--() RETURN n.name LIMIT 20关系对象实体名不一致别名是否归并MATCH (n:Entity {name:不寐}) RETURN n.name抽取阶段未调用 alias_map孤立节点是知识图谱问答里最容易出隐藏 bug 的地方。比如“黄连”节点存在但关系里写到“黄蓮”这个繁体字程序查询“黄连”时就永远匹配不到。导入后跑一次孤立节点查询能提前发现这类不一致。3.3 导入后先做一次端到端 Cypher 抽样入库完成后不要急着写问答接口先用几条 Cypher 直接验证用户会问的问题。比如MATCH (h:Entity {type:中药})-[:TREATS]-(d:Entity {name:失眠}) RETURN h.name AS herb LIMIT 10;把“失眠”改成“不寐”再执行一次如果结果不同说明别名归一没生效。这一步骤本质上是把最核心的 20 个问题映射成 Cypher 回归用例所有功能开发都围绕这些查询展开。它不需要 UI只需要一个.cypher文件放在项目里作为后续问答准确率的基线。4. 智能问答系统把自然语言问题翻译成 Cypher4.1 意图清单控制问答边界智能问答系统不是万能聊天机器人第一步是定义意图。意图在这里代表“问题结构”不是文本情感。中医药问答常见的意图不外乎以下几类意图示例问题需要抽取的实体输出内容treat_by_disease什么药能治失眠疾病中药列表treat_by_symptom咳嗽吃什么方剂症状方剂列表formula_component麻黄汤里有哪些药方剂中药列表herb_property黄连是温性还是寒性中药属性值point_meridian足三里属于哪条经穴位经络名称shared_effect哪些药能治失眠和心悸疾病 x2中药交集意图分类我建议先用规则不要一上来就训练模型。规则表维护成本低而且每个意图都对应一个查询模板规则准确率能到 90% 以上。设计意图时注意treat_by_disease和treat_by_symptom的页面句式几乎一样区分的依据是实体类型而不是关键词“治”还是“缓解”。4.2 实体识别词典匹配和同义词归一实体抽取阶段把问题里的名词映射到图谱标准名。中医实体词长从两个字到四个字都有直接用 jieba 切词容易被切碎我一般会先做最大正向匹配ENTITY_DICT { 失眠: disease, 不寐: disease, 黄连: herb, 足三里: point, 胃经: meridian, } def max_forward_match(question: str, entity_names: set[str]) - list: results [] i 0 max_len 4 while i len(question): matched False for end in range(min(i max_len, len(question)), i, -1): seg question[i:end] if seg in entity_names: results.append(seg) i end matched True break if not matched: i 1 return results参数max_len设成 4是因为“小柴胡汤”“龙胆泻肝丸”这类方剂名长度在 4 字左右。单个字的病名如“痹”要靠词典覆盖不能依赖长度。得到原始词后再用alias_map转成标准名比如“足三里穴”在词典里写“足三里”匹配时至少要加一条别名映射。实体抽取要返回实体类型列表后面查询模板要根据类型判断该把词填到哪个槽位。4.3 从意图模板到 FastAPI 接口得到意图和实体之后可以用一个函数生成 Cypher。FastAPI 接口示例from fastapi import FastAPI from pydantic import BaseModel from neo4j import GraphDatabase app FastAPI() driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) class Question(BaseModel): q: str def build_cypher(intent: str, entities: dict): if intent treat_by_disease: disease entities.get(disease, [])[0] if not disease: return None, None cypher MATCH (h:Entity {type:中药})-[:TREATS]-(d:Entity {type:疾病}) WHERE d.name $disease RETURN h.name AS answer LIMIT 20 return cypher, {disease: disease} if intent formula_component: formula entities.get(formula, [])[0] if not formula: return None, None cypher MATCH (f:Entity {type:方剂})-[:CONTAINS]-(h:Entity {type:中药}) WHERE f.name $formula RETURN collect(h.name) AS answer return cypher, {formula: formula} return None, None app.post(/qa) def qa(question: Question): entities max_forward_match(question.q, set(ENTITY_DICT.keys())) intent classify_intent(question.q) cypher, params build_cypher(intent, entities) if not cypher: return {answer: [], intent: intent, entities: entities} with driver.session() as session: result session.run(cypher, **params).data() return {answer: result, intent: intent, entities: entities}build_cypher里先判断实体槽位是否为空避免传入空字符串导致 Cypher 匹配到错误节点。接口返回结构化结果前端可以根据intent决定展示成列表还是属性卡片。这套方案的好处是每个查询模板都是显式代码出错了可以单测不需要去渲染一个隐式模型的决策过程。5. 多跳问答和答案生成从图谱路径到能读的句子5.1 直接属性问答用 OPTIONAL MATCH用户问“黄连的功效是什么”时实体是黄连意图是查效果。直接写MATCH (h:Entity {name: $herb, type: 中药}) OPTIONAL MATCH (h)-[:HAS_EFFECT]-(e:Entity) RETURN h.name AS herb, collect(DISTINCT e.name) AS effects;这里用OPTIONAL MATCH是防止知识库里没有HAS_EFFECT关系时连主实体黄连都被过滤掉。不要小看这个细节实测中很多中药只有归经没有功效如果写成普通MATCH最终会返回空结果用户会以为系统不认识黄连。5.2 交集型多跳同时满足多个条件有些问题不能用一条路径遍历表示比如“哪些药既能治失眠又能治心悸”。如果写成变长路径会变成“中间经过一个疾病再回来”的错误语义。正确的做法是两条边同时约束同一实体MATCH (h:Entity {type:中药})-[:TREATS]-(d1:Entity {name:失眠}) WITH h MATCH (h)-[:TREATS]-(d2:Entity {name:心悸}) RETURN DISTINCT h.name AS herb;第一个 MATCH 选出所有治失眠的药第二个 MATCH 在这个集合里筛掉不治心悸的药。WITH h相当于把中间结果传给下一段查询它还能拆成多条 Cypher 在应用层做交集。如果问题里出现三个症状就增加第三个 MATCH逻辑不变。这种多跳问答是知识图谱相对倒排索引的最大优势候选不是靠文本相似度排序而是靠图谱路径上的关系约束。5.3 候选排序和空结果兜底答案返回多个时直接用图谱里的连接度来排序是稳定的做法MATCH (h:Entity {type:中药})-[:TREATS]-(d:Entity {name:$disease}) RETURN h.name AS herb, size((h)-[:TREATS]-()) AS treat_count ORDER BY treat_count DESC, h.name LIMIT 10;treat_count表示这个中药关联了多少疾病关联越多的药在常识里普适性越高但也要小心它可能在用户具体场景里不够精准。业务上如需更严格可以在节点加入source_weight属性来自权威教材的权重高于网络百科排序改为ORDER BY h.weight DESC。空结果出现时不要直接返回“无答案”先检查别名实体是否已归一再放宽为CONTAINS模糊匹配MATCH (h:Entity {type:中药})-[:TREATS]-(d:Entity) WHERE d.name CONTAINS $keyword RETURN DISTINCT h.name AS herb LIMIT 10;5.4 模板答案与大模型润色的边界答案生成我分三档纯模板、图谱结果给大模型润色、大模型自由生成。中医药场景下推荐第一档和第二档组合。生成方式可解释性稳定性适合场景模板拼接高高对外 API、生产系统LLM 润色给定结果中中内部知识库LLM 自由生成低低不建议医药问答使用模板拼接示例def format_answer(intent: str, rows: list[dict]) - str: names [row[answer] for row in rows] if intent treat_by_disease: return 可以关注这几种中药{}。.format(、.join(names[:5])) return 查到 {} 条结果{}。.format(len(names), 、.join(names[:5]))如果接大模型必须把图谱查询结果拼进 prompt并限制模型只能改写不允许新增医学事实。同时保留图谱里的关系路径让用户点开“为什么推荐这个药”时能看到黄连 - TREATS - 失眠的完整链路。6. 上线前评测智能问答准确率的四件事6.1 人工准备标准答案集没有标准答案集准确率就是空谈。我会先准备 200 到 300 条问题覆盖全部意图每条问题记录“标准答案实体名”。例如“什么中药能治失眠”的标准答案可以是一条集合不要求顺序完全一致只要求返回的集合与标准集合重叠度符合阈值。评测脚本可以写成 pytestdef test_treat_insomnia(): resp client.post(/qa, json{q: 什么中药能治失眠}) answer_names {row[answer] for row in resp.json()[answer]} expected {酸枣仁, 柏子仁, 远志} assert len(answer_names expected) / len(expected) 0.5这个用例把“至少有 50% 的标准答案被召回”当作通过线。刚开始跑不过很正常失败原因往往集中在实体识别和别名归一。6.2 失败日志聚类后先查实体表再调查询把线上或评测失败的问题落到日志表按失败类型聚类失败现象常见原因调整方向实体没识别出来词典缺失别名扩充 alias_map实体识别错位病名与证候同名增加上下文槽位校验问题答非所问意图分类规则冲突调整关键词优先级答案为空图谱关系缺失检查孤立节点6.3 用端到端回归脚本锁住图谱变更图谱每次更新都必须跑一遍评测集。把 Cypher 基线查询和 pytest 放在同一个 CI 项目里任何新的实体别名或关系写入如果导致老问题回退就要在合并前暴露。智能问答系统的准确率不是靠一次调优而是靠这组用例持续累积。可解释性也应该纳入评测答案里必须能看到实体名和关系名不允许只返回一段黑盒文本。本文还有配套的精品资源点击获取
返回列表