以疾病为中心的知识图谱问答系统:从Neo4j建模到自然语言查询落地 简介这是一套基于Python构建的以疾病为中心的医药领域知识图谱问答系统源码面向医疗信息化研发者、知识图谱与自然语言处理学习者可用于快速掌握医药知识结构化建模与自动问答的实现路径。压缩包共31个文件包含8个Python源码文件覆盖图谱构建、问题分类、问句解析、答案检索、对话交互等核心模块另有9张PNG架构图与效果图、9个TXT词典及说明文档、1个JSON图谱数据文件、1个PPTX演示文稿整体约49MB目录按功能分类清晰。目前已有307人学习浏览。该源码完整呈现了从医学数据准备、知识抽取与图谱构建到基于规则模板的问答匹配与答案生成的全流程其中PYC文件便于直接运行测试JSON数据保留了医药实体关系配合图片与PPT可快速理解模块划分与问答路由设计是学习知识图谱落地应用的合适参考。1. 疾病为中心的医药知识图谱问答系统解决的不只是“搜答案”基于疾病为中心的医药领域知识图谱问答系统核心思路是把医学知识组织成以“疾病”为核心节点的图结构然后让用户用自然语言提问系统把问句翻译成图查询并返回确定答案。它和搜索引擎式问答最大的区别在于搜索给一堆链接问答给一个明确结论比如“阿司匹林和布洛芬能一起吃吗”这种复合用药问题只有顺着图中“药物—相互作用—药物”的边才能推出来。这套方案适合做医疗科普、诊前预问诊、健康管理导诊的团队也适合拿来当医学NLP或知识图谱课程设计的完整骨架。我最初用它是为了处理医疗科普问答后来发现最值钱的部分不是单轮检索而是把语义关系折叠进查询路径里。2. 图谱怎么建疾病节点上的实体、关系与数据清洗2.1 本体设计以疾病为中心先定实体类型和关系方向医学知识图谱最忌讳一上来就堆数据。没有本体约束实体和关系会越抽越乱后面问答层根本无法用统一模板去查。常见做法是以“疾病”为hub节点把症状、药物、检查、科室、并发症、高危人群、饮食注意这些外围实体挂上去。我一般会在设计文档里先画一张关系矩阵明确每一种关系的主体、客体、方向和反向关系名。关系名主体客体用途是否必须建反向临床表现疾病症状查询“XX病有什么症状”否常用药物疾病药物查询“XX病吃什么药”建议建禁忌药物药物疾病查询“谁不能用这个药”是确诊检查疾病检查项目查询“怎么确诊XX病”否建议科室疾病科室查询“XX病挂什么科”否可能并发疾病疾病查询并发症是相互作用药物药物复合用药禁忌查询是关系方向一旦定下来后面写查询模板就省事。反向关系缺了会导致“这个药哪些人禁用”这类从药物出发的查询写不出来。一个可以复制到实践中的标准是每一条正向关系必须问自己一句——用户会不会从另一头问过来会就建反向。2.2 数据清洗别名映射与实体对齐是源头工程构建图谱的第一步不是导入而是清洗。医药领域的数据来源通常有结构化数据库、公开医学百科、药品说明书这三类源的实体命名口径完全不一致。比如“阿莫西林”在说明书写“阿莫西林胶囊”在医学百科写“阿莫西林”在临床指南里可能写“羟氨苄青霉素”。如果不对齐同一个药会在图里出现三个节点。我先维护一份统一的别名映射表用python做标准化处理import csv from collections import defaultdict alias_map defaultdict(set) # 读取人工整理的别名表格式: canonical_name, alias with open(drug_alias.tsv, r, encodingutf-8) as f: for row in csv.reader(f, delimiter\t): canonical, alias row[0].strip(), row[1].strip() alias_map[canonical].add(alias) # 清洗时把所有别名归一到标准名 def normalize_entity(entity: str) - str: entity entity.strip().lower() entity entity.replace(片, ).replace(胶囊, ) entity entity.replace(, ().replace(, )) for canonical, aliases in alias_map.items(): if entity canonical or entity in aliases: return canonical return entity这段代码的逻辑是先把形态词去掉再做精确别名匹配。参数上要注意replace(片, )只适合实体名是“药名剂型”的拼接场景不能对症状名做同样处理否则“偏头痛”会被拆成“偏头”。清洗这一步决定问答准确率的上限我见过太多项目因为一个实体对齐没做好最后NLP跑得再好查询也返回空。2.3 导入Neo4j先建唯一约束再批量写入把清洗后的三元组装进Neo4j顺序有讲究。先建唯一约束再导数据否则重复写入会让图谱出现同实体的多个节点问答查出来会有冗余。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) # 建唯一约束保证每个实体只有一个节点 graph.run(CREATE CONSTRAINT IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE) graph.run(CREATE CONSTRAINT IF NOT EXISTS FOR (m:Medicine) REQUIRE m.name IS UNIQUE) def import_triples(triples): batch_size 500 for i in range(0, len(triples), batch_size): batch triples[i:ibatch_size] tx graph.begin() for subj, rel, obj in batch: s_node Node(Disease, namesubj) o_node Node(Medicine, nameobj) # 合并而非创建避免破坏唯一约束 tx.merge(s_node, Disease, name) tx.merge(o_node, Medicine, name) tx.merge(Relationship(s_node, rel, o_node)) tx.commit()这里batch_size设成500是因为单个事务过大时Neo4j内存占用会突然拉高小批量能减少锁竞争。merge而不是create是这套导入的核心它按指定的唯一键去匹配已存在的节点不产生重复。导入完成后还可以顺手做一次全库校验统计每个类型节点的数量防止关系数明显对不上。3. 问句怎么解析意图识别与实体的先后顺序3.1 先判意图再抽实体比先抽实体再分类更稳医药问答的问法相对固定但实体密度很高。比如“我最近老是咳嗽、低烧会不会是肺炎应该吃什么药”这句话里同时出现症状、疾病、药物三类实体如果先做实体识别模型会把“肺炎”和“咳嗽”都抽出来后面反而不知道用户到底要问什么。我的做法是先做意图分类用模板把问句框架约束住再在意图框架内去填槽位这样实体的作用位置是确定的。意图分类层的设计不需要太重。对医药问答这个封闭域规则模板加关键词匹配就足够扛住大部分流量。例如“XX病有什么症状”“XX病吃什么药”“XX和XX能一起吃吗”“XX挂什么科”这四类高频意图可以直接用正则捕获。import re INTENT_PATTERNS [ (symptom_of_disease, r(.?)(病|症).*?(什么症状|临床表现|有哪些表现)), (medicine_for_disease, r(.?)病.*?(吃什么药|用什么药|用药)), (drug_interaction, r(.?)和(.?)能.*?(一起吃|同服|合用)), (department_for_disease, r(.?)病.*?(挂什么科|去哪个科|看什么科)), ] def parse_intent(question: str) - tuple: for intent, pattern in INTENT_PATTERNS: m re.search(pattern, question) if m: return intent, m.groups() return unknown, ()正则参数要注意两点。第一r(.?)(病|症)里的非贪婪匹配保证“肺炎”这种以炎结尾的疾病也能被捕获而不是吞掉整个句子。第二意图判定之后还要做槽位解析从m.groups()里取实体文本再拿实体文本去对齐图谱中的标准名。这种规则做法在很多开源知识图谱问答项目里仍然保留原因是医学问句的句式变化有限规则维护成本远低于标注样本的训练成本。3.2 实体识别词典匹配优先CRF做兜底规则意图只能给出实体的大概位置真正把“肺炎”和“咳嗽”对齐到图谱节点还要一层实体识别。我首先做基于AC自动机的词典匹配把所有标准实体名和别名构建成词典树再对问句做最长匹配。优先选择词典因为药品说明书、医学术语的拼写高度固定这比训练模型可预期得多。from ahocorasick import Automaton def build_automaton(entity_list): automaton Automaton() for idx, entity in enumerate(entity_list): automaton.add_word(entity, (idx, entity)) automaton.make_automaton() return automaton # 返回最长匹配结果避免“阿莫西林”被切成“阿莫”和“西林” def extract_entities(question: str, automaton) - list: hits [] for end_index, (idx, entity) in automaton.iter(question): start_index end_index - len(entity) 1 hits.append((start_index, end_index, entity)) # 按起始位置排序并做重叠合并保留最长片段 hits.sort() return merge_overlap(hits)这段代码的关键在merge_overlap重叠匹配时保留覆盖范围更长的那个实体。比如“阿莫西林胶囊”同时会命中“阿莫西林”和“阿莫西林胶囊”后者才是真正的标准名。这个环节的参数主要是词典规模一般医药项目实体词典在几千到两万之间构建自动机仅需几秒单句匹配在毫秒级。词典匹配查不到的实体用CRF兜底。我在实践中给CRF喂的是BIO标签序列特征模板只用当前字、前一字、后一字的词性和是否为词典词。这个轻量特征配置在疾病、症状实体上的F1大约能到0.86够用。如果你手上有标注数据再考虑换成BERT-NER否则模型训练和推理成本都不合算。3.3 槽位填充与实体归一化抽出来的实体是“阿莫西林胶囊”图谱里的标准名是“阿莫西林”这中间还要过一遍归一化。把上一步抽取的实体文本重新送进normalize_entity函数统一成标准名再去图谱里查询。如果归一化后在图谱中找不到对应节点说明词典没覆盖此时我会把问句原样记录下来归入“未登录实体”集合定期补充别名表。4. 从自然语言到Cypher查询模板、参数和答案生成4.1 查询模板库每个意图对应一条参数化Cypher问答系统的精华在查询模板层。意图识别确定了用户要什么实体识别确定了对谁查模板则确定怎么从图里取答案。每条模板都是参数化的Cypher语句参数位用{entity}占位执行前再替换。// 查询某疾病常用药物先匹配疾病节点再取指向药物的边 MATCH (d:Disease {name: $disease})-[:常用药物]-(m:Medicine) RETURN m.name AS medicine_name, m.usage AS usage, m.contraindication AS contraindication LIMIT 10 // 查询两种药物能否同服检查是否存相互作用关系 MATCH (m1:Medicine {name: $drug1}), (m2:Medicine {name: $drug2}) OPTIONAL MATCH (m1)-[r:相互作用]-(m2) RETURN m1.name AS drug1, m2.name AS drug2, CASE WHEN r IS NULL THEN 无已知相互作用 ELSE r.description END AS interaction第一条模板的LIMIT 10是防爆机制有些疾病关联的药物动辄几十种全部返回既慢也不友好。第二条模板是问答系统最能出价值的一类查询把实体对的相互作用关系返回成一句话比在搜索框里翻十几个链接强得多。4.2 填参、执行与异常过滤模板说清楚了还要有代码把这些步骤串起来这里特别注意对用户输入做参数化查询不要手工拼接字符串。如果直接把问句拼进Cypher一个包含引号的反引号片段就可能让查询炸掉。from neo4j import GraphDatabase class KBQAEngine: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) self.templates { medicine_for_disease: ( MATCH (d:Disease {name: $disease})-[:常用药物]-(m:Medicine) RETURN m.name AS medicine_name, m.usage AS usage LIMIT 10 ), drug_interaction: ( MATCH (m1:Medicine {name: $drug1}), (m2:Medicine {name: $drug2}) OPTIONAL MATCH (m1)-[r:相互作用]-(m2) RETURN m1.name AS drug1, m2.name AS drug2, CASE WHEN r IS NULL THEN 无已知相互作用 ELSE r.description END AS interaction ), } def answer(self, intent: str, slots: dict) - dict: if intent not in self.templates: return {error: unsupported_intent} cypher self.templates[intent] with self.driver.session() as session: result session.run(cypher, **slots) records [record.data() for record in result] return {records: records, count: len(records)}slots字典里的键必须和Cypher参数名一一对应否则Neo4j会抛MissingParameter。我在实际项目里还会加一层参数校验比如slots里的实体值不得超过20个字符超过就直接拒绝——医药实体名普遍较短这能挡住异常输入也能挡住无意义的扫描查询。参数化查询带来的另一层好处是Neo4j会缓存执行计划同一个模板重复查询时速度会比拼接字符串快不少。4.3 答案生成直接给结论而不是给数据图查询返回的是记录列表但用户不是来读Cypher结果的。答案生成要按意图把记录整理成自然语言。我的处理方式分三种属性型答案直接拼句子列表型答案做排序截断路径型答案把路径上的节点串成一段话。以“阿司匹林和布洛芬能一起吃吗”为例查询结果只有一行answer逻辑把它渲染成“阿司匹林与布洛芬存在相互作用两药同属非甾体抗炎药合用会增加胃肠道出血风险不建议同时服用。”如果图里没有这条关系也不要直接说“没有”而是返回“暂未收录该药物的相互作用信息”把未知和否定区分开这在医疗场景里是底线要求。5. 避坑与排查医药知识图谱问答系统的五类高频问题5.1 实体别名归一化失配导致答案为空现象问“布洛芬缓释胶囊”有结果问“芬必得”返回空。原因品牌名、通用名、别名没有同步进词典和归一化表图里只存在标准名节点。解决把品牌名统一映射到通用名在build_automaton前把所有别名加进词典normalize_entity里做一层“品牌名-通用名”的转换。排查时可以随机抽样问句把未命中实体的文本全部打印出来人工补别名表连续补两周后覆盖率能从70%拉到95%以上。5.2 关系方向缺失反向查询全部落空现象“这个药哪些人禁用”能查到“这个病不能用什么药”查不到。原因图谱只建了“疾病-禁忌药物→药物”的方向没建反向关系Cypher无从匹配。解决在schema设计阶段就为每条关系决定是否建反向导入时成对写入。排查方法是列出所有关系类型人工确认是否覆盖了用户最大概率的反向问法。5.3 长问句多实体同时命中槽位填充错乱现象“我高血压还有糖尿病能不能吃这种感冒药”被解析成用降压药治疗感冒。原因意图模板中(.?)非贪婪捕获了“高血压还有糖尿病”然后当成单一疾病实体。解决在槽位填充阶段对捕获文本做二次切分识别“还有”“以及”“并且”等连接词把复合实体拆成多个查询条件分别查询后合并结果。5.4 术语包含导致匹配过度答案张冠李戴现象问“慢性胃炎”返回的结果中包含“慢性胃炎伴糜烂”的用药但用户实际问的是一般慢性胃炎。原因AC自动机最长匹配时“慢性胃炎”作为更短词被“慢性胃炎伴糜烂”覆盖掉导致意图对应的实体指向了更具体的病种。解决在匹配时区分精确匹配和前缀匹配如果问句文本和词典词完全相等优先用精确匹配只有问句中出现了额外修饰词时才用最长匹配。5.5 图谱膨胀后查询变慢Cypher超时现象数据量从几千条涨到几十万条后常见查询从几十毫秒涨到几秒前端直呼卡死。原因节点没有按类型加索引MATCH走了全库扫描。解决为所有高频查询的节点属性创建索引或约束比如Disease.name和Medicine.name。排查方法是开启Neo4j的PROFILE命令看查询计划里是不是出现了NodeByLabelScan出现就是缺索引。PROFILE MATCH (d:Disease {name: $disease})-[:常用药物]-(m:Medicine) RETURN m.name LIMIT 10执行后会看到计划算子如果显示NodeByLabelScan而不是NodeUniqueIndexSeek那就说明这个属性的索引没建或没生效。6. 让问答系统真正可用测试集、指标与一条多跳进阶路径这套系统跑通后最容易被忽视的就是验证环节。我一般会准备一个固定问答测试集按意图分类统计元素例如“疾病症状”类100问、“用药推荐”类100问、“药物相互作用”类50问每一条都要有标准答案和对应的查询路径。每次改动图谱或修改模板就用这个测试集回归一遍计算三项指标准确率即返回的答案与标准答案一致的比例无答案率即图谱里确实查不到信息的比例落空率即图谱里有但没查出来的比例。准确率尽量往90%以上拉落空率控制在5%以内。进阶路径我优先推“多跳问答”。单跳查询只匹配一条边比如“疾病→药物”但用户真正关心的问题往往需要两步比如“糖尿病患者感冒了能吃什么感冒药”要先从“糖尿病”找到禁忌药物集合再拿感冒药候选去排除这个集合。这种两条边以上的推理不能靠堆模板解决更好的方式是做一个简单的知识图谱路径排序模块把所有两步可达路径枚举出来按关系可信度打分。这个阶段仍然不需要上大型语言模型。我个人习惯是把“未登录实体”和“未覆盖意图”两个日志每天看一眼坚持三个月系统会越来越结实。这套方案的边界也很清楚它只能回答被建模进图谱的知识对没建立的关系给不出答案。真要把它用在临床辅助场景必须有人工审核兜底。希望这些实践能帮你在自己的医疗知识图谱问答系统上少走弯路。本文还有配套的精品资源点击获取