简介:这份资源是面向计算机、人工智能、通信、自动化等专业学生与开发者的知识图谱医疗领域问答系统完整实现,包含可直接运行的源码与配套数据,适合作为毕业设计、期末大作业或课程设计参考,也便于基础较好的学习者在此基础上二次开发。压缩包共188个文件,约115.09MB,以41个Python源码文件为核心,辅以44个txt数据说明、21个html前端页面、10个js脚本、8个db数据库文件及若干png、css、json等资源,覆盖知识图谱构建、问答逻辑与前端交互等模块。目前已有139人学习下载。项目经过调试测试,答辩评审分达98分,读者可获取完整工程结构、图谱数据与运行环境配置,快速理解医疗问答系统的实现思路,并对照源码梳理知识抽取、关系存储与查询应答等关键环节,具备较高的学习借鉴价值。
1. 从一份能跑起来的 Python 医疗问答源码说起:知识图谱到底解决了什么
医疗问诊场景里,用户问的从来不是「感冒吃什么药」这种单跳问题,而是「我父亲有高血压,最近咳嗽能吃复方甘草片吗」——这句话里藏着药物、疾病、人群、禁忌四类实体和它们之间的约束关系。普通关键词检索或 FAQ 匹配在这里会直接翻车,因为它匹配不到「高血压患者慎用甘草类制剂」这条隐含路径。基于 Python 的知识图谱医疗问答系统,核心就是用「实体—关系—实体」的三元组把医学知识显式存下来,再用图查询把多跳推理跑通。这套方案适合两类人:一是想拿一个完整可运行项目入门知识图谱构建与问答的开发者,二是需要给院内导诊、用药咨询做原型验证的工程师。源码和数据能直接跑,意味着你不用从零搭 Neo4j、不用自己标注几万条医疗实体,重点可以放在理解图谱结构、问答链路和后续替换数据上。
2. 医疗知识图谱的数据从哪来、怎么变成三元组
2.1 医疗数据的三个来源与清洗边界
一套能跑的医疗问答系统,数据质量决定上限。常见做法是三条线并行:一是公开医学知识库的结构化条目,比如疾病百科、药品说明书这类半结构化文本;二是权威教材和诊疗指南里的章节内容,用来补全症状、检查、治疗之间的关联;三是人工整理的问答对,用来覆盖口语化表达。我一般会把前两类做成实体关系抽取的输入,第三类单独存成 FAQ 兜底。
清洗阶段最容易踩的坑是「同名不同义」。比如「阿司匹林」既是药品名,也可能出现在「阿司匹林哮喘」这个疾病名里。如果不做实体消歧,后面图谱里会多出一堆错误边。实操上我会先跑一遍规则过滤:把长度超过 8 个字的实体名单独拎出来人工过一遍,再对药品名做一次后缀归一化,把「片」「胶囊」「注射液」这类剂型后缀暂时剥离,只保留通用名做主键。
提示:医疗数据涉及隐私和合规,公开数据集里如果带患者主诉文本,入库前必须做脱敏,姓名、身份证、电话这类字段直接丢弃,不要图省事留着。
2.2 用 Python 把原始文本抽成(头实体,关系,尾实体)
抽取环节我一般用「规则 + 词典 + 轻量模型」的组合,不直接上大模型,因为医疗实体边界要求稳定,大模型容易自由发挥。下面这段代码演示从一段药品说明文本里抽三元组的最小实现,依赖 jieba 分词和自定义词典。
import jieba import re # 自定义医疗词典,实际项目里从文件加载 jieba.load_userdict("medical_dict.txt") # 关系触发词表:出现这些词时,前后实体建立对应关系 RELATION_PATTERNS = { "适应症": ["用于", "适用于", "治疗"], "禁忌": ["禁用", "忌用", "慎用"], "不良反应": ["不良反应", "副作用"], } def extract_triples(text, drug_name): triples = [] sentences = re.split(r"[。;;]", text) for sent in sentences: words = list(jieba.cut(sent)) for rel, triggers in RELATION_PATTERNS.items(): for trigger in triggers: if trigger in sent: # 触发词后面的名词短语作为尾实体 idx = sent.find(trigger) tail = sent[idx + len(trigger):].strip(",,、 ") if tail and len(tail) <= 20: triples.append((drug_name, rel, tail)) break return triples text = "本品用于缓解轻至中度疼痛。严重肝肾功能不全者禁用。偶见恶心、呕吐等不良反应。" print(extract_triples(text, "布洛芬"))这段逻辑的核心是「触发词定位 + 尾实体截取」。RELATION_PATTERNS里每个关系对应一组触发词,命中后取触发词之后到句末的片段作为尾实体。参数上,len(tail) <= 20是防止把整段话当成实体,实际项目里可以改成用词性标注只保留名词短语。jieba.load_userdict加载的医疗词典至少覆盖疾病名、药品名、症状名、检查项四类,否则「肝肾功能不全」会被切碎。
抽取完成后,三元组统一存成 CSV,字段固定为head, relation, tail,方便后面批量导入图数据库。这一步不要急着入库,先做一轮去重和空值过滤,否则 Neo4j 里会出现大量重复边,查询时性能掉得厉害。
2.3 三元组落库前的实体对齐与去重
抽出来的三元组直接入库,十有八九会遇到「高血压」和「高血压病」被当成两个实体。实体对齐就是解决这个问题的。我的做法是维护一张别名表,用 Python 做归一化映射:
ALIAS_MAP = { "高血压病": "高血压", "2型糖尿病": "糖尿病", "阿司匹林肠溶片": "阿司匹林", } def normalize(entity): entity = entity.strip() return ALIAS_MAP.get(entity, entity) def dedup_triples(triples): seen = set() result = [] for h, r, t in triples: key = (normalize(h), r, normalize(t)) if key not in seen: seen.add(key) result.append(key) return resultALIAS_MAP是手工维护的,规模不大但收益很高。实际项目里我会把别名表单独存成 CSV,方便非技术同事补充。dedup_triples用集合做去重,注意 key 里已经做了归一化,所以「高血压病-禁忌-甘草」和「高血压-禁忌-甘草」只会保留一条。这一步做完,三元组数量通常会减少 15% 到 30%,具体取决于原始数据的规范程度。
3. 用 Neo4j 构建知识图谱:从 CSV 到可查询的图
3.1 Neo4j 环境准备与 Python 驱动连接
图数据库选 Neo4j 是医疗知识图谱里最常见的做法,原因是 Cypher 查询语言对多跳路径的表达非常直观,而且社区版免费、文档全。安装方式我一般用 Docker,省去配 Java 环境的麻烦:
docker run -d \ --name medical-neo4j \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/your_password \ neo4j:5端口 7474 是浏览器控制台,7687 是 Bolt 协议端口,Python 驱动走 7687。NEO4J_AUTH设置初始账号密码,生产环境不要用默认密码。启动后浏览器打开http://localhost:7474能进控制台就说明环境通了。
Python 侧用官方neo4j驱动:
from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "your_password") ) def test_connection(): with driver.session() as session: result = session.run("RETURN 1 AS ok") return result.single()["ok"] print(test_connection()) # 输出 1 表示连接正常GraphDatabase.driver的第一个参数是 Bolt 地址,第二个是认证元组。session.run执行 Cypher 语句,result.single()取第一条记录。连接失败时优先检查 Neo4j 容器是否在运行、密码是否和NEO4J_AUTH一致,这两个原因占了九成。
3.2 批量导入三元组的 Cypher 写法与索引优化
把 CSV 里的三元组导入 Neo4j,有两种方式:一是用LOAD CSV在 Cypher 里直接读文件,二是用 Python 驱动逐批写入。数据量在十万条以内,Python 批量写入更可控;超过百万条,建议用neo4j-admin import做离线导入。
下面是 Python 批量写入的写法,用MERGE保证实体不重复创建:
def import_triples(triples, batch_size=500): query = """ UNWIND $batch AS row MERGE (h:Entity {name: row.head}) MERGE (t:Entity {name: row.tail}) MERGE (h)-[r:RELATION {type: row.relation}]->(t) """ with driver.session() as session: for i in range(0, len(triples), batch_size): batch = [ {"head": h, "relation": r, "tail": t} for h, r, t in triples[i:i + batch_size] ] session.run(query, batch=batch)UNWIND $batch把一批数据展开成多行,MERGE的语义是「存在就不创建,不存在才创建」。这里所有实体都用:Entity标签,关系类型统一叫RELATION,具体关系名存在type属性里。这种建模方式灵活,但查询时要写[r:RELATION {type: "禁忌"}],不如直接把关系名做成边类型直观。我的建议是:关系种类少于 20 种时,直接用边类型,比如-[:禁忌]->,查询更快;关系种类多且动态变化时,才用属性存类型。
导入前记得建索引,否则MERGE在数据量上来后会慢到怀疑人生:
CREATE INDEX entity_name_index IF NOT EXISTS FOR (e:Entity) ON (e.name);这条语句在Entity标签的name属性上建索引,MERGE查找已有节点时会走索引。十万条三元组导入时间能从几分钟降到十几秒。
3.3 验证图谱连通性:三条必跑的 Cypher 查询
导入完成后,不要急着接问答接口,先用三条查询验证图谱质量。
第一条,查实体总数和关系总数:
MATCH (e:Entity) RETURN count(e) AS entity_count; MATCH ()-[r:RELATION]->() RETURN count(r) AS relation_count;如果实体数远小于关系数,说明实体复用率高,图谱连通性好;如果实体数接近关系数,说明大量实体只出现一次,图谱是散的,问答时多跳查询会查不到东西。
第二条,查某个疾病的两跳邻居:
MATCH (d:Entity {name: "高血压"})-[r1:RELATION]->(mid)-[r2:RELATION]->(target) RETURN d.name, r1.type, mid.name, r2.type, target.name LIMIT 20;这条查询能直观看到「高血压」通过中间实体能关联到什么。如果结果为空,说明图谱里缺少以高血压为起点的边,需要回头补数据。
第三条,查孤立实体:
MATCH (e:Entity) WHERE NOT (e)-[:RELATION]-() RETURN e.name LIMIT 20;孤立实体在问答里永远匹配不到路径,要么补关系,要么从图谱里清掉。我一般会把孤立实体导出成清单,人工判断是数据缺失还是抽取错误。
4. 问答链路怎么搭:从用户问句到图谱查询结果
4.1 问句实体识别与意图分类的轻量方案
用户输入「高血压能吃布洛芬吗」,系统要做的第一件事是识别出「高血压」和「布洛芬」两个实体,以及「禁忌」这个意图。实体识别可以复用第 2 章的词典和 jieba 分词,意图分类用规则匹配就够,不必上 BERT。
INTENT_RULES = { "禁忌": ["能吃", "可以吃", "能不能吃", "禁忌", "慎用"], "适应症": ["治什么", "适应症", "用于", "主治"], "不良反应": ["副作用", "不良反应", "有什么危害"], } def classify_intent(question): for intent, keywords in INTENT_RULES.items(): for kw in keywords: if kw in question: return intent return "未知" def extract_entities(question, entity_set): words = jieba.cut(question) return [w for w in words if w in entity_set]INTENT_RULES里每个意图对应一组口语化关键词,命中即返回。extract_entities用分词结果和实体集合求交集,entity_set从 Neo4j 里一次性拉出来缓存在内存。这套方案在医疗问答的封闭场景里准确率够用,因为用户问法相对集中。如果问句里出现「我父亲」这类指代,需要额外做一轮指代消解,简单做法是把「我父亲」映射成「老年人」这个人群实体。
4.2 把自然语言问句翻译成 Cypher 查询
实体和意图都拿到后,拼 Cypher 就是模板填空:
def build_cypher(entities, intent): if len(entities) < 2: return None h, t = entities[0], entities[1] query = f""" MATCH (a:Entity {{name: $h}})-[r:RELATION {{type: $intent}}]->(b:Entity {{name: $t}}) RETURN a.name, r.type, b.name UNION MATCH (b:Entity {{name: $t}})-[r:RELATION {{type: $intent}}]->(a:Entity {{name: $h}}) RETURN a.name, r.type, b.name """ return query, {"h": h, "t": t, "intent": intent}这里用UNION把两个方向的查询合并,因为「高血压-禁忌-布洛芬」和「布洛芬-禁忌-高血压」在数据里可能只存了一个方向。参数用$h、$t、$intent占位,由驱动做参数化查询,避免 Cypher 注入。如果两个实体之间没有直接边,可以退一步查两跳路径:
MATCH path = shortestPath( (a:Entity {name: $h})-[*..3]-(b:Entity {name: $t}) ) RETURN path LIMIT 1;shortestPath找最短路径,[*..3]限制最多三跳。医疗场景里三跳通常够用,再长路径的置信度就低了,容易给出误导性答案。
4.3 查询结果转自然语言回复的模板设计
图谱返回的是三元组,用户要的是人话。回复模板按意图分:
RESPONSE_TEMPLATES = { "禁忌": "根据知识图谱,{h}与{t}之间存在禁忌关系,建议咨询医生确认。", "适应症": "{h}的适应症包括{t}。", "不良反应": "{h}可能引起{t}等不良反应。", "未知": "抱歉,暂时没有找到相关医学知识,请咨询专业医生。", } def generate_response(intent, h, t): template = RESPONSE_TEMPLATES.get(intent, RESPONSE_TEMPLATES["未知"]) return template.format(h=h, t=t)模板里必须带「请咨询医生」这类兜底话术,医疗问答不能给出确定性诊断结论。如果查询结果为空,统一走「未知」模板,不要编造答案。实际项目里我会在回复末尾附上数据来源,比如「以上信息来自药品说明书」,增加可信度。
5. 避坑与排查:医疗问答系统落地时最容易翻车的五件事
5.1 实体识别把症状名切碎导致查不到路径
现象:用户问「头晕乏力是什么病」,系统识别出的实体是「头晕」和「乏力」,但图谱里存的是「头晕乏力」作为一个整体症状,查询返回空。
原因:jieba 默认词典没有收录「头晕乏力」这个组合词,分词时被切开。
解决:在自定义词典里补充常见症状组合词,同时在实体识别后加一步「相邻实体合并」逻辑——如果两个识别出的实体在原文中相邻,尝试合并后去图谱里查一次,命中则用合并结果。
5.2 Neo4j 导入时 MERGE 导致关系重复
现象:同一对实体之间出现多条相同类型的关系边,查询结果重复。
原因:MERGE (h)-[r:RELATION {type: row.relation}]->(t)在并发写入时,如果两个线程同时判断关系不存在,会各创建一条。
解决:导入改成单线程,或者给关系加唯一约束。更稳妥的做法是导入前在 Python 侧用集合去重,导入时用MERGE而不是CREATE。如果已经产生重复边,用 Cypher 清理:
MATCH (a)-[r:RELATION]->(b) WITH a, b, r.type AS type, collect(r) AS rels WHERE size(rels) > 1 FOREACH (r IN tail(rels) | DELETE r);5.3 问句里的否定词被忽略导致答反
现象:用户问「高血压不能吃布洛芬吗」,系统识别意图为「禁忌」,返回「存在禁忌关系」,但用户实际想确认的是「是不是不能吃」,回复语气不对。
原因:意图分类只看了关键词,没处理否定和疑问语气。
解决:在意图分类前先做一轮否定检测,如果问句里出现「不能」「不可以」「是不是不」这类结构,把意图标记为「确认禁忌」,回复模板改成「是的,{h}与{t}存在禁忌关系,不建议使用」。否定词表不用大,覆盖常见十几种就够。
5.4 图谱数据更新后问答结果没变化
现象:往 Neo4j 里补了新数据,但问答接口返回的还是旧结果。
原因:实体集合entity_set在服务启动时加载到内存后没刷新,新实体识别不到。
解决:给实体集合加定时刷新,比如每 10 分钟从 Neo4j 重新拉一次;或者在数据导入后主动调一次刷新接口。如果问答服务是多实例部署,每个实例都要刷新,别只刷一台。
5.5 多跳查询返回路径过长导致答案不可信
现象:用户问「糖尿病能吃阿司匹林吗」,系统通过五跳路径找到一条关联,回复了一个不相关的结论。
原因:shortestPath没限制跳数,或者限制太宽。
解决:把跳数限制在 3 跳以内,超过 3 跳的路径不返回。同时在回复里标注路径长度,比如「通过 2 步关联找到以下信息」,让用户对可信度有判断。医疗场景宁可说「没找到」,也不要给一条绕了五跳的弱关联。
6. 让问答更准的一个技巧:给图谱边加权重和来源
图谱跑通之后,真正拉开差距的不是模型多复杂,而是边上的信息够不够。我后来养成一个习惯:每条关系边都加两个属性——weight和source。weight表示这条关系的置信度,人工整理的数据给 1.0,规则抽取的给 0.7,模型抽取的给 0.5;source记录数据来源,比如「药品说明书」「诊疗指南」「人工录入」。
加属性的 Cypher 写法:
MERGE (h:Entity {name: $head}) MERGE (t:Entity {name: $tail}) MERGE (h)-[r:RELATION {type: $relation}]->(t) SET r.weight = $weight, r.source = $source;查询时按权重排序,优先返回高置信度的结果:
MATCH (a:Entity {name: $h})-[r:RELATION {type: $intent}]->(b:Entity {name: $t}) RETURN b.name, r.weight, r.source ORDER BY r.weight DESC LIMIT 3;这个改动带来的收益很直接:同一个问题,图谱里可能有多条来源不同的边,按权重排序后,用户看到的第一条大概率是药品说明书里的权威结论,而不是某条规则误抽的边。source属性还能在回复里展示,比如「以上信息来自《中国药典》」,可信度立刻不一样。
另一个技巧是给高频查询建缓存。医疗问答里「高血压」「糖尿病」这几个实体的查询占了很大比例,每次走 Neo4j 没必要。我一般用 Python 的functools.lru_cache对build_cypher的结果做缓存,key 是实体和意图的组合,缓存 500 条,命中率能到六成以上。注意缓存要设置过期时间,数据更新后旧缓存得失效,否则又回到 5.4 那个坑里。
最后说一个我踩过的血泪教训:别在问答服务里直接连生产 Neo4j 做写操作。问答是读多写少的场景,读写混在一起,一次批量导入就能把查询拖垮。正确做法是导入走单独的脚本或管理接口,问答服务只读,用只读账号连接。这个习惯让我少加了很多次班。希望帮到你。
本文还有配套的精品资源,点击获取