基于知识图谱的心血管疾病问答系统实现:从本体设计到Neo4j落地
简介:一套基于知识图谱的心血管疾病问答系统源码与配套数据集,面向计算机相关专业的学生、教师及企业开发者,适合作为毕业设计、课程设计、初期立项演示,也适合新手进阶学习。压缩包共197个文件、约5.52MB,以84个csv疾病数据和84个json图谱结构为核心,另有6个Python脚本、15个txt说明、README文档及演示图片,按数据层、图谱层与应用层组织,便于分段对照学习。数据涵盖心房颤动、冠心病、心力衰竭、心肌梗死等常见心血管病种,可完整实践命名实体识别、关系抽取、图谱存储与问答检索流程。项目代码已通过运行测试,目前已有58人学习下载;下载后参照说明即可启动,遇到环境或运行问题还可远程指导,同时保留了前端展示与图谱可视化的二次开发空间。
1. 基于知识图谱的心血管疾病问答系统到底解决什么问题:不是搜索,是关联
患者问“高血压挂什么科”“胸痛该做什么检查”这类问题,传统关键词搜索只能返回一堆网页片段,而基于知识图谱的心血管疾病问答系统会把疾病、症状、药物、检查、科室建模成一张关系网,让程序按关系路径直接给出结构化答案。它能回答“冠心病吃什么药”这种单跳问题,也能顺着图谱走向回答需要关联推理的多跳问题。适合正在学 Python 和知识图谱构建的开发者,也适合做毕设或简历项目的学生。项目包里的文档说明一般会先交代数据字典和启动步骤,读懂了再跑代码,能少走很多弯路。
2. 先定本体再写代码:心血管图谱有哪些实体、关系和属性
2.1 本体建模是问答系统的天花板:能回答什么由本体决定
知识图谱落到应用,第一步不是写爬虫或者堆数据,而是本体设计。本体决定了系统“知道什么”和“能回答什么”。如果本体里没有“疾病—科室”这条关系,用户问“挂什么科”就永远答不出来,加再多模板都没用。反过来,本体设计得合理,后续的清洗、灌库、问答都会顺畅很多。
面向心血管问答场景,我一般把实体收敛成五类:疾病、症状、药物、检查、科室。这五类实体已经能覆盖门诊和患者最常问的高频问题。至于食物禁忌、并发症、风险因素这类实体,第一版建议先不做,整理成本高且问答收益有限,等核心链路跑通后再扩展。
实体类型和属性建议这样设计:
| 实体类型 | 建议属性 | 示例 |
|---|---|---|
| Disease 疾病 | name, desc, alias | 高血压、冠心病、心律失常 |
| Symptom 症状 | name, alias | 胸痛、心慌、气短 |
| Drug 药物 | name, alias | 硝酸甘油、硝苯地平 |
| Check 检查 | name | 心电图、冠脉造影、心脏彩超 |
| Department 科室 | name, alias | 心血管内科、心内科 |
2.2 原始数据集长什么样:先盘点字段再动手
项目包里的数据集通常是 CSV 或 Excel 格式,最常见的组织方式是“一张主表 + 多个关联字段”。比如 disease 表里同时有 symptom_list、drug_list、check_list、department 这几个列,值用逗号或顿号分隔。这种表结构看起直观,但直接拿来建图是不行的,因为每个关系都要被拆成独立的一行。
拿到数据后我先做三件事。第一,统一列名,把“疾病名称”“name”这种混用列名统一成小写英文。第二,统一编码,所有文件转成 UTF-8,避免后续灌库乱码。第三,清洗值里的杂质,最常见的是“胸闷(活动后加重)”这种括号注释,会把括号里的内容也当成实体名;还有空值和“暂无”这类占位值,都需要处理成统一规则。
2.3 清洗与转换脚本:把结构化数据拍平成三元组
这里的核心任务是把一对多的关系字段拆成“头实体—关系—尾实体”三元组。以下脚本以 disease_raw.csv 为输入,输出疾病节点列表和关系列表:
import pandas as pd df = pd.read_csv("data/disease_raw.csv", encoding="utf-8") df.columns = [c.strip().lower() for c in df.columns] df = df.fillna("未知") # 去掉括号注释,统一分隔符 df["symptom_list"] = df["symptom_list"].str.replace(r"(.*?)", "", regex=True) df["symptom_list"] = df["symptom_list"].str.split(r"[,,、;;]") diseases = [] relations = [] for _, r in df.iterrows(): diseases.append({"id": f"D{r['id']}", "name": r["name"], "desc": r.get("desc", "未知")}) for s in r["symptom_list"]: s = s.strip() if s and s != "未知": relations.append(["D" + str(r["id"]), "HAS_SYMPTOM", s])逻辑说明:先把列名全部转小写,避免后面写代码时对大小写敏感;再统一分隔符,中文逗号、顿号、英文逗号、分号一次清洗干净。最后把一对多的字段拍平成三元组关系行,这是后续建图最需要的“半成品”格式。参数方面,encoding 统一指定 utf-8,replace 里开了 regex=True 才能处理正则表达式。
需要注意,实体 id 用 D1、D2 这种纯数字前缀,而不是直接用中文名,好处是后续在 Neo4j 里做节点匹配时不容易出现编码或空格问题。中文名只作为 name 属性存进节点。
2.4 别名表与实体对齐:系统能不能听懂人话就看这一步
这一步是几乎所有问答系统翻车的重灾区。图谱里存的实体叫“胸痛”,患者可能说“胸口疼”;图谱里科室叫“心血管内科”,医生习惯写“心内科”。如果不在查询前做实体对齐,用户问题里的词和图谱里的节点永远对不上。
我用的方案是维护一张别名映射表,查询前先做字符串替换:
alias_map = { "心内科": "心血管内科", "心脏内科": "心血管内科", "胸口疼": "胸痛", "心口痛": "胸痛", "耐绞宁": "硝酸甘油", } def normalize_alias(text): for k, v in alias_map.items(): text = text.replace(k, v) return text逻辑说明:归一化要在所有后续处理之前执行,保证进到实体匹配环节的文本已经用的是图谱里的标准名。参数说明:这里用最简单的字符串替换就够,几十到几百条别名时完全可维护。不要一上来就上模糊匹配工具,别名表的好处是每一条替换都可解释、可排查,出问题一眼能看出来。
提示:用药和症状相关的知识来自公开医学资料的结构化整理,仅用于项目学习演示,不能替代医生处方。这句话建议写进你的项目 README 里。
3. 把数据灌进 Neo4j:py2neo 批量写入、唯一约束与图谱验证
3.1 为什么选 Neo4j:别用邻接矩阵硬扛问答系统
学 Python 时很多人会接触到用邻接矩阵表示图,但那是给几十个节点的演示用的。问答系统一旦到了几百上千实体、需要做多跳查询时,用邻接矩阵手写遍历路径就很吃力了。MySQL 也能做关联查询,但多跳关系要反复 JOIN,写起来繁琐,灵活度也不够。
Neo4j 的优势是 Cypher 查询语言专门为关系遍历设计,图形化 Browser 还能直接看到图谱长什么样,调试时比对着表格直观得多。下面是常见选型对比:
| 方案 | 适合规模 | 查询方式 | 部署成本 |
|---|---|---|---|
| MySQL 多表 JOIN | 关系深度浅 | SQL | 低 |
| Python 邻接矩阵 | 几十节点的演示 | Python 遍历 | 最低 |
| Neo4j | 千级以上、多跳问答 | Cypher 图查询 | 中 |
RDF 三元组库也是备选,但对问答场景来说,Neo4j 的成熟度和可视化调试体验更合适。先把环境装好,Python 版本 3.8 以上就完全够用,Neo4j 用社区版即可。
3.2 py2neo 灌库脚本:把三元组装成图谱
Neo4j 部署完成后,用 py2neo 连接数据库并写入节点和关系。这是一个最小可跑的灌库脚本:
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "你的密码")) # 清空旧数据,避免重复灌库 graph.run("MATCH (n) DETACH DELETE n") # 创建疾病节点 for row in diseases: node = Node("Disease", name=row["name"], desc=row["desc"]) graph.create(node) # 先按 name 索引节点,再创建关系 graph.run("CREATE INDEX IF NOT EXISTS FOR (d:Disease) ON (d.name)") for rel in relations: head = graph.nodes.match("Disease", name="D" + rel[0]).first() tail = graph.nodes.match("Symptom", name=rel[2]).first() if head and tail: graph.create(Relationship(head, rel[1], tail))逻辑说明:第一步 DETACH DELETE 清空所有节点和关系,保证反复调试时图谱是干净的;第二步创建疾病节点;第三步给 Disease.name 建索引,这样后面按名字查节点时不会全表扫描。参数说明:auth 元组第一个元素是用户名,第二个是密码,不能用默认密码 neo4j/neo4j 直接连,Neo4j 首次启动会要求你设置新密码。
这里有个关键建议:py2neo 的版本坑非常多,很多人的连接问题都出在版本上。py2neo v5 的接口变化较大,我一般固定用 py2neo==2021.2.3 来配合 Neo4j 4.x/5.x,并且统一用 graph.run() 传 Cypher 字符串的方式执行,而不是依赖 NodeMatch 等高级 API。这样即使版本有差异,核心逻辑也不容易坏。
3.3 唯一约束:二次灌库不翻车的后悔药
第一次灌库跑通后,第二次跑脚本最容易出现的问题是节点重复。如果灌库脚本用了 CREATE 而不是 MERGE,每跑一次就会生成一批一模一样的节点,高血压在图谱里变成三个,问答查询结果重复。
解决办法是给节点加唯一约束。Neo4j 4.x 以上用:
CREATE CONSTRAINT FOR (d:Disease) REQUIRE d.name IS UNIQUE如果用的是旧版 Neo4j,则写成:
CREATE CONSTRAINT ON (d:Disease) ASSERT d.name IS UNIQUE加约束之后再配合 MERGE 写入,数据库会先按 name 查找已有节点,存在则复用,不存在才创建。这样无论脚本跑多少遍,图谱里的每个实体都只有一份。这个操作建议在灌库脚本开头就做,而不是等重复数据出现后再清理。
3.4 灌库后验证:五条必跑的 Cypher 查询
灌库成功不等于数据没问题。我用一组 Cypher 查询来验收图谱质量,每条都有具体目的:
// 查看各类实体数量分布 MATCH (n) RETURN labels(n), count(*) // 查看高血压一跳以内的所有邻居 MATCH (d:Disease {name: "高血压"})-->(s) RETURN s第一条能快速判断是否某个实体类型完全没建上,比如科室数量为 0 说明清洗时 dept 字段没有被拆进去。第二条抽查单节点的关系情况,如果某个疾病的邻居只有症状没有药物,说明原始数据里该字段为空。还需要跑一条核心业务查询:
// 冠心病有哪些推荐药物 MATCH (d:Disease {name: "冠心病"})-[:RECOMMEND_DRUG]->(dr:Drug) RETURN dr.name最终验证标准有三个:实体类型数量不为 0、每个疾病节点至少有一条症状关系、孤立节点数量接近 0。孤立节点多说明关系拆分行在清洗阶段丢了,需要回到第 2 章的拆分逻辑排查。
4. 避坑指南:灌库、编码、版本和实体匹配的五个血泪记录
4.1 二次灌库图谱翻倍:CREATE 用太狠
现象:灌库脚本跑了两次,Browser 里“高血压”出现了两个节点,问答结果变成重复列表。原因是脚本里用的 CREATE 无条件创建新节点,完全不会检查库里是否已存在同名实体。解决方法是把节点和关系的创建语句全部改成 MERGE,并在 name 属性上加唯一约束。MERGE 会先按 name 做查找,找到就复用,找不到才创建。注意只给 CREATE 换 MERGE 还不够,必须配合唯一约束,否则数据量大时 MERGE 的性能会很差。
4.2 py2neo 版本玄学:本地连不上 Neo4j
现象:pip install py2neo 装完最新版,执行 Graph("bolt://localhost:7687", auth=("neo4j", "密码")) 时报认证失败或者握手错误,而且错误信息看着像网络问题。原因多半是 py2neo v5 与当前 Neo4j 版本的驱动协议不兼容,或者密码还是初始状态没改。解决方法是把 py2neo 固定到 2021.2.3 版本,同时确认 Neo4j 的用户名是 neo4j、密码是你在安装时自行设置的那个。执行 Cypher 时建议统一走 graph.run() 字符串方式,不要用不同版本里差异很大的 NodeMatch 写法。
4.3 中文全变问号:编码问题从 CSV 一直带到图谱
现象:用 pandas 清洗后写入 Neo4j,Browser 里节点显示全是问号,或者执行 graph.run() 时抛 UnicodeEncodeError。原因绝大多数是文件读取时没指定编码,Windows 下 Excel 另存的 CSV 默认是 GBK,而 Neo4j 和 py2neo 都按 UTF-8 处理。解决方法是所有读取和写入都显式加 encoding="utf-8":pd.read_csv 的 encoding 参数、open() 时的 encoding 参数都要写。如果数据是从 Excel 导出的,另存为 CSV 时选择“CSV UTF-8”格式,而不是默认的“CSV”。
4.4 用户问题里的实体匹配不上:分词把实体切碎了
现象:用户问“高血压吃什么药比较好”,程序返回“没有找到实体”,但图谱里明明有高血压节点。原因是 jieba 默认词典没有收录“高血压”,把句子切成了“高血压吃”“什么药”,实体被拦腰切断。解决方法是给 jieba 加载自定义词典,把疾病、症状、药物、检查、科室的名称全部放进 user_dict.txt:
import jieba jieba.load_userdict("data/user_dict.txt")同时做一层兜底:不依赖分词结果,直接遍历图谱所有实体名,判断哪个实体名出现在问句字符串里。这一层“包含匹配”虽然笨,但在短问题上准确率非常高,是问答系统里最实用的兜底策略。
4.5 心内科还是心血管内科:别名表不全导致答案空白
现象:用户问“心口疼挂什么科”,系统回答空白,但图谱里确实有“胸痛”症状和“心血管内科”科室。原因就是别名缺失,问句里“心口疼”和图谱里“胸痛”对不上,实体识别环节直接失败。解决方法是完善 alias_map,把用户常见说法逐一映射到标准名。这里有个技巧:做完一轮问答测试后,把所有回答失败的句子收集起来,看到底是哪个别名没覆盖,补进别名表再跑一遍。实体对齐的质量应该优先于图谱规模,别急着加新实体,先把已有实体的别名做透。
5. 跑通问答并验收:五类高频问句的模板与验证方法
5.1 问答主流程一句话:归一化、识别实体、套模板、查图谱
整个问答系统收敛成一条流水线:别名归一,让“心口疼”变成“胸痛”;实体识别,找出问句里涉及的疾病或症状;意图判定,判断用户问的是症状、药物、科室还是检查;最后组装 Cypher 查询图谱并生成答案。意图模板决定了系统的“可回答边界”,模板越多,能接住的问法越多。
| 问法示例 | 意图 | 组装出的 Cypher |
|---|---|---|
| 高血压有什么症状 | 查症状 | MATCH (d:Disease {name})−[:HAS_SYMPTOM]->(s) RETURN s |
| 胸痛可能是什么病 | 查疾病 | MATCH (s:Symptom {name})<-[:HAS_SYMPTOM]-(d) RETURN d |
| 高血压吃什么药 | 查药物 | MATCH (d:Disease {name})−[:RECOMMEND_DRUG]->(dr) RETURN dr |
| 高血压挂什么科 | 查科室 | MATCH (d:Disease {name})−[:DEPT_OF]->(dept) RETURN dept |
| 高血压做什么检查 | 查检查 | MATCH (d:Disease {name})−[:NEED_CHECK]->(c) RETURN c |
5.2 核心问答函数与五类例句验收
def answer(question): q = normalize_alias(question) # 1. 别名归一 entity = next((e for e in entity_names if e in q), None) if not entity: return "这个问题我还没覆盖,建议先去医院确认" for intent, cql in TEMPLATES.items(): if any(k in q for k in intent_keywords[intent]): rows = graph.run(cql, name=entity).data() names = [r["name"] for r in rows] return "、".join(names) if names else "知识库里没有查到" return "换个问法再试试"参数说明:entity_names 来自所有图谱实体名列表,程序启动时构建一次并缓存;intent_keywords 是每类意图的问法词表,例如 symptom 对应“症状、表现、反应”。验收时跑五条代表句:高血压有什么症状、胸痛可能是什么病、高血压吃什么药、高血压挂什么科、高血压需要做什么检查。每条都要返回非空答案,重复执行两次结果一致。
我第一次做这个项目时把重心全放在扩大图谱规模上,直到跑验收才发现“心口疼挂什么科”答不出来,问题根本不在图谱,而在别名表。先把别名补全再做图谱扩展,这个顺序能省掉一大半调试时间。希望帮到你。
本文还有配套的精品资源,点击获取