ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python医疗知识图谱问答系统:从数据到答案的工程化落地

Python医疗知识图谱问答系统:从数据到答案的工程化落地 简介本资源是一套基于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, encodingutf-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, indexFalse, encodingutf-8)逻辑说明normalize 函数处理的是最常见的格式不一致问题全角括号和逗号在中文医疗文本里出现频率很高不统一的话同一个实体在 Neo4j 里会变成两个节点。长度过滤是为了去掉那些明显是噪声的行比如把一整句话误当成实体。去重必须按三列联合去重只按头实体去重会把合法的多关系数据删掉。参数说明长度阈值 2 和 20 不是绝对的如果你的数据里有「阿司匹林肠溶片」这种长药名上限可以放到 30。关系类型分布打印出来是为了下一步设计意图分类的类别如果某个关系只有两三条数据考虑合并到相近类别里。2.3 导入 Neo4jLOAD CSV 的写法与索引建立Neo4j 是这套方案里最常用的图数据库社区版免费单机跑几万节点没问题。导入前先确认 Neo4j 服务已启动默认端口 7474浏览器和 7687Bolt 协议。把 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, encodingutf-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_size0.2, random_state42, stratifydata[intent] ) # TF-IDF 向量化 vectorizer TfidfVectorizer(max_features5000, ngram_range(1, 2)) X_train_vec vectorizer.fit_transform(X_train) X_test_vec vectorizer.transform(X_test) # 线性 SVM 训练 clf LinearSVC(C1.0, max_iter2000) 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_features5000 是经验值数据量小可以降到 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), keylen, reverseTrue) 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(entityentity) 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, nameentity).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 测一遍比如空问句、超长问句、图谱里不存在的实体确保不会崩。毕设答辩时老师问「如果用户问了一个图谱里没有的疾病怎么办」你能答出兜底策略和友好提示比只讲模型准确率更有说服力。希望帮到你。本文还有配套的精品资源点击获取
返回列表