拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

医疗知识图谱问答系统实战:从三元组构建到多跳查询的Python实现

医疗知识图谱问答系统实战:从三元组构建到多跳查询的Python实现

简介:这是一套面向计算机相关专业学生与项目实战学习者的医疗知识图谱知识问答系统毕业设计资料,包含完整源码与配套数据,适合用作毕业设计、课程大作业或知识图谱与问答系统方向的练手项目。压缩包共70个文件,约51.61MB,以32个Python源码文件为核心,辅以json数据、pkl模型文件、docx文档、bat启动脚本及txt说明等,覆盖知识抽取、意图识别、命名实体识别、知识图谱构建与问答交互等模块,目录结构清晰,便于按功能模块阅读与调试。项目经导师指导并获评审98分,源码均经本地编译与严格调试,可正常运行,难度适中。已有58人学习关注。读者可据此掌握从医疗数据到图谱构建再到问答服务的完整链路,理解BERT意图识别、BiLSTM实体抽取等关键实现,并借助现成脚本与配置快速复现环境、排查问题,节省从零搭建的时间成本。

1. 从一份毕业设计源码说起:医疗知识图谱问答到底在做什么

很多计算机专业的同学在选题时会遇到一个尴尬:想做点有技术含量的东西,又怕踩到「数据拿不到、模型跑不动、答辩讲不清」的坑。医疗知识图谱知识问答系统这个题目之所以年年有人选,恰恰是因为它踩在了一个甜点区——数据可以自己造,技术栈全是 Python 生态里的成熟件,而且做出来的东西演示效果直观:输入「高血压吃什么药」,系统能顺着图谱关系把答案推出来,而不是像传统检索那样丢一堆网页给你。

这个系统的本质,是把非结构化的医疗文本(疾病描述、症状、药品说明、诊疗指南)抽成「实体—关系—实体」的三元组,存进图数据库,再在图上做自然语言问句的解析与查询。它解决的核心问题是:让机器理解「糖尿病」和「胰岛素」之间不是简单的关键词共现,而是有「治疗用药」这条明确的语义边。适合谁做?适合已经学过 Python 基础、想找一个能同时展示数据处理、图数据库、NLP 三块能力的毕业设计选题的同学,也适合想快速搭一个领域问答原型的工程师。

我见过太多人一上来就想着接大模型、上向量库,结果连三元组都没抽干净,问「感冒吃什么」返回「糖尿病」。这篇笔记就按我实际带过几届学生的路径,把从环境搭建到图谱构建、问句解析、系统联调的完整链路拆开讲,源码结构、参数设置、翻车点都摆出来,你照着能跑通,也能看懂每一步为什么这么做。

2. 环境与数据准备:Python 医疗知识图谱问答系统的地基怎么打

2.1 技术选型:为什么是 Neo4j + jieba + py2neo 这套组合

做医疗知识图谱问答,绕不开三个环节:存图、切词、连图。存图我一般推荐 Neo4j,原因是它的 Cypher 查询语言对「多跳关系」的表达极其自然,比如「找高血压患者可能出现的并发症对应的药品」,用 Cypher 两三行就能写出来,换成关系型数据库要写一堆 JOIN。而且 Neo4j 有社区版免费可用,本地装一个 Desktop 版本,图形化界面直接能看到节点和边,答辩演示时非常加分。

切词用 jieba,这是 Python 中文分词里最稳的选择,医疗领域可以加载自定义词典,把「二甲双胍」「冠状动脉粥样硬化」这类专业词加进去,避免被切碎。连图用 py2neo,它是 Python 操作 Neo4j 的成熟库,虽然官方现在主推 neo4j 驱动,但 py2neo 的 API 更贴近「面向对象建图」的直觉,适合毕业设计这种需要快速出效果的场景。

至于问句解析,新手容易一上来就上 BERT 做意图分类,我的建议是先别。毕业设计的核心是展示「图谱 + 规则/模板」的完整闭环,用「实体识别 + 模板匹配」就能覆盖 80% 的演示问句,等这套跑通了再考虑加模型。下面这套环境配置是我反复验证过的,Python 3.8 到 3.10 都能跑。

# 创建虚拟环境,避免污染系统 Python python -m venv medkg_env # Windows 激活 medkg_env\Scripts\activate # macOS / Linux 激活 source medkg_env/bin/activate # 安装核心依赖,版本是我踩坑后锁定的 pip install py2neo==2021.2.4 pip install jieba==0.42.1 pip install pandas==1.5.3 pip install flask==2.2.5

这里 py2neo 锁 2021.2.4 是有原因的:更新的版本在连接 Neo4j 4.x 时偶发ConnectionUnavailable,而 2021.2.4 对 4.4 LTS 兼容最稳。Flask 用来做后端接口,版本不用太新,2.2.x 足够。

2.2 医疗数据的三种来源与清洗要点

数据是医疗知识图谱的命根子。常见做法有三条路:一是用公开的医疗知识库,比如中文医学知识图谱 CMeKG 的开放部分;二是爬取百科类网站的疾病词条,自己抽三元组;三是老师直接给一份标注好的 CSV。不管哪条路,最后你手里应该是一张「头实体、关系、尾实体」的三元组表。

我一般会先把数据整理成下面这种 CSV 结构,字段名固定,后面入库脚本直接读:

字段名含义示例
head头实体高血压
relation关系类型并发症
tail尾实体冠心病
head_type头实体类别Disease
tail_type尾实体类别Disease

清洗时最容易翻车的是实体对齐。「高血压」和「高血压病」如果不合并,图谱里就会出现两个节点,问句匹配时命中率直接掉一半。我的处理方式是建一个别名词典,用 pandas 做一次映射替换:

import pandas as pd # 读取原始三元组 df = pd.read_csv("raw_triples.csv", encoding="utf-8") # 别名词典:把不规范写法统一到标准实体名 alias_map = { "高血压病": "高血压", "2型糖尿病": "糖尿病", "Ⅱ型糖尿病": "糖尿病", "冠心病": "冠状动脉粥样硬化性心脏病", } # 对 head 和 tail 同时做替换 df["head"] = df["head"].replace(alias_map) df["tail"] = df["tail"].replace(alias_map) # 去重,避免同一三元组重复入库 df = df.drop_duplicates(subset=["head", "relation", "tail"]) df.to_csv("clean_triples.csv", index=False, encoding="utf-8") print(f"清洗后剩余 {len(df)} 条三元组")

这段代码的逻辑很直白:先读原始数据,再用字典把别名映射到标准名,最后去重。参数上要注意encoding="utf-8",医疗数据里常有生僻字,用 gbk 会报错。drop_duplicates的 subset 一定要指定三个字段,否则只要 head 相同就会被误删。

提示:清洗完一定要打印实体总数和关系类型分布,如果某个关系类型只有一两条,考虑合并到相近关系里,否则图谱会显得很稀疏,答辩时不好看。

3. 图谱构建:用 py2neo 把三元组灌进 Neo4j 的完整脚本

3.1 节点与关系的建模:先定 Schema 再写代码

很多人拿到三元组就直接往 Neo4j 里塞,结果节点没有标签、关系没有方向,查的时候一团乱。正确做法是先定 Schema。医疗知识图谱常见的节点标签有:Disease(疾病)、Symptom(症状)、Drug(药品)、Check(检查项目)、Department(科室)。关系类型对应有:has_symptom、treated_by、need_check、belongs_to、complication_of。

Schema 定好后,入库脚本要分两步:先建节点,再建关系。建节点时用MERGE而不是CREATE,这样重复实体不会产生多个节点。下面是我常用的入库脚本骨架:

from py2neo import Graph, Node, Relationship import pandas as pd # 连接 Neo4j,默认 bolt 端口 7687 graph = Graph("bolt://localhost:7687", auth=("neo4j", "你的密码")) df = pd.read_csv("clean_triples.csv", encoding="utf-8") # 用字典缓存已创建的节点,避免重复查询数据库 node_cache = {} def get_or_create_node(name, label): key = (name, label) if key in node_cache: return node_cache[key] # MERGE 保证同名同标签只建一个节点 node = Node(label, name=name) graph.merge(node, label, "name") node_cache[key] = node return node for _, row in df.iterrows(): head_node = get_or_create_node(row["head"], row["head_type"]) tail_node = get_or_create_node(row["tail"], row["tail_type"]) rel = Relationship(head_node, row["relation"], tail_node) graph.merge(rel) print("入库完成")

逻辑说明:node_cache是关键优化,如果不加缓存,每处理一行都要去数据库查一次节点是否存在,几千条数据能跑十几分钟。graph.merge(node, label, "name")的意思是「按 label 和 name 属性合并」,这是 py2neo 里保证幂等的标准写法。关系用graph.merge(rel)同样保证不重复。

参数上,连接字符串bolt://localhost:7687是 Neo4j 默认的,如果你改了端口要同步改。auth 里的密码是你第一次启动 Neo4j 时设的,忘了就去 Desktop 里重置。

3.2 用 Cypher 验证图谱:三条必查语句

入库完别急着写问答,先用 Cypher 确认图谱结构对不对。我一般会跑这三条:

// 1. 看节点总数和标签分布 MATCH (n) RETURN labels(n) AS label, count(*) AS cnt ORDER BY cnt DESC; // 2. 看关系类型分布 MATCH ()-[r]->() RETURN type(r) AS rel, count(*) AS cnt ORDER BY cnt DESC; // 3. 抽查一个疾病的邻居 MATCH (d:Disease {name: "高血压"})-[r]-(n) RETURN d.name, type(r), n.name LIMIT 20;

第一条确认节点没建重复,第二条确认关系类型没有拼写错误(比如 has_symptom 写成了 hassymptom),第三条是直观检查「高血压」这个节点周围有没有合理的症状、药品、并发症。如果第三条返回空,说明你的实体名和查询名不一致,回去检查别名字典。

注意:Neo4j 社区版默认内存有限,如果三元组超过十万条,导入时可能报OutOfMemory。解决办法是分批导入,每 5000 条 commit 一次,或者调大neo4j.conf里的堆内存。

4. 问句解析与查询:从「高血压吃什么药」到 Cypher 的映射

4.1 实体识别:jieba 自定义词典 + 实体表匹配

问句解析的第一步是把用户问题里的医疗实体抠出来。比如「糖尿病有哪些并发症」,要能识别出「糖尿病」。纯 jieba 分词会把「糖尿病」切成一个词,但「冠状动脉粥样硬化性心脏病」这种长实体就会被切碎。解决办法是加载自定义词典,词典来源就是你图谱里所有节点的 name。

import jieba import pandas as pd # 从清洗后的三元组里收集所有实体,去重 df = pd.read_csv("clean_triples.csv", encoding="utf-8") entities = set(df["head"]).union(set(df["tail"])) # 写入自定义词典,词频给高一点保证不被切碎 with open("med_dict.txt", "w", encoding="utf-8") as f: for e in entities: f.write(f"{e} 1000\n") jieba.load_userdict("med_dict.txt") def extract_entity(question): words = jieba.lcut(question) # 优先匹配长实体,避免「糖尿病」被「糖尿」干扰 matched = [w for w in words if w in entities] # 按长度降序,取最长的作为主实体 matched.sort(key=len, reverse=True) return matched[0] if matched else None print(extract_entity("高血压患者不能吃什么药"))

这段的关键在matched.sort(key=len, reverse=True)。医疗实体常有包含关系,比如「糖尿病」和「糖尿病肾病」,如果不按长度优先,短实体可能先被选中,导致查询走偏。词频给 1000 是为了让 jieba 优先把自定义词当成一个整体。

4.2 意图模板:把问句模式映射到关系类型

识别出实体后,还要判断用户问的是哪类关系。常见问句模式我整理成了一张模板表,用正则匹配:

问句模式(正则)意图对应关系
.吃什么药.查询治疗药物treated_by
.有什么症状.查询症状has_symptom
.并发症.查询并发症complication_of
.做什么检查.查询检查项目need_check
.挂什么科.查询科室belongs_to

匹配到意图后,拼 Cypher 查询。下面是一个完整的问答函数:

import re from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "你的密码")) # 意图模板:正则 -> 关系类型 intent_patterns = [ (r".*吃什么药.*", "treated_by"), (r".*有什么症状.*", "has_symptom"), (r".*并发症.*", "complication_of"), (r".*做什么检查.*", "need_check"), (r".*挂什么科.*", "belongs_to"), ] def answer(question): entity = extract_entity(question) if not entity: return "抱歉,没有识别到医疗实体" for pattern, relation in intent_patterns: if re.match(pattern, question): # 用参数化查询,防止注入,也避免中文拼接问题 cypher = f""" MATCH (d:Disease {{name: $name}})-[:{relation}]-(n) RETURN n.name AS answer LIMIT 10 """ result = graph.run(cypher, name=entity).data() if result: return "、".join([r["answer"] for r in result]) return "图谱中暂无相关记录" return "抱歉,我还没学会回答这类问题" print(answer("高血压吃什么药"))

逻辑说明:先抽实体,再遍历意图模板,命中后用参数化 Cypher 查询。这里关系类型是拼进字符串的,因为 Cypher 不支持把关系类型作为参数,但关系类型来自我们自己的白名单,不存在注入风险。$name是参数占位,py2neo 会自动转义,中文实体名也不会出问题。

参数上,LIMIT 10是防止某个疾病关联太多药品导致返回一大串,实际演示时 5 到 10 条足够。如果返回空,先确认实体名是否和图谱里的完全一致,再看关系方向对不对——treated_by如果是疾病指向药品,那(d)-[:treated_by]-(n)里的 n 就是药品,方向别搞反。

4.3 多跳查询:处理「高血压并发症能用什么药」这类复合问句

单跳查询只能回答简单问题,真正拉开差距的是多跳。比如「高血压的并发症可以用什么药治疗」,需要先查高血压的并发症,再查这些并发症的治疗药物。Cypher 写起来很优雅:

MATCH (d:Disease {name: "高血压"})-[:complication_of]->(c:Disease) MATCH (c)-[:treated_by]->(drug:Drug) RETURN c.name AS complication, collect(drug.name) AS drugs

在 Python 里,我一般会为这类复合问句单独写一个处理分支,先判断问句里是否同时出现「并发症」和「药」两个关键词,如果出现就走多跳查询。这样代码结构清晰,也方便答辩时讲「系统支持多跳推理」。

提示:多跳查询很容易返回大量结果,演示前先用几个典型问句测一遍,把返回条数控制在合理范围,必要时加LIMIT或按关系权重排序。

5. 避坑与排查:医疗知识图谱问答系统最常见的五个翻车点

5.1 现象:问句实体识别为空,明明图谱里有这个词

原因通常是 jieba 自定义词典没加载成功,或者实体名里有空格、全角半角混用。比如图谱里存的是「高血压」,用户输入「高血压 」(带尾随空格),匹配就失败。

解决:在extract_entity里先对问句做strip()和全角转半角处理,再加载词典后打印一次jieba.lcut("高血压")确认切分结果。词典文件路径要用绝对路径,相对路径在不同工作目录下会失效。

5.2 现象:Cypher 查询报Unknown relationship type

原因是意图模板里的关系类型和图谱里实际存的不一致。比如你入库时写的是treatedBy(驼峰),模板里写的是treated_by(下划线),Cypher 找不到就报错。

解决:入库前把关系类型统一成下划线命名,并在入库脚本里加一个断言,检查所有关系类型是否在白名单内。跑一次MATCH ()-[r]->() RETURN DISTINCT type(r)把实际关系类型列出来,和模板表逐一对照。

5.3 现象:Neo4j 连接超时,程序卡在Graph(...)这行

原因多半是 Neo4j 服务没启动,或者密码错了。py2neo 在连接失败时有时不会立刻抛异常,而是卡住。

解决:先在浏览器打开http://localhost:7474确认 Neo4j 活着,再用 Neo4j Desktop 里的「Reset Password」确认密码。代码里给 Graph 加超时参数:Graph("bolt://localhost:7687", auth=(...), timeout=10),这样连不上会快速失败而不是干等。

5.4 现象:同一实体出现多个节点,图谱越查越乱

原因是入库时用了CREATE而不是MERGE,或者别名字典没覆盖全。比如「心梗」和「心肌梗死」没合并。

解决:入库脚本统一用graph.merge,并且在清洗阶段做一次实体频次统计,把出现次数少但语义相同的实体人工合并。可以写个脚本列出所有实体,按名称相似度排序,人工过一遍。

5.5 现象:Flask 接口返回中文乱码

原因是响应头没设Content-Type,或者 JSON 序列化时没指定ensure_ascii=False。

解决:Flask 里返回jsonify时加app.config['JSON_AS_ASCII'] = False,或者手动构造Response(json.dumps(data, ensure_ascii=False), content_type='application/json; charset=utf-8')。这个坑在 Windows 上尤其常见,因为默认编码是 gbk。

6. 让系统更像「智能问答」:三个提升演示效果的进阶技巧

第一个技巧是给答案加「解释路径」。用户问「高血压吃什么药」,如果只返回「硝苯地平」,显得很单薄;如果返回「高血压 —[治疗用药]→ 硝苯地平」,答辩老师一眼就能看出这是图谱推理的结果。实现方式是在 Cypher 里把路径也返回:

MATCH path = (d:Disease {name: $name})-[:treated_by]->(drug:Drug) RETURN drug.name AS drug, [rel in relationships(path) | type(rel)] AS path LIMIT 5

然后在 Python 里把 path 拼成可读字符串。这个改动很小,但演示效果提升明显。

第二个技巧是加一个「未知问句兜底」。当实体识别或意图匹配失败时,不要只返回「我不知道」,而是返回「我目前能回答症状、用药、并发症、检查、科室这几类问题,您可以换个问法试试」。这样既显得系统完整,也引导用户往你能答的方向问。

第三个技巧是用 Neo4j 的图算法做一点「推荐」。比如用apoc库里的相似度算法,找出和当前疾病最相关的其他疾病,作为「相关推荐」返回。APOC 是 Neo4j 的扩展库,Desktop 里可以一键安装。下面是一个简单的相似疾病查询:

MATCH (d:Disease {name: $name})-[:has_symptom]->(s:Symptom)<-[:has_symptom]-(other:Disease) WHERE other.name <> $name RETURN other.name AS related, count(s) AS common_symptoms ORDER BY common_symptoms DESC LIMIT 5

这段的意思是:找和当前疾病共享症状最多的其他疾病。逻辑简单,但能体现「图谱不只是存储,还能推理」的价值。

最后说个我自己的习惯:每次改完图谱或问句模板,我都会准备一组 20 条的「回归测试问句」,跑一遍看命中率。这组问句覆盖单跳、多跳、未知实体、模糊问法四种情况。毕业设计答辩前跑三遍,心里就有底了。别等到演示现场才发现某个问句返回空,那时候没有后悔药。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表