简介:本资源是一套基于Python实现的医疗知识图谱知识问答系统完整项目,面向计算机相关专业的毕业设计、期末大作业与课程设计需求者,也适合希望入门知识图谱与问答系统开发的初学者。项目含详细代码注释,新手也能看懂,下载后简单部署即可运行,界面美观、功能完善、操作便捷。压缩包共188个文件,约115.23MB,以41个py源码文件、44个txt说明与数据文件、21个html页面、22张png界面截图及10个js脚本为主,另含css样式、json配置、db数据库与Neo4j图数据库存储文件,覆盖前端展示、后端逻辑与图谱数据全链路。目前已有266人学习下载。读者可获得完整可运行的问答系统源码、配套使用教程与部署说明,并借助注释理解医疗实体识别、图谱查询与问答匹配的实现思路,便于二次修改与答辩展示。
1. 医疗知识图谱问答系统:从数据到答案的工程化落地
医疗知识图谱的知识问答系统,核心要解决的问题很具体:用户用自然语言问「高血压吃什么药」,系统要能理解意图、在结构化的医疗图谱里找到对应实体和关系、再组织成一句人话返回。这件事听起来像搜索,但和搜索有本质区别——搜索返回文档列表,问答要求系统自己完成「实体识别 → 意图分类 → 图谱查询 → 答案生成」这条链路。基于 Python 实现的好处是生态成熟:Neo4j 做图存储、py2neo 做连接、jieba 做分词、sklearn 或深度学习模型做意图分类,每一环都有现成轮子。这套方案适合计算机毕设选题里偏知识工程方向的同学,也适合想入门知识图谱的 Python 开发者。它不需要 GPU 也能跑通全流程,数据规模可控,是少有的「能讲清楚原理、也能看到实际效果」的题目。下面从数据准备一路讲到查询优化,把每个环节的参数和坑都摊开。
2. 医疗数据从哪来、怎么变成图谱能吃的三元组
2.1 医疗数据的三个来源与取舍
做医疗知识图谱,第一个卡点不是代码,是数据。常见做法有三条路:一是用公开的医疗知识库,比如中文医学知识图谱 CMeKG 的开放部分、OpenKG 上的医疗子集,这些数据已经整理成实体-关系-实体的形式,拿来就能用;二是从百科类页面半自动抽取,用爬虫抓疾病、症状、药品的条目,再用规则或模型抽三元组;三是自己手工整理一份小规模数据集,几十个疾病、上百种药品,足够毕设演示。
我一般会建议毕设规模控制在 5000 到 20000 个三元组之间。太少,问答效果出不来,问两句就「不知道」;太多,Neo4j 社区版单机跑起来查询会变慢,而且数据清洗的工作量会失控。如果选公开数据集,注意看清楚授权协议,别把有版权争议的数据直接打包进毕设仓库。
数据格式上,最省事的是 CSV,三列:头实体、关系、尾实体。比如「高血压, 常用药物, 硝苯地平」。这种格式导入 Neo4j 用 LOAD CSV 一条命令就能搞定,后面会讲。
2.2 用 pandas 做三元组清洗与去重
拿到原始数据后,别急着往图数据库里灌。原始数据里常见的脏东西包括:实体名带空格或全角符号、同一实体有多种写法(「2型糖尿病」和「II型糖尿病」)、关系名不统一(「治疗药物」和「常用药」混用)。这些不处理,查询时匹配不上,问答就会莫名其妙失败。
下面这段代码做三件事:统一实体名格式、按「头实体+关系+尾实体」去重、过滤掉长度异常的实体。
import pandas as pd # 读取原始三元组,假设列名为 head, relation, tail df = pd.read_csv("raw_triples.csv", encoding="utf-8") # 1. 去除首尾空格,全角转半角(简化处理,只处理常见符号) def normalize(text): text = str(text).strip() text = text.replace("(", "(").replace(")", ")") text = text.replace(",", ",").replace(";", ";") return text df["head"] = df["head"].apply(normalize) df["relation"] = df["relation"].apply(normalize) df["tail"] = df["tail"].apply(normalize) # 2. 过滤实体长度异常的行(医疗实体一般 2-20 字) df = df[(df["head"].str.len() >= 2) & (df["head"].str.len() <= 20)] df = df[(df["tail"].str.len() >= 2) & (df["tail"].str.len() <= 20)] # 3. 按三元组整体去重 df = df.drop_duplicates(subset=["head", "relation", "tail"]) # 4. 统计关系类型分布,方便后续设计意图分类 print(df["relation"].value_counts()) df.to_csv("clean_triples.csv", index=False, encoding="utf-8")逻辑说明:normalize 函数处理的是最常见的格式不一致问题,全角括号和逗号在中文医疗文本里出现频率很高,不统一的话同一个实体在 Neo4j 里会变成两个节点。长度过滤是为了去掉那些明显是噪声的行,比如把一整句话误当成实体。去重必须按三列联合去重,只按头实体去重会把合法的多关系数据删掉。
参数说明:长度阈值 2 和 20 不是绝对的,如果你的数据里有「阿司匹林肠溶片」这种长药名,上限可以放到 30。关系类型分布打印出来是为了下一步设计意图分类的类别,如果某个关系只有两三条数据,考虑合并到相近类别里。
2.3 导入 Neo4j:LOAD CSV 的写法与索引建立
Neo4j 是这套方案里最常用的图数据库,社区版免费,单机跑几万节点没问题。导入前先确认 Neo4j 服务已启动,默认端口 7474(浏览器)和 7687(Bolt 协议)。
把 clean_triples.csv 放到 Neo4j 安装目录的 import 文件夹下,然后在 Neo4j Browser 里执行:
// 创建约束,保证实体唯一,同时自动建索引 CREATE CONSTRAINT entity_name IF NOT EXISTS FOR (n:Entity) REQUIRE n.name IS UNIQUE; // 导入三元组 LOAD CSV WITH HEADERS FROM 'file:///clean_triples.csv' AS row MERGE (h:Entity {name: row.head}) MERGE (t:Entity {name: row.tail}) MERGE (h)-[:RELATION {type: row.relation}]->(t);逻辑说明:MERGE 而不是 CREATE,是因为 MERGE 会先查再建,避免同一个实体被重复创建成多个节点。约束语句里的 IS UNIQUE 会自动为 name 属性建索引,后面按实体名查询时速度差别很大——没索引时几万节点全表扫描要几百毫秒,有索引基本在 10 毫秒以内。
参数说明:关系类型这里用了一个通用 RELATION 标签加 type 属性,而不是给每种关系建一种边类型。这样做的好处是导入脚本不用改,坏处是查询时要按 type 属性过滤。如果你的关系种类少于 20 种,也可以直接建成不同类型的边,查询写起来更直观。
导入完成后验证一下:
MATCH (n:Entity) RETURN count(n) AS node_count; MATCH ()-[r:RELATION]->() RETURN count(r) AS rel_count;节点数和关系数应该和你 CSV 里的统计对得上。对不上就检查 CSV 是否有空行、编码是否是 UTF-8。
3. 问句理解:意图分类和实体识别的工程实现
3.1 意图分类为什么用 sklearn 而不是直接上 BERT
问句理解分两步:先判断用户问的是哪类问题(问症状、问药物、问检查、问科室),再从问句里把医疗实体抠出来。意图分类这一步,很多人第一反应是上 BERT。但对于毕设规模的数据——通常几百到几千条标注问句——BERT 微调容易过拟合,而且推理需要 GPU 或较长的 CPU 时间,调试起来也麻烦。
我的经验是:先用 TF-IDF + 线性 SVM 跑一版基线。医疗问句的意图特征其实很明显,「吃什么药」和「有什么症状」在词面上区分度很高,TF-IDF 完全够用。等基线跑通了,如果准确率不满意,再考虑换模型。这样你在毕设答辩时也能讲清楚「为什么选这个方案」,而不是「因为别人都用 BERT」。
下面是一个完整的意图分类训练脚本:
import jieba import pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.svm import LinearSVC from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report import joblib # 加载标注数据,格式:question, intent data = pd.read_csv("intent_train.csv", encoding="utf-8") # 中文分词,医疗领域可以加载自定义词典 jieba.load_userdict("medical_dict.txt") # 每行一个医疗词 def cut(text): return " ".join(jieba.cut(text)) data["seg"] = data["question"].apply(cut) # 划分训练集和测试集 X_train, X_test, y_train, y_test = train_test_split( data["seg"], data["intent"], test_size=0.2, random_state=42, stratify=data["intent"] ) # TF-IDF 向量化 vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2)) X_train_vec = vectorizer.fit_transform(X_train) X_test_vec = vectorizer.transform(X_test) # 线性 SVM 训练 clf = LinearSVC(C=1.0, max_iter=2000) clf.fit(X_train_vec, y_train) # 评估 y_pred = clf.predict(X_test_vec) print(classification_report(y_test, y_pred)) # 保存模型和向量器 joblib.dump(vectorizer, "tfidf.pkl") joblib.dump(clf, "intent_clf.pkl")逻辑说明:jieba.load_userdict 加载自定义词典是关键一步,医疗词如「硝苯地平」「冠状动脉」如果被切碎,TF-IDF 特征就废了。ngram_range 设为 (1,2) 是为了捕捉「什么药」这种二元短语。stratify 参数保证训练集和测试集的类别比例一致,避免小类别在测试集里消失。
参数说明:max_features=5000 是经验值,数据量小可以降到 2000,数据量大可以升到 10000。C 是 SVM 的正则化参数,C 越大越容易过拟合,1.0 是常用起点。如果某个类别的 recall 明显偏低,先看训练数据里这个类别的样本是不是太少,少于 50 条的话考虑补充数据或合并类别。
3.2 实体识别:词典匹配 + 规则兜底
实体识别在医疗问答里不需要太复杂。因为图谱里的实体名是已知的,直接用词典匹配就能覆盖大部分情况。做法是把 Neo4j 里所有实体名导出来,构建一个前缀树或直接用 Aho-Corasick 算法做多模式匹配。
import ahocorasick from py2neo import Graph # 从 Neo4j 拉取所有实体名 graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) entities = [record["name"] for record in graph.run("MATCH (n:Entity) RETURN n.name AS name")] # 构建 AC 自动机 A = ahocorasick.Automaton() for idx, name in enumerate(entities): A.add_word(name, (idx, name)) A.make_automaton() def recognize_entities(question): """返回问句中出现的所有图谱实体""" found = [] for end_index, (idx, name) in A.iter(question): found.append(name) # 去重并按长度降序,优先匹配长实体 found = sorted(set(found), key=len, reverse=True) return found # 测试 q = "高血压患者能吃硝苯地平吗" print(recognize_entities(q)) # ['高血压', '硝苯地平']逻辑说明:AC 自动机适合实体数量在几千到几万级别的场景,构建一次可以反复用。按长度降序排序是为了处理嵌套实体,比如「糖尿病」和「2型糖尿病」同时匹配时,优先取长的那个。
参数说明:如果实体数量超过 10 万,AC 自动机的内存占用会比较大,这时候考虑用 Elasticsearch 的 term 查询或者自己建倒排索引。auth 里的密码换成你本地 Neo4j 的密码,默认是 neo4j/neo4j,首次登录会要求改。
3.3 意图和实体怎么串成一条查询
拿到意图和实体后,需要把它们映射成 Cypher 查询。这一步用一个配置表来管理最清晰:
INTENT_QUERY_MAP = { "问药物": "MATCH (e:Entity {{name: '{entity}'}})-[r:RELATION]->(t) WHERE r.type IN ['常用药物', '治疗药物'] RETURN t.name AS answer", "问症状": "MATCH (e:Entity {{name: '{entity}'}})-[r:RELATION]->(t) WHERE r.type = '临床症状' RETURN t.name AS answer", "问科室": "MATCH (e:Entity {{name: '{entity}'}})-[r:RELATION]->(t) WHERE r.type = '所属科室' RETURN t.name AS answer", "问检查": "MATCH (e:Entity {{name: '{entity}'}})-[r:RELATION]->(t) WHERE r.type = '相关检查' RETURN t.name AS answer", } def query_graph(intent, entities): if not entities: return "没有识别到医疗实体,请换一种问法" entity = entities[0] # 简化处理,取第一个实体 cypher = INTENT_QUERY_MAP.get(intent) if not cypher: return "暂不支持这类问题" cypher = cypher.format(entity=entity) results = graph.run(cypher).data() if not results: return f"图谱中没有找到关于「{entity}」的相关信息" answers = [r["answer"] for r in results] return "、".join(answers)逻辑说明:用字典把意图映射到 Cypher 模板,新增意图只需要加一条配置,不用改查询逻辑。format 时注意 Cypher 里的大括号要转义成双大括号。取第一个实体是简化处理,如果问句里有多个实体,需要根据意图决定用哪个,比如「高血压和糖尿病有什么区别」这种对比问句,要两个实体都用到。
参数说明:r.type IN [...] 里的关系名必须和导入时 CSV 里的关系名完全一致,大小写和空格都不能差。如果查询返回空,先用 Neo4j Browser 手动跑一遍 Cypher,确认是数据问题还是代码问题。
4. 避坑与排查:医疗问答系统最容易翻车的五个地方
4.1 实体匹配不上,问句里明明有这个词
现象:用户问「糖尿病吃什么药」,系统返回「没有识别到医疗实体」,但图谱里确实有「糖尿病」这个节点。
原因:最常见的是分词或匹配时的编码问题。CSV 导入 Neo4j 时如果编码不是 UTF-8,实体名里会混入不可见字符。另一个原因是实体名在问句里以别名出现,比如图谱里存的是「2型糖尿病」,用户说的是「二型糖尿病」。
解决:先用MATCH (n:Entity) WHERE n.name CONTAINS '糖尿病' RETURN n.name确认图谱里的实际存储值。如果是别名问题,建一张别名表,在实体识别前先把问句里的别名替换成标准名。编码问题就重新用 UTF-8 导入一遍。
4.2 意图分类准确率虚高,实际用起来不对
现象:训练时 classification_report 显示准确率 95%,但实际测试时经常把「问症状」判成「问药物」。
原因:训练数据里不同意图的样本分布不均,或者测试集和训练集有重叠问句。另一个隐蔽原因是 TF-IDF 向量化时用了 fit_transform 在全部数据上,导致信息泄漏。
解决:先检查每个类别的样本数,少于 50 条的类别要补充数据。然后确认 train_test_split 是在向量化之前做的,vectorizer 只能 fit 训练集。如果还是不行,打印混淆矩阵,看具体是哪两个类别在互相混淆,针对性补充区分性样本。
4.3 Neo4j 查询超时,几万节点就卡住
现象:图谱导入了几万条三元组后,问答查询要等好几秒才返回,有时候直接超时。
原因:没有建索引,或者 Cypher 写法导致全图扫描。比如MATCH (n)-[r]->(m) WHERE n.name = '高血压' RETURN m这种写法,如果 name 上没有索引,Neo4j 会遍历所有节点。
解决:确认约束建了:SHOW CONSTRAINTS看有没有 Entity.name 的唯一约束。查询时尽量先用索引定位起始节点,再展开关系。如果数据量确实大,考虑给关系类型也建索引,或者把常用查询结果缓存起来。
4.4 答案拼接生硬,用户看不懂
现象:系统返回「硝苯地平、氨氯地平、缬沙坦」,用户不知道这是药名还是别的什么。
原因:查询只返回了实体名,没有返回关系类型和上下文。
解决:在答案生成时加上模板。比如问药物就返回「高血压的常用药物包括:硝苯地平、氨氯地平、缬沙坦。」问症状就返回「糖尿病的常见症状有:多饮、多尿、多食、体重下降。」模板可以存在配置表里,按意图选择。
4.5 多实体问句只处理了第一个
现象:用户问「高血压和糖尿病分别吃什么药」,系统只返回了高血压的药物。
原因:实体识别返回了多个实体,但查询逻辑只取了 entities[0]。
解决:根据意图判断是否需要多实体查询。对比类问句要两个实体都查,然后分别组织答案。实现上可以把 query_graph 改成接收实体列表,返回一个字典,key 是实体名,value 是答案列表,最后统一拼接。
5. 让问答更准:同义词扩展与查询兜底的具体做法
前面跑通的是最小可用版本,但实际用起来会发现两个问题:一是用户问法千变万化,词典匹配覆盖不全;二是有些问题图谱里确实没有直接答案,但可以通过推理得到。这一章讲两个提升点,都是我在实际项目里验证过有效的。
先说同义词扩展。医疗领域同义词特别多,「心梗」和「心肌梗死」、「甲亢」和「甲状腺功能亢进症」,用户不会按图谱的标准名来问。做法是建一张同义词表,在实体识别之前做一次替换。同义词表可以手工整理,也可以从公开的医疗词表里抽。下面是一个简单的实现:
# 同义词映射表,key 是用户可能说的,value 是图谱标准名 SYNONYM_MAP = { "心梗": "心肌梗死", "甲亢": "甲状腺功能亢进症", "二型糖尿病": "2型糖尿病", "高血压病": "高血压", } def expand_synonyms(question): for alias, standard in SYNONYM_MAP.items(): if alias in question: question = question.replace(alias, standard) return question # 在实体识别前调用 q = expand_synonyms("心梗吃什么药") entities = recognize_entities(q) # 现在能匹配到「心肌梗死」逻辑说明:替换要在实体识别之前做,否则 AC 自动机匹配不到别名。替换时注意长词优先,比如「高血压病」和「高血压」同时存在时,先替换长的,避免「高血压病」被替换成「高血压病」这种半截结果。实际项目中同义词表可能有几百条,建议存在 CSV 或数据库里,不要硬编码在 Python 文件里。
再说查询兜底。用户问「高血压会引发什么病」,如果图谱里只有「高血压-并发症->冠心病」这一条关系,直接查能返回。但如果用户问「高血压和冠心病有什么关系」,这就需要双向查询。兜底策略是:当按意图查询返回空时,自动放宽条件,查该实体的所有出边和入边,把关系类型和对方实体一起返回。
def fallback_query(entity): """兜底查询:返回实体的所有直接关联""" cypher = """ MATCH (e:Entity {name: $name})-[r:RELATION]->(t) RETURN r.type AS rel, t.name AS target, 'out' AS direction UNION MATCH (s)-[r:RELATION]->(e:Entity {name: $name}) RETURN r.type AS rel, s.name AS target, 'in' AS direction """ results = graph.run(cypher, name=entity).data() if not results: return None lines = [] for r in results: if r["direction"] == "out": lines.append(f"{entity}的{r['rel']}包括:{r['target']}") else: lines.append(f"{r['target']}的{r['rel']}包括:{entity}") return ";".join(lines)逻辑说明:UNION 把出边和入边查询合并,这样不管用户从哪个方向问都能兜住。参数化查询用 $name 而不是字符串拼接,避免 Cypher 注入。返回结果按方向组织成自然语言,用户能看懂。
参数说明:兜底查询返回的结果可能很多,实际使用时可以限制返回条数,比如 LIMIT 10,避免一次返回几十条把界面撑爆。如果某个实体的关联特别多,考虑按关系类型分组返回。
最后说一个验证方法:准备 50 到 100 条测试问句,覆盖所有意图和常见实体,每次改完代码跑一遍,统计准确率。这个测试集不用很大,但要有代表性。我一般会把测试问句和期望答案存在 CSV 里,写一个脚本自动跑,输出哪些通过了、哪些失败了。这样改代码时心里有底,不会出现「改了一个地方,另一个地方又坏了」的情况。
这套系统从数据到问答,核心代码量其实不大,难点在数据清洗和边界处理。我的习惯是每加一个功能,先用手动构造的极端 case 测一遍,比如空问句、超长问句、图谱里不存在的实体,确保不会崩。毕设答辩时,老师问「如果用户问了一个图谱里没有的疾病怎么办」,你能答出兜底策略和友好提示,比只讲模型准确率更有说服力。希望帮到你。
本文还有配套的精品资源,点击获取