简介:这是一套面向医疗领域知识图谱问答系统开发者的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编码的txt:
text = "患者因持续性胸痛伴大汗三小时入院,心电图提示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', encoding='utf-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_size=500): with self.driver.session() as session: for i in range(0, len(relation_batches), batch_size): batch = relation_batches[i:i+batch_size] session.run( """ UNWIND $batch AS row MERGE (d:Disease {name: row.disease}) MERGE (s:Symptom {name: row.symptom}) MERGE (d)-[:HAS_SYMPTOM]->(s) """, batch=batch )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: ( f"MATCH (d:Disease {{name: $disease_name}})" f"-[rel:常用]->(m:Medicine) " f"RETURN m.name AS drug_name, rel.usage AS usage_note", {"disease_name": slots["disease"]} ), "query_department": lambda slots: ( f"MATCH (s:Symptom {{name: $symptom_name}})" f"<-[:表现出]-(d:Disease) " f"MATCH (d)-[:就诊科室]->(dep:Department) " f"RETURN 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(index="medical_kb", body=index_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(index="medical_kb", body=body) return [hit["_source"] for hit in resp["hits"]["hits"]]boost=2的意思是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_k=3时,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 用压测脚本验证问答延迟,看三个核心指标
交付前做一次简单的压测,比讨论任何优化都有说服力。按源码的问答管线,拆成三段的耗时模型大概是:实体识别加归一化10ms,Neo4j查询5ms,ES检索加排序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_size=200, connection_acquisition_timeout=5 )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只是起点,持续治理才是把系统养胖的日常。
本文还有配套的精品资源,点击获取