基于Python的医疗知识图谱问答系统:从实体识别到Neo4j实现 📅 发布时间:2026/9/17 13:07:10 👁 浏览次数: 简介这是一套面向医疗领域知识图谱问答系统开发者的Python源码项目适合具备一定Python基础、希望学习知识图谱与自然语言处理落地应用的读者。资源完整覆盖数据预处理、医疗知识图谱构建、用户提问语义分析、实体识别、图谱查询与结果展示的全流程包含build_medicalgraph.py、question_classifier.py、answer_search.py等核心模块以及病症、药物、食物、检查科室等分类词典和医疗JSON数据便于直接运行与二次扩展。压缩包共26个文件以8个py源码、8个txt词典与说明、5个xml工程配置及2个json数据文件为主整体大小15.54MB目录结构清晰适合作为课程设计、毕业设计或医疗问答产品原型的参考实现。目前已有184人学习下载对想快速搭建领域知识图谱问答系统、理解图数据库与规则匹配机制的开发者有较好的借鉴价值。1. 基于python的医疗知识图谱自动问答系统先搭骨架再填肉医疗知识图谱自动问答系统本质上是把“医生脑子里的分诊逻辑”翻译成机器能检索的图结构再用自然语言入口把图里的知识捞出来。和通用问答不同医疗场景对实体边界和关系类型极其敏感——“头疼”在神经内科和耳鼻喉科是完全不同的两条路径错一个实体答案就偏到另一个科室去了。做这套系统我一般把技术栈拆成五块基于python的爬虫或人工整理来灌数据、Neo4j或gStore存图谱、HanLP或LTP做实体识别、AC自动机做实体归一化、最后用一个基于模板匹配加BM25检索引擎的问答层把结果拼出来。源码里最值钱的不是某个算法有多新而是那些经过标注的实体词典和问句模板这两样才是真正让系统从demo变成能用的关键。适合谁读准备做医疗知识中台、病历结构化、或者想从零搭一个领域问答原型的人。2. 医疗知识图谱的数据建模与实体关系设计决定问答上限2.1 从医学文本到三元组先定schema再做抽取医疗知识图谱的schema比通用图谱收敛得多。我常用的七类实体是疾病、症状、检查、药物、科室、手术、饮食建议关系则控制在十类以内比如“疾病-表现出-症状”、“疾病-需做-检查”、“疾病-常用-药物”、“药物-禁忌-疾病”、“科室-负责-手术”等。不要一上来就学通用知识图谱堆三十种关系医疗数据噪声高关系种类越多实体标注成本越高问答时误匹配的概率也越大。取一段真实病历文本做演示假设已经清洗成utf-8编码的txttext 患者因持续性胸痛伴大汗三小时入院心电图提示ST段抬高急诊行PCI术术后服用阿司匹林与氯吡格雷。用python跑一遍构建流程核心是预先定义好实体词典再基于词典做最长匹配。词典格式用json结构如下{ 疾病: [冠心病, 心肌梗死, 稳定性心绞痛], 症状: [胸痛, 大汗, 心悸], 检查: [心电图, 冠脉造影, 肌钙蛋白], 药物: [阿司匹林, 氯吡格雷, 他汀类] }匹配代码用python写注意优先匹配长词防止“心肌梗死”被拆成“心肌”和“梗死”两个无效实体import json from typing import List, Tuple class MedicalEntityMatcher: def __init__(self, dict_path: str): with open(dict_path, r, encodingutf-8) as f: self.entity_dict json.load(f) # 构建前缀树提升匹配效率 self.trie {} self.entity_type_map {} for etype, entities in self.entity_dict.items(): for ent in entities: node self.trie for char in ent: if char not in node: node[char] {} node node[char] self.entity_type_map[ent] etype def match_longest(self, text: str) - List[Tuple[str, str, int]]: results [] i 0 while i len(text): node self.trie matched None matched_len 0 j i while j len(text): if text[j] not in node: break node node[text[j]] j 1 # 当前位置有完整实体则记录继续尝试更长匹配 if .join(text[i:j]) in self.entity_type_map: matched .join(text[i:j]) matched_len j - i if matched: results.append((matched, self.entity_type_map[matched], i)) i matched_len else: i 1 return results这里用了前缀树来做词典匹配时间复杂度从暴力匹配的O(n*m)降到接近O(n)。匹配结果里带着实体类型和起始位置后续做三元组构建时可以直接用。医疗场景里实体词典的质量直接决定实体识别上限比模型参数还重要。建议用《医学主题词表》的常用子集做种子词表再配合临床病历里的高频词扩充。注意区分“症状”和“体征”前者是患者主诉、后者是医生查体发现问答时“我胸痛”和“你胸骨压痛”走的不是同一套查询逻辑混在一起会答错。2.2 关系抽取用规则模板兜底别一开始就上BERT很多团队拿到源码第一反应就是训练一个关系抽取模型实际效果往往不如规则。原因是医疗文本里的关系表达高度固定“患者因XX入院”结构里XX大概率是症状“行XX术”后面的XX基本是手术“医嘱XX”后面跟的是药物。用依存句法分析再套模板准确率能做到85%以上而训练一个医疗BERT关系抽取模型没有一万条标注数据根本起不来。源码里给的做法就是正则加依存句法混合import re import spacy nlp spacy.load(zh_core_web_md) def extract_relations(entity_pairs, sent_text): doc nlp(sent_text) relations [] for subj, subj_type, subj_pos in entity_pairs: for obj, obj_type, obj_pos in entity_pairs: if subj obj: continue between sent_text[subj_pos len(subj): obj_pos] if re.search(r(伴有|出现|表现出|有), between): relations.append((subj, 表现出, obj)) elif re.search(r(行|接受|做了), between): relations.append((subj, 需做, obj)) elif re.search(r(口服|服用|静滴|静推), between): relations.append((subj, 常用, obj)) elif re.search(r(转入|转入到|转至), between): relations.append((subj, 转移至, obj)) return relations注意这里的between字符串是取两个实体之间的文本片段用正则去匹配关系触发词。如果两个实体距离太远触发词就不太可靠一般超过15个字符的建议丢弃。spaCy的依存句法在这里的作用不是直接抽关系而是过滤无效配对——比如把主谓宾结构里明显不搭的名词对去掉。如果你的环境装不了spaCy退而求其次用jieba分词加自写规则也能跑通只是recall会低一些。2.3 Cypher写入Neo4j批量提交避免OOM实体和关系抽完接下来是写入Neo4j。源码里给了两种写入路径第一种是逐条Cypher适合调试第二种是批量写入适合几千条以上数据的初始化。批量写入的一个关键参数是UNWIND它能减少网络往返次数UNWIND $batch AS row MERGE (d:Disease {name: row.disease}) ON CREATE SET d.icd10 row.icd10 MERGE (s:Symptom {name: row.symptom}) MERGE (d)-[:HAS_SYMPTOM]-(s)对应python侧的驱动调用参数from neo4j import GraphDatabase class MedicalGraphWriter: def __init__(self, uri, user, password): self.driver GraphDatabase.driver(uri, auth(user, password)) def write_batch(self, relation_batches, batch_size500): with self.driver.session() as session: for i in range(0, len(relation_batches), batch_size): batch relation_batches[i:ibatch_size] session.run( UNWIND $batch AS row MERGE (d:Disease {name: row.disease}) MERGE (s:Symptom {name: row.symptom}) MERGE (d)-[:HAS_SYMPTOM]-(s) , batchbatch )batch_size设成500是个经验值亲测Neo4j 4.x版本在默认堆内存配置下这个值最稳定。调太大服务端要开大事务容易把内存撑爆报OutOfMemoryError调太小写入速度太慢一万条数据要跑半天。写入时机也有讲究——尽量在业务低峰期做全量重建不要在线更新图谱因为Neo4j的MERGE在并发写入同一节点时会有锁等待问答系统在线时压测过延迟会从5ms飙到200ms。3. 问句解析模块从“我最近总是头晕”到Cypher查询3.1 基于模板的意图识别是医疗问答的兜底方案问句解析是自动问答系统的咽喉。源码里用的方案不是端到端生成式模型而是模板匹配加槽位填充。原因是医疗问句有非常明显的句式重复患者不会问“冠状动脉粥样硬化性心脏病和高血压之间有什么复杂的生物学关联”而是问“冠心病能吃降压药吗”、“高血压平时注意什么”、“头疼挂哪个科”。模板匹配在这样的封闭域里足够好用且完全可控不会出现生成模型那种“一本正经胡话”的问题。问句模板用正则表达来定义每一条模板绑定一个查询意图import re class MedicalQueryParser: def __init__(self): self.templates [ { intent: query_treatment, pattern: r(.?)能(?:吃|用|服用|口服)(.?)吗, slot_names: [disease, drug] }, { intent: query_department, pattern: r(.?)(?:挂|去|找)(什么|哪个)科(?:室)?, slot_names: [symptom] }, { intent: query_symptom, pattern: r(.?)(?:有什么|有哪些|会出现|会有什么)(?:症状|表现), slot_names: [disease] } ] def parse(self, question: str): for tpl in self.templates: m re.search(tpl[pattern], question) if m: slots {} for idx, name in enumerate(tpl[slot_names]): slots[name] m.group(idx 1) return tpl[intent], slots return unknown, {}这里的正则贪婪匹配要小心“头疼挂什么科”和“头疼应该挂什么科”都能被第二条模板命中但如果问句里多了一个“应该”匹配到的slots内容还是“头疼”不会有问题。真正容易出错的是多重实体出现在同一个问句里的情况比如“高血压伴有头痛吃什么药”这时“高血压伴有头痛”会被当成一个整体匹配不到任何实体就落到兜底逻辑。源码的兜底逻辑是拆掉疑问词后做实体识别再按实体类型组合查询。如果两个实体都是疾病那就走“共病查询”如果一病一症状走“关系查询”。这一步不完美但至少不会返回空结果。3.2 实体归一化用编辑距离兜底同义词用户问的是“冠心病”图谱里存的是“冠状动脉粥样硬化性心脏病”直接用实体名做完全匹配必然失败。源码里做了一个三层归一化from rapidfuzz import fuzz class EntityNormalizer: def __init__(self, all_entity_names): self.all_names all_entity_names def normalize(self, mention: str, threshold: int 60): # 第一层完全匹配 if mention in self.all_names: return mention, 100 # 第二层别名词典 # 第三层模糊匹配 best_score 0 best_name None for name in self.all_names: score fuzz.ratio(mention, name) if score best_score: best_score score best_name name if best_score threshold: return best_name, best_score return None, 0参数含义threshold控制模糊匹配的宽松程度60意味着“头疼”和“头痛”这种一个字不同的词能匹配上但“头疼”和“脚疼”不会误匹配。rapidfuzz比difflib快一个数量级用python写在线服务时优先选前者。实际医疗场景里同义问题远比想象的严重——“高血压”和“hypertension”、“原发性高血压”和“高血压病”都要映射到同一个节点。如果图谱里的标准名占位符是中文建议在构建图谱时直接冗余存储别名属性而不是每次查询都跑模糊匹配毕竟线上实时问答对时延很敏感。3.3 生成Cypher的三种模式静态模板加参数动态拼接拿到意图和槽位之后就该拼Cypher了。核心原则是“宁可多查一跳也别拼错关系”。源码里我比较认可的模式是把Cypher分成三段式匹配节点、过滤属性、返回关系。举一个“冠心病吃什么药”的处理builders { query_treatment: lambda slots: ( fMATCH (d:Disease {{name: $disease_name}}) f-[rel:常用]-(m:Medicine) fRETURN m.name AS drug_name, rel.usage AS usage_note, {disease_name: slots[disease]} ), query_department: lambda slots: ( fMATCH (s:Symptom {{name: $symptom_name}}) f-[:表现出]-(d:Disease) fMATCH (d)-[:就诊科室]-(dep:Department) fRETURN DISTINCT dep.name AS department, {symptom_name: slots[symptom]} ) }这里的变量用$disease_name而不是直接拼字符串是为了防注入。虽然Neo4j的Cypher注入在问答场景里很难造成实质伤害但养成参数化习惯不会错。另外可以看到“查询科室”的Cypher走了两步匹配先找到症状对应的疾病再找疾病对应的科室。有些团队会直接在症状节点上挂科室关系看似省一跳但“头疼”既可能是神经内科也可能是耳鼻喉科直接挂科室就丢了中间的疾病约束回答会不准确。这就是为什么图谱设计阶段宁可多一层中间节点也别为查询方便做冗余。4. 基于Elasticsearch的检索式问答给图谱查询加一层兜底4.1 为什么有了Neo4j还要Elasticsearch纯图查询有个问题患者问“最近总是心慌气短还失眠”这种描述在图谱里根本找不到完全匹配的节点Cypher查询直接落空。检索式问答的意义就在于能用BM25算法在实体描述文档里做模糊查找把不完全匹配的实体或子图捞出来。源码里的做法是把每个疾病节点的描述文本、症状别名、相关科室、常用药物全部拼成一个文档灌进Elasticsearch问题来了先走ES召回top10候选实体再用这些候选实体去Neo4j做精查。文档结构如下{ entity_type: Disease, entity_name: 偏头痛, entity_aliases: [血管性头痛, migraine], description: 偏头痛是最常见的原发性头痛类型以反复发作的中重度头痛为特征..., symptoms: [单侧头痛, 恶心, 呕吐, 畏光], departments: [神经内科] }写入ES用python的elasticsearch客户端注意设置分析器。中文分析器建议用ik_max_word比默认的standard分词效果好一个量级from elasticsearch import Elasticsearch es Elasticsearch(http://localhost:9200) index_body { mappings: { properties: { entity_type: {type: keyword}, entity_name: {type: keyword}, entity_aliases: {type: text, analyzer: ik_max_word}, description: {type: text, analyzer: ik_max_word}, symptoms: {type: text, analyzer: ik_max_word} } } } es.indices.create(indexmedical_kb, bodyindex_body)keyword类型用于精确过滤text加ik_max_word用于全文检索。生产环境要建索引别名避免重建索引时服务中断这个细节源码没给但上线必踩。4.2 检索时用bool查询组合must和should控制召回精度ES查询不能只match一个字段那样会把“头疼”匹配到“脚头疼”上面的笑话闹出来。源码里的做法是组合查询should是症状字段的匹配filter限定实体类型为疾病或症状def search_entities(query_text: str, top_k: int 10): body { query: { bool: { should: [ {match: {description: {query: query_text, boost: 2}}}, {match: {symptoms: {query: query_text}}}, {match: {entity_aliases: {query: query_text}}} ], minimum_should_match: 1 } }, size: top_k } resp es.search(indexmedical_kb, bodybody) return [hit[_source] for hit in resp[hits][hits]]boost2的意思是description字段的匹配得分权重提高一倍。为什么因为症状字段写的是规范化术语“心慌”在那里可能存的是“心悸”但description是自由文本“最近总是心慌气短”这句话能在description里找到更宽松的匹配。minimum_should_match设成1避免三个字段都不命中的情况返回空列表。这一步捡回来的候选实体再送回Neo4j做精确的关系查询回答质量比单走图查询高很多。4.3 融合排序规则分加BM25分不要把鸡蛋放一个篮子拿到ES的候选和Neo4j的精确结果之后需要一个融合排序机制。源码里的做法是给每个候选实体打一个最终分final_score 0.6 * bm25_score 0.4 * graph_score。graph_score的含义是这个实体在Neo4j中连接到答案节点的路径数路径越多说明这个候选越可能是用户真正想问的。举个例子“心慌”既可能是心律失常的症状也可能是甲亢的症状如果图谱中心律失常关联了10个科室、5种药、3项检查而甲亢只关联了2个科室那最终排序会把心律失常排在前面。这符合临床直觉——同一个症状常见病的优先级本来就高。排序的阈值要压测调源码给了一个参考范围top_k3时final_score低于0.35直接返回“未找到相关答案”避免硬答。5. 多轮对话状态管理让问答系统从单发走向会话式5.1 槽位追踪记录“上一轮没说完的病”单轮问答做得好只是第一步真正的医疗问诊场景几乎都是多轮的。患者说“高血压好多年了”下一句“最近老觉得头晕”系统要能自动判定“头晕”是附着在“高血压”这个上下文实体上的新症状而不是开启一个新问题。源码实现了一个简单的槽位管理器用上下文字典来存已确认的实体class SlotManager: def __init__(self): self.slots {} def update_slots(self, entities: dict, confirm_ratio: float 0.7): for etype, ename in entities.items(): if etype not in self.slots: self.slots[etype] ename else: # 同一类型的实体重复出现时以置信度高的为准 if confirm_ratio 0.7: self.slots[etype] ename def get_missing_slots(self, required_slots: list) - list: return [slot for slot in required_slots if slot not in self.slots] def clear_slots(self): self.slots.clear()必要的参数是confirm_ratio它的含义是当用户在新的一轮里提供了和上一轮相同槽位类型的不同实体值时旧值要不要被替换。比如上一轮患者说“冠心病”这一轮说“高血压”这两个都是疾病实体如果直接替换上一轮的上下文就丢了。源码里的做法是保留两个作为“候选疾病列表”问句模板里需要疾病的地方用列表第一个但检索时会把两个疾病都送进图查询找出共病关系。5.2 澄清机制让系统会反问而不是瞎答当检测到必要槽位缺失比如用户只说了“吃什么药”而没有指定什么病系统不应该返回空结果而应该生成澄清问句。源码里的澄清规则表用python字典实现CLARIFICATION_PROMPTS { disease: 请问您具体是哪个疾病比如高血压、糖尿病、冠心病..., symptom: 您能描述一下具体是哪个部位不舒服吗, drug: 您想了解哪种药物的信息 } def generate_clarification(question_type: str, missing_slots: list): if len(missing_slots) 1: return 为了给您更准确的建议请先告诉我您更多不舒服的细节。 # 保持系统在缺失信息较多时不做有风险建议 return CLARIFICATION_PROMPTS.get(missing_slots[0], 请换个说法再试一次)这里的思路是宁可多轮澄清也别给出模棱两可的答案。医疗场景和其他垂直领域最大的区别是“错误答案有代价”一次“患者问A病结果答B病的药”可能造成实际伤害。默认策略是当意图识别置信度低于0.5时系统进入澄清模式连续澄清两轮填不上槽位就转人工提示。6. 从单机原型到可交付系统版本选型和性能压测6.1 Python版本和依赖锁定别让环境成为事故源头源码能在本地跑起来和能在生产环境稳定运行中间隔着一条依赖管理的鸿沟。实测在Python 3.8到3.11之间切版本spacy的模型加载行为有差异rapidfuzz的C扩展在不同版本上也必须预编译匹配。建议用pyproject.toml锁定所有依赖的精确版本而不是用requirements.txt写宽松的1.0。一个反例是用neo4j驱动4.4版本连接Neo4j 5.x服务端握手直接失败报Unsupported bolt protocol version。遇到这种情况锁定neo4j驱动版本到5.x第一条就解决了。用python环境管理工具建虚拟环境后先跑一遍源码自带的pytest测试集确认基线通过再动代码。源码目录里如果带了data/文件夹一定有词典和问句模板不要删。6.2 用压测脚本验证问答延迟看三个核心指标交付前做一次简单的压测比讨论任何优化都有说服力。按源码的问答管线拆成三段的耗时模型大概是实体识别加归一化10msNeo4j查询5msES检索加排序15ms。总延迟在30ms左右是正常水准。用python写一个压测脚本模拟连续问答请求ab -n 1000 -c 20 -p question_post.json -T application/json http://localhost:8080/ask压测关注三个指标平均响应时间、P99延迟、错误率。P99如果超过200ms优先查是不是Neo4j连接池打满了。Neo4j驱动默认的连接池大小是100压测时如果并发超过这个数排队等待是必然的。调大连接池的参数在驱动初始化时设置from neo4j import GraphDatabase driver GraphDatabase.driver( bolt://localhost:7687, auth(neo4j, password), max_connection_pool_size200, connection_acquisition_timeout5 )connection_acquisition_timeout设置的是获取连接的最大等待时间超过5秒直接抛异常比无限等下去好。另外ES侧的max_connections也要同步调大两个系统是串联关系一个瓶颈就会拖垮整条链路。6.3 可观测性与用后即焚的调试日记最后一公里就靠它问答系统上线后最头疼的问题是“为什么这个用户问到了但系统没答出来”。我的做法是在源码里预留一个debug_trace字段记录每一次问答的完整链路trace { question: question, intent: intent, normalized_entities: normalized_entities, cypher: cypher_query, es_candidates: es_candidates, final_answer: answer, latency_ms: latency_ms }存入日志时做脱敏只保留问句的哈希值不存明文。线上开着debug日志跑一周把badcase收集回来然后系统地回归到第四步的实体词典和第五步的模板上补数据。医疗知识图谱自动问答系统不是一个一次性的源码zip而是需要持续喂语料的算法服务。把badcase补进词典再把词典转成新的测试集周而复始系统的准确率就会在跑过5000条真实问句之后稳定在能交付的状态。源码zip只是起点持续治理才是把系统养胖的日常。本文还有配套的精品资源点击获取