简介:面向计算机相关专业毕业设计学生,提供基于Python的知识图谱医疗领域问答系统完整实现,覆盖知识图谱构建、实体识别、关系抽取、问答匹配等核心环节,属于导师认可的评审98分高分项目,难度适中,可直接作为毕设主体或大作业参考。压缩包共1403个文件,约38.72MB,以Python源码(py)为主,辅以Java与XML工程文件、JSON/CSV医疗数据、H5模型文件、PDF论文资料及Markdown说明文档等,各类型分工明确,目录结构清晰,便于定位与复用。目前已有102人学习下载,适合需要快速搭建医疗问答系统的学生和开发者。资源包含可运行的完整代码、医疗领域数据集及配套论文资料,源码经过本地编译与严格调试,确保可直接运行;除核心算法实现外,还提供数据库脚本、配置文件及项目文档,帮助用户理解整套系统的设计思路与工程细节,能显著降低毕业设计开发门槛,同时也可作为知识图谱应用开发的项目实战素材。
1. 医疗问答系统绕不开知识图谱:它解决的不是“聊天”而是“找对答案”
在医院导诊台和在线问诊场景里,用户问得最多的不是“我得了什么病”,而是“我头晕挂什么科”“高血压平时吃什么药”“这个药和那个药能一起吃吗”。这类问题有一个共同特点:答案藏在实体和实体之间的关系里,而不是藏在某个网页的正文里。传统关键词搜索会把整个网页甩给你,而知识图谱驱动的智能问答系统可以直接回答“神经内科”或“硝苯地平缓释片”,这正是基于python知识图谱医疗领域问答系统的核心价值。用Neo4j存医学实体,用Python做问句解析和查询生成,把“症状—疾病—科室—药品”这条路走通,整个系统就能在毕业设计答辩现场现场演示,也能作为医疗导诊、健康科普这类轻量级应用的原型。本文面向把毕设做成能跑、能讲、能演示的读者,从数据构建讲到位问答链路闭环。
2. 医疗知识图谱构建:本体设计、数据清洗与Neo4j落地
2.1 医疗本体设计:先定义“节点和边”,再想怎么写代码
很多第一次做知识图谱的人上来就爬数据,爬到什么存什么,结果图谱变成一个“数据垃圾场”。医疗场景最忌讳这个,因为“高血压”可以是疾病,也可以是症状描述里的修饰词;“科室”和“医生”如果混在一个节点里,后面查询就全乱套。我一般会先花半天时间把本体设计成一张表,明确哪些东西是实体,哪些东西是关系。
| 实体类型 | 代表性实例 | 说明 |
|---|---|---|
| Disease(疾病) | 高血压、2型糖尿病、上呼吸道感染 | 图谱的核心主语 |
| Symptom(症状) | 头晕、乏力、多饮多尿 | 连接患者的问句入口 |
| Department(科室) | 神经内科、内分泌科、呼吸内科 | 用于导诊类问答 |
| Drug(药品) | 硝苯地平片、二甲双胍 | 用于用药类问答 |
| Check(检查项) | 血常规、糖化血红蛋白 | 用于检查类问答 |
关系则围绕“疾病”展开:Disease—HAS_SYMPTOM—Symptom,Disease—VISIT—Department,Disease—TAKE—Drug,Disease—CHECK—Check。这套模型足够覆盖导诊、用药、检查这三个高频问答意图,又不至于把本体复杂到后期标注不过来。设计原则只有一个:每条关系都必须能回答一类真实问句,不能为对称而对称。
2.2 数据来源与清洗:把半结构化的表变成三元组
公开渠道能拿到的医疗数据大多是“疾病百科”结构,一列是疾病名称,一列是用顿号分隔的症状列表。这种表格不能直接导入Neo4j,要先把“一行多值”拆成“一行一值”的三元组。清洗这一步做得不干净,后面Cypher查出来的答案质量会很差,甚至出现“高血压—症状—高血压”这种自环。
import pandas as pd raw = pd.read_csv("medical_disease.csv", encoding="utf-8-sig") raw = raw.dropna(subset=["disease", "symptom", "department", "drug"]) def split_to_triples(row, field, relation): triples = [] # 症状字段可能是“头晕,乏力,失眠”或“头晕;乏力” values = str(row[field]).replace(";", ",").replace("、", ",").split(",") for val in values: val = val.strip() if val and val != row["disease"]: triples.append((row["disease"], relation, val)) return triples all_triples = [] for _, row in raw.iterrows(): all_triples += split_to_triples(row, "symptom", "HAS_SYMPTOM") all_triples += split_to_triples(row, "deparment", "VISIT") all_triples += split_to_triples(row, "drug", "TAKE") out = pd.DataFrame(all_triples, columns=["disease", "relation", "target"]) out.drop_duplicates(inplace=True) out.to_csv("triples.csv", index=False, encoding="utf-8-sig")代码逻辑不复杂,重点在三个地方:字段分隔符不统一,所以要先用replace把全角标点统一成半角逗号;去掉与疾病名完全相同的值,这一步能过滤掉很多脏数据;drop_duplicates去重,否则同一条关系被写入两次,Neo4j里会出现重复关系,查询时还得distinct。
2.3 用py2neo批量导入Neo4j:从逐条CREATE到UNWIND
常见做法是先用Cypher建好约束和索引,再把数据灌进去。这里的“约束”不是数据库性能优化,而是图模型的正确性保障:同一个疾病节点如果被创建两次,后面问答系统匹配就会翻车。导入脚本我用py2neo写成批量模式,而不是在循环里逐条create,逐条插入10万条记录能跑几个小时,批量插入十分钟以内。
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) BATCH = 500 batch = [] with open("triples.csv", "r", encoding="utf-8-sig") as f: next(f) # 跳过表头 for line in f: disease, relation, target = line.strip().split(",") d_node = Node("Disease", name=disease) t_node = Node(target_type(relation), name=target) rel = Relationship(d_node, relation, t_node) batch.append(rel) if len(batch) >= BATCH: graph.create(*batch) # 一次提交一整批 batch.clear() if batch: graph.create(*batch)逻辑说明:py2neo的create会自动为不存在的节点创建新节点,所以这里的d_node和t_node即使重复创建也不会报错,只会产生重复数据。target_type是一个外部函数,需要根据relation判断目标节点类型,例如HAS_SYMPTOM对应Symptom。参数说明:BATCH设为500比较稳妥,太小则事务开销大,太大则单次事务内存压力高;auth里的密码不要硬编码,毕设里可以用环境变量读。导入完成后,用下面这段Cypher补上约束,这是很多教程不会写但实际必须做的事:
CREATE CONSTRAINT disease_unique IF NOT EXISTS ON (d:Disease) ASSERT d.name IS UNIQUE; CREATE CONSTRAINT symptom_unique IF NOT EXISTS ON (s:Symptom) ASSERT s.name IS UNIQUE;设置唯一约束后,如果再用graph.create去做重复导入,Neo4j会直接报唯一性冲突,这反而帮我们暴露了清洗阶段漏掉的重复数据。这一步做完,图谱的底层就稳了。我在实际项目中还习惯再跑一条统计查询确认数据量级,比如MATCH (d:Disease) RETURN count(d),如果疾病节点数和CSV里的去重后数量对不上,说明导入时节点被重复创建了,要从清洗再检查。
3. 问答链路实现:问句意图识别、实体链接与Cypher生成
3.1 问句意图识别:为什么先做规则而不是直接上BERT
医疗问答的意图集合其实很有限:问症状、问科室、问药品、问检查,再加上一个“不在知识库范围内”的兜底。用规则做意图识别,既不用标注数据集,也能在答辩时把每一类意图为什么这么匹配讲得明明白白,这比塞一个训练好的模型更有说服力。等系统跑通之后,再考虑用训练模型替换规则层,这是“先可解释、后智能化”的稳妥路线。
intent_rules = { "disease_symptom": ["有什么症状", "有哪些表现", "症状是什么"], "disease_department": ["挂什么科", "哪个科室", "去什么科", "看哪个科"], "disease_drug": ["吃什么药", "用什么药", "服药", "用药"], "disease_check": ["做什么检查", "怎么查", "检查项目"], } def predict_intent(question): for intent, keywords in intent_rules.items(): for kw in keywords: if kw in question: return intent return "unknown"这里有个容易被忽略的细节:关键词的设定不能只看词本身,要结合用户的真实口语。“高血压挂什么科”和“高血压应该去哪个科室”在用词上完全不同,所以同一种意图要尽可能多地写同义表达。规则匹配的局限在于遇到没见过的说法会落空,所以兜底分支“unknown”一定要有,否则系统会在实体识别成功但意图未知时给出莫名其妙的结果。排序上,把更具体的意图判断放在前面,比如“有什么症状”和“症状是什么”这类,避免“药”字同时出现在症状描述里造成误判。
3.2 实体识别:用自定义词典解决医疗词汇被切碎的问题
医疗命名实体识别最让人头疼的不是模型能力,而是分词器不认识医学词汇。比如“布洛芬缓释胶囊”如果没加词典,可能被切成“布洛芬/缓释/胶囊”,其中任何一个词都链接不到图谱里的Drug节点。毕设阶段的最佳方案是:jieba加载自定义医疗词典,词典里的词从图谱里直接导出,做到“图谱有什么,词典就有什么”。
import jieba from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) result = graph.run("MATCH (n) RETURN labels(n)[0] AS label, n.name AS name") with open("medical_dict.txt", "w", encoding="utf-8") as f: for record in result: f.write(record["name"] + "\n") jieba.load_userdict("medical_dict.txt") def extract_entities(question): segs = [w.strip() for w in jieba.cut(question) if w.strip()] entities = [] for seg in segs: # 同时命中“疾病”和“症状”时,优先按疾病处理 if "Disease" in node_types.get(seg, []): entities.append(("Disease", seg)) elif "Symptom" in node_types.get(seg, []): entities.append(("Symptom", seg)) return entities说明一下:从Neo4j导出所有实体名生成词典,可以保证词典与图谱数据完全同步,这是最不容易出错的方案。node_types是一个提前加载好的字典,把每个实体名映射到它所属的标签集合,因为同一个词可能既是疾病名也是症状名,比如“贫血”在Medical dict里两个标签都存在,这时候按“具体优先”原则处理。参数层面:jieba的load_userdict只需要在进程启动时调用一次,不要把它放进每个请求里,否则性能会很难看。
3.3 Cypher查询生成与答案返回:参数化查询是唯一选择
实体识别和意图识别都做完后,就到了整个问答链路最关键的一步:把“用户到底想问什么”翻译成Cypher。常见的错误做法是直接用字符串拼接Cypher,比如f"MATCH (d:Disease {{name:'{disease}'}})",一旦实体名里包含特殊字符,查询就炸了,而且有注入风险。正确做法是使用参数化查询。
def build_query(intent, entity_name): if intent == "disease_department": cypher = ( "MATCH (d:Disease {name: $name})-[:VISIT]->(dept:Department) " "RETURN dept.name AS answer LIMIT 3" ) elif intent == "disease_drug": cypher = ( "MATCH (d:Disease {name: $name})-[:TAKE]->(drug:Drug) " "RETURN drug.name AS answer LIMIT 3" ) elif intent == "disease_symptom": cypher = ( "MATCH (d:Disease {name: $name})-[:HAS_SYMPTOM]->(s:Symptom) " "RETURN s.name AS answer LIMIT 5" ) else: cypher = None return cypher for item in entities: cypher = build_query(intent, item[1]) if cypher: res = graph.run(cypher, name=item[1]).data() answers = [r["answer"] for r in res] if answers: return {"intent": intent, "entity": item[1], "answers": answers}这里的关键参数是$name,它对应graph.run的第二个关键字参数name。这样做的好处是:实体名里的中英文括号、引号、单引号都不需要手动转义,Neo4j驱动会处理。LIMIT的取值和意图挂钩,症状可以给5条,因为一个病可能有很多症状;科室和药品给3条就够,多了用户也记不住。另外注意return后的answer字段要和Cypher里的AS answer完全对应,大小写错了会直接KeyError。
3.4 问答主流程编排:把三个模块串成一个闭环
有了意图识别、实体识别和查询生成三个模块,就可以把它们编排成一个完整的问答函数。这个函数就是整个系统的门面,不管后面接Flask还是接命令行,都调它。
def qa_pipeline(question): question = question.strip() intent = predict_intent(question) entities = extract_entities(question) if not entities: return {"answers": ["抱歉,我还没有学到这个疾病的知识"], "intent": intent} if intent == "unknown": return {"answers": ["这个问题我暂时无法回答,建议咨询医生"], "intent": intent} for etype, name in entities: cypher = build_query(intent, name) if not cypher: continue answer = graph.run(cypher, name=name).data() answers = [r["answer"] for r in answer] if answers: return {"answers": answers, "intent": intent, "entity": name} return {"answers": ["没有找到对应信息,请换个问法"], "intent": intent}这段编排逻辑有几层防护:实体为空时不要继续拼Cypher,避免拿空字符串去查图;意图unknown时要礼貌兜底,而不是返回空列表让前端报错;拿到答案时要立刻返回,避免同一问题匹配到多个实体时重复查库。整个问答链路到这里已经是能跑通的状态,但离“能演示、能答辩”还差一步,就是要把各种异常情况处理掉,这正是下一章要讲的踩坑内容。
4. 医疗问答系统的避坑清单:从数据到查询的四类翻车现场
4.1 翻车现场一:实体识别把“高血压”切成了“高/血压”
现象:用户输入“高血压挂什么科”,实体识别结果为空,系统返回“抱歉,我还没有学到这个疾病”。但图谱里明明有“高血压”这个节点。
原因:jieba默认词典里没有“高血压”这个医疗词条,分词时它把词切成了“高”和“血压”,而“高”这个单字并不在实体词典里,导致实体匹配失败。这类问题在医疗词汇上非常集中,尤其是“高血压”“糖尿病”“冠心病”这类三字词。
解决:从Neo4j导出所有实体名生成medical_dict.txt,然后在线程启动时执行jieba.load_userdict。生成后务必手动测试一句包含最长实体名的问句,比如“上呼吸道感染挂什么科”,如果切分结果还是碎的,说明词典没加载成功,检查文件路径和编码,medical_dict.txt必须是UTF-8无BOM格式。
4.2 翻车现场二:Neo4j导入10万条数据跑到怀疑人生
现象:用循环逐条graph.create()导入三万多条三元组,跑了一小时才导入一半,而且Neo4j的CPU占用极高,其他查询全部卡顿。
原因:每条create都是一次独立事务,事务提交开销远超数据写入本身。图数据库不是关系型数据库,不能按“一行一条insert”的思维操作。
解决:改成批量提交。上面2.3节已经给过实现,核心是控制BATCH大小,我试过500到2000,500的时候单次事务体量小、失败重试成本低,是最稳的。另外导入前先把唯一约束建好,虽然导入时校验会增加一点开销,但能避免导入完才发现重复节点成堆的绝境。
4.3 翻车现场三:同义词没处理,“高血压”查得到“hypertension”查不到
现象:用户问“高血压吃什么药”能正常回答,换成“血压高吃什么药”就返回“没有找到对应信息”。
原因:百科类数据源里,症状和疾病名称往往是标准名,但用户口语句式里大量使用简称或口语化表达,比如“血压高”指的就是“高血压”,“糖尿病”可能被说成“血糖高”。图谱里没有建立同义词映射关系。
解决:在数据清洗阶段增加一个同义词映射表,手工整理常见别名。不要试图用算法自动发现同义词,毕设阶段人工维护50到100条映射就够覆盖演示场景。具体做法是在实体识别阶段做一层规范化,把“血压高”映射成“高血压”,然后再去图谱查询。这比在同义词上建关系更简单,也不影响图结构。
4.4 翻车现场四:Cypher查出来的答案顺序每次都变,答辩时前后不一致
现象:同一个问题连续问两次,答案列表顺序不一样,有时候答案是“神经内科”在前,有时候变成“心血管内科”在前。
原因:Cypher的MATCH在不带ORDER BY时,返回顺序取决于图存储的物理顺序,不是逻辑上的稳定顺序。这在Neo4j里不是bug,但会让演示看起来很不可靠。
解决:所有返回答案的Cypher都加上ORDER BY,没有业务权重时按名称排序,让输出稳定。如果有业务权重,比如导诊场景里“内科”类科室排在前面,可以在关系上增加一个weight属性,然后ORDER BY weight DESC。
4.5 翻车现场五:实体匹配成功但查询结果为空,查了半天是关系方向反了
现象:用户问“感冒有什么症状”,实体识别到了“感冒”和“症状”,Cypher写的是MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:Symptom),但返回空列表。
原因:导入数据时把关系方向建反了,变成了(s:Symptom)-[:HAS_SYMPTOM]->(d:Disease)。Cypher里的箭头方向是严格的,反向查询什么都查不到。这类问题在数据量大的时候特别难排查。
解决:写一个快速校验函数,随机抽5个疾病节点,用MATCH (d:Disease)-[r]->(n) RETURN type(r), labels(n), n.name LIMIT 5检查关系方向和关系类型是否正常。如果发现方向不对,不用重新导入,可以用Cypher批量修正:MATCH (s:Symptom)-[r:HAS_SYMPTOM]->(d:Disease) DELETE r CREATE (d)-[:HAS_SYMPTOM]->(s)。这段Cypher替换关系方向时务必先在测试库上跑一遍,确认关系类型和节点标签无误再执行。
5. 评估问答效果与整理毕设论文资料:验证方法、可复现实验与三个收尾技巧
毕设答辩最怕的不是功能不完整,而是被问“你的系统准确率是多少”时答不上来。所以系统跑通后,第一时间要做的不是完善界面,而是构建一个最小测试集做效果评估。我习惯按意图各准备20条人工编写的问句,一共80条左右,标注好期望答案里必须包含的实体名,然后写一个简短的评估脚本循环调用qa_pipeline,统计准确率。
test_set = [ ("高血压挂什么科", "心血管内科"), ("感冒有什么症状", "流鼻涕"), ("2型糖尿病吃什么药", "二甲双胍"), # ... 其他测试问题 ] def evaluate(test_set): correct = 0 failure_cases = [] for question, expected in test_set: result = qa_pipeline(question) answers = result["answers"] if "answers" in result else [] if any(expected in a for a in answers): correct += 1 else: failure_cases.append((question, expected, answers)) print(f"Accuracy: {correct}/{len(test_set)} = {correct / len(test_set):.2%}") return failure_cases评估脚本会输出两点关键信息:整体准确率,以及所有失败用例。失败用例就是论文里“系统不足与改进方向”那一章的最好素材,比如“同义词覆盖不足”“长问句意图识别失败”。最后补一个日志技巧:在qa_pipeline里把每一条用户问句、识别到的实体、预测意图、返回结果和耗时写入一个CSV文件,答辩时可以直接展示50条真实日志说明系统的排查和迭代过程。这个习惯在我做过的项目里帮了大忙,不用临时编数据,所有改进依据都在日志里。希望这篇笔记的落地路径能帮到你,少走几段我当年走过的弯路。
本文还有配套的精品资源,点击获取