news 2026/10/11 21:06:14

基于Neo4j的知识图谱医疗问答系统:从实体识别到工程落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Neo4j的知识图谱医疗问答系统:从实体识别到工程落地

简介:面向计算机、人工智能、自动化等相关专业学生的Python毕业设计源码包,基于知识图谱实现医疗症状、疾病、药物等实体关系问答,适用于课程设计、大作业或毕业设计。项目为高分毕设,答辩评审98分,代码已调试可运行,适合从知识图谱入门到工程复现的不同阶段学习者二次开发。压缩包共188个文件,约23.61MB,含71个py源码、8个md文档、7个csv数据文件,以及png流程图、json配置、h5模型、pkl序列化文件等,目录按数据处理、意图识别、命名实体识别、问答检索等模块组织,另有bat/sh启动脚本便于运行。目前已有165人学习,可作为医疗知识图谱与智能问答开发的完整参考,覆盖数据清洗、实体关系构建、模型训练到问答接口流程;说明文档与源码配合,便于对照调试与功能扩展。

1. 基于知识图谱的医疗问答系统:把“实体关系检索”做成可落地项目

当用户输入“高血压应该挂什么科”,全文检索会返回一堆页面上含这两个词的推荐内容,但基于知识图谱的医疗问答系统会先把问句拆成实体“高血压”和意图“科室”,然后沿着图谱里“高血压 → belongs_to → 心血管内科”这层关系直接取出答案。这份毕设源码把这条链路完整走了一遍:从医疗百科数据清洗、Neo4j 图谱构建、基于规则模板的问答引擎,到用 Flask 暴露 HTTP 接口,每一段都有对应代码和说明文档。

它适合两类人:一是准备毕业设计的学生,需要一套能现场演示、流程完整、答辩能讲清楚的项目;二是在研究知识图谱落地的开发者,想看看医疗领域如何把实体、关系、查询路径组织成一个闭环。注意这套系统本质上不是大模型套壳,而是更接近“实体识别 + 意图分类 + 图数据库检索”的工程实现,所以运行稳定、可控性强,也容易二次扩展。

2. 知识图谱构建:从医疗百科采集到 Neo4j 图数据库

2.1 医疗数据从哪来:爬虫采集与三元组格式清洗

知识图谱的核心不是数据库,而是结构化的语义关系。医疗问答需要的数据可以拆成实体和关系两类:实体包括疾病、症状、科室、药物、检查项目;关系包括“表现为哪些症状”“属于哪个科室”“用什么药治疗”“需要做什么检查”。这些数据通常来自医学百科的疾病词条、公开医学知识库,通过爬虫抓取后清洗成三元组,再写入图数据库。

源码里已经给了一套清洗后的数据,字段格式如下:

文件字段示例
disease.csvname, symptoms, department, drugs, checks高血压, 头晕/头痛, 心血管内科, 硝苯地平, 血压测量
symptom.csvname, related_disease头晕, 高血压/贫血/颈椎病
department.csvname, disease_list心血管内科, 高血压/冠心病/心绞痛
drug.csvname, target_disease硝苯地平, 高血压

我一般会在导入 Neo4j 之前,用 pandas 先做一遍数据体检,重点处理三件事:空值过滤、实体名去重、同义词归并。以下是一个典型的清洗脚本:

import pandas as pd df = pd.read_csv("disease.csv", encoding="utf-8") # 丢弃疾病名为空的记录 df = df.dropna(subset=["name"]) # 按疾病名去重,保留第一条 df = df.drop_duplicates(subset=["name"], keep="first") # 统一科室别名:把“心血管内科”、“心内科”归并为标准名 alias_map = {"心内科": "心血管内科", "心内": "心血管内科"} df["department"] = df["department"].replace(alias_map) # 检查关键字段是否有空值 assert df["department"].notna().all(), "department 存在空值" assert df["drugs"].notna().all(), "drugs 存在空值" df.to_csv("disease_clean.csv", index=False, encoding="utf-8-sig")

这段脚本里,dropna(subset=["name"])确保实体节点有唯一标识;drop_duplicates防止同一个疾病被重复建节点;replace(alias_map)把别名统一成标准名,这一步直接影响后续问答命中率。清洗完成后输出为utf-8-sig编码的 CSV,避免 Excel 打开时中文乱码。

2.2 Neo4j 图模型设计:节点、关系与索引

Neo4j 的图模型设计决定了问答层的查询复杂度。这套项目采用了五类节点和四类关系,设计上非常贴近“患者咨询”的真实路径:

  • 节点类型:Disease、Symptom、Department、Drug、Check
  • 关系类型:has_symptom(疾病→症状)、belongs_to(疾病→科室)、treat_by(疾病→药物)、need_check(疾病→检查)

为什么选 Neo4j 而不是 MySQL?最直接的原因是“多跳查询”的需求。用户问“高血压有什么症状”是一跳查询,问“高血压应该去哪个科室、这个科室还能治什么病”就是两跳甚至三跳查询。用 SQL 表达两跳查询需要多次 JOIN,而图数据库里用 Cypher 一条路径就能完成。

建索引和创建关系的 Cypher 如下:

// 创建唯一约束,防止重复实体 CREATE CONSTRAINT disease_name FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT symptom_name FOR (s:Symptom) REQUIRE s.name IS UNIQUE; // 创建示例节点与关系 MERGE (d:Disease {name: "高血压"}) MERGE (s:Symptom {name: "头晕"}) MERGE (dep:Department {name: "心血管内科"}) MERGE (drug:Drug {name: "硝苯地平"}) MERGE (d)-[:has_symptom {weight: 0.9}]->(s) MERGE (d)-[:belongs_to]->(dep) MERGE (d)-[:treat_by {priority: 1}]->(drug)

这里建约束的意义是保证同名实体不会被重复创建;MERGE比CREATE更安全,因为它先匹配再创建,重复执行不会产生冗余节点。关系上带weight、priority属性,是为后续排序做准备的——比如某种症状对疾病越典型,weight越高,答案排序时越靠前。

2.3 批量导入:数据从 CSV 到图数据库

小数据量可以手工写 MERGE 语句,但该项目的疾病数据有近千条,必须用脚本来批量导。常见做法是使用 py2neo 库连接 Neo4j,逐条读取清洗后的 CSV,用 MERGE 语义写节点和关系。

from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) tx = graph.begin() # 开启事务 df = pd.read_csv("disease_clean.csv") for _, row in df.iterrows(): disease = Node("Disease", name=row["name"]) tx.merge(disease, "Disease", "name") # 处理关系:症状 for sym in str(row["symptoms"]).split("/"): symptom_node = Node("Symptom", name=sym.strip()) tx.merge(symptom_node, "Symptom", "name") rel = Relationship(disease, "has_symptom", symptom_node, weight=0.8) tx.merge(rel) # 处理关系:科室 dep_node = Node("Department", name=row["department"]) tx.merge(dep_node, "Department", "name") tx.merge(Relationship(disease, "belongs_to", dep_node)) # 处理关系:药物 for drug_name in str(row["drugs"]).split("/"): drug_node = Node("Drug", name=drug_name.strip()) tx.merge(drug_node, "Drug", "name") tx.merge(Relationship(disease, "treat_by", drug_node)) tx.commit() # 统一提交

这段代码里,tx.begin()和tx.commit()是关键:事务分批提交能避免几千条数据一次性写入导致的内存压力。每个节点先merge再创建关系,保证关系不会指向不存在的节点——如果直接使用tx.create()创建关系,遇到数据里引用了一个空的科室名,就会产生一条“悬挂关系”,问答检索时会查出空节点。实际项目里我会每 200 条commit一次,虽然慢一点,但不会把 Neo4j 进程撑爆。

3. 问答核心流程:实体识别、意图匹配与答案组装

3.1 中文分词与医疗实体识别

实体识别是整个问答系统的地基。医疗文本和通用文本的差别非常大,最典型的例子是“高血压”如果被 jieba 默认词典拆成“高血”和“压”,后续图查询根本匹配不到Disease节点。而且反向也有问题,“盐酸”是一种药物,也是化学名词,靠通用模型很难区分。

该项目采用的方式是自建医疗词典:把图数据库里已有的疾病名、症状名、科室名、药物名全部拉出来,拼成 jieba 的用户词典,再配合自定义分词逻辑做最长匹配。这是知识图谱问答里最常用、也最可控的做法。

import jieba from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def load_entities_from_graph(): entities = set() for label in ["Disease", "Symptom", "Department", "Drug", "Check"]: nodes = graph.run(f"MATCH (n:{label}) RETURN n.name AS name").data() for node in nodes: entities.add(node["name"]) jieba.add_word(node["name"]) return entities MEDICAL_ENTITIES = load_entities_from_graph() def recognize_entities(question): words = list(jieba.cut(question)) matched = [] for word in words: if word in MEDICAL_ENTITIES: matched.append(word) # 再做一次最长匹配:如果分词结果里没有命中完整实体,则用逐字滑动窗口补查 for size in range(4, 1, -1): for i in range(len(question) - size + 1): cand = question[i:i+size] if cand in MEDICAL_ENTITIES and cand not in matched: matched.append(cand) return matched

分词后加一轮滑动窗口,是为了兜住那种“词典加了词,但句子里有前缀或后缀干扰”的情况。比如“得了高血压需要注意什么”,分词后可能命中“高血压”,滑动窗口则会命中更完整的实体,避免匹配到半个词。实体集合从图数据库动态加载,意味着你往图里加了新药物,词典会自动带上,不需要改代码。

3.2 基于模板的意图匹配:把问句映射到图谱路径

意图识别这个环节,很多人第一反应是用 BERT 或者 TextCNN,但在这套系统里完全不必要。知识图谱问答的问题类型是固定的,常见的就四类:科室咨询、症状查询、药物查询、病因咨询。用模板规则做,解析透明、调试方便,而且毕业设计答辩时容易讲清楚。

def detect_intent(question): intent = "general" if any(word in question for word in ["什么科", "挂什么号", "哪个科室"]): intent = "department" elif any(word in question for word in ["什么药", "吃什么", "怎么治疗", "用什么"]): intent = "drug" elif any(word in question for word in ["为什么", "原因", "诱因", "怎么回事"]): intent = "cause" elif any(word in question for word in ["什么症状", "会怎么样", "有哪些表现"]): intent = "symptom" return intent def answer_question(question): entities = recognize_entities(question) intent = detect_intent(question) if not entities: return "没识别到对应的医学实体,请换个说法,例如:高血压有哪些症状?" entity = entities[0] if intent == "department": result = graph.run( "MATCH (d:Disease {name:$name})-[:belongs_to]->(dep:Department) " "RETURN dep.name AS answer", name=entity ).data() return f"{entity}建议挂:{result[0]['answer']}" if result else f"暂时没查到{entity}的科室信息" if intent == "drug": result = graph.run( "MATCH (d:Disease {name:$name})-[:treat_by]->(drug:Drug) " "RETURN drug.name AS answer ORDER BY d.weight DESC LIMIT 3", name=entity ).data() drugs = "、".join([r["answer"] for r in result]) return f"{entity}常用药物:{drugs}" if drugs else f"暂时没查到{entity}的用药方案" if intent == "symptom": result = graph.run( "MATCH (d:Disease {name:$name})-[:has_symptom]->(s:Symptom) " "RETURN s.name AS answer LIMIT 5", name=entity ).data() symptoms = "、".join([r["answer"] for r in result]) return f"{entity}常见症状:{symptoms}" if symptoms else f"暂时没查到{entity}的症状信息" return "请再说详细一点,我会根据疾病为你查询科室、药物和症状。"

这段代码把问答流程封装成了一个函数:先识别实体,再判断意图,然后按意图组装对应的 Cypher 查询。每条查询都带参数$name,避免拼接字符串导致注入问题。ORDER BY对多个答案排序,返回前三条给用户,比一次性把几十个药物全抛出来体验好。答不上来时,回退语句把用户往已知的实体名称上引导,而不是给一个冷冰冰的空结果。

3.3 兜底逻辑:实体识别失败和空结果的降级回答

问答系统里最容易翻车的情况,是用户问了一个图谱里根本不存在的疾病。比如“新冠吃什么药”,如果数据是 2019 年之前爬的,图谱里肯定没有“新冠”这个实体,那么实体识别返回空列表,代码直接走入兜底分支。

除了空实体,还有一种情况是实体命中但关系缺失。典型例子是“高血压需要做哪些检查”,图里也许只有疾病到科室、药物的关系,没有建need_check,查询结果为空数组。我把这层降级答案也放在源码里:

ANSWER_FALLBACK = { "department": "目前知识库中还没有这个疾病的科室信息,试试换个疾病名称", "drug": "这个疾病的用药方案还没收录,建议咨询专业医生", } def safe_answer(question): try: return answer_question(question) except IndexError: return "没有找到对应路径,请把问题描述得更具体一些" except Exception: return "服务暂时不可用,请稍后重试"

这套兜底逻辑的价值不只是防止报错——它让你从日志里看到哪些问题答不上来,再去回填图谱数据。我一般会在上线前遍历测试集,把所有返回兜底语句的问题收集到unanswered.log,那里面每一条都是数据补全的线索。

4. 源码结构与环境部署:从 requirements.txt 到 Flask 接口

4.1 源码目录结构与核心模块分工

拿到源码包后,不要急着点运行,先把目录结构过一遍。这套项目分了四个模块,职责边界比较清楚:

模块/文件职责说明
spider/爬虫脚本,负责从医疗百科采集疾病词条
data/清洗后的 CSV 文件与图谱导入脚本
kg_builder/构建知识图谱的 py2neo 导入逻辑
qa_engine/实体识别、意图匹配、答案组装
server.pyFlask 接口层,把问答引擎暴露成 HTTP 服务
requirements.txt项目依赖清单
README.md运行说明、部署步骤、答辩演示建议

数据流转路径是:spider采集原始 HTML → 清洗成 CSV →kg_builder写入 Neo4j →qa_engine从 Neo4j 查询 →server.py返回 JSON。每个环节之间用文件解耦,比如爬虫停掉不影响图谱构建,图谱构建失败也不影响问答引擎开发——你完全可以用现成的 CSV 直接跳到导入步骤。

4.2 环境准备与启动步骤

运行这套项目需要准备 Python 3.8+、Neo4j 社区版、以及 JVM(Neo4j 依赖 Java 11 以上)。环境配置建议按下面的顺序来:

# 1. 安装依赖 pip install -r requirements.txt # 2. 启动 Neo4j(以本地解压版为例) ./neo4j_home/bin/neo4j start # 3. 确认 Neo4j 图形化界面可访问 # http://localhost:7474 # 4. 执行图谱构建脚本 python kg_builder/build_graph.py # 5. 启动问答接口服务 python server.py --port 5000

requirements.txt里核心依赖只有六个:flask、py2neo、pandas、jieba、requests、openpyxl。没有烦人的深度学习框架,安装时间可以控制在两分钟以内。如果本机没有 Neo4j,也可以改用 docker 方式:docker run -p 7474:7474 -p 7687:7687 neo4j:5.x,注意 5.x 版本默认强制修改初始密码,首次登录后要尽快改掉。

4.3 接口测试与返回结构

接口层用 Flask 实现,只有一个 POST 接口,请求和返回都是 JSON。我用requests写一个简单的联调脚本:

import requests response = requests.post( "http://localhost:5000/ask", json={"question": "高血压吃什么药?"} ) print(response.json()) # 期望输出: {"answer": "高血压常用药物:硝苯地平、厄贝沙坦、氯沙坦", "question": "高血压吃什么药?"}

接口内部逻辑很简单,就是调用qa_engine.answer_question,再套一层 HTTP 壳。源码里还包含了历史记录的存储,每一条问答都会被存到 SQLite 里,方便答辩时展示“这个系统被真实提问过”。我自己的习惯是部署后先用 curl 跑 20 个问题,把返回结果存成文本文件,作为功能测试的凭证:能导出这些记录,演示环节就不怕现场断网。

5. 避坑清单:Neo4j 导入、中文分词、同义词归一的三类问题

5.1 Neo4j 批量导入卡死与内存溢出

现象:执行build_graph.py导入数据时,Neo4j 进程 CPU 飙升,随后报OutOfMemoryError,甚至整个服务无响应。

原因:py2neo 默认使用事务提交,但如果整个循环共享一个tx对象并且不 commit,几千个节点和关系会堆积在内存里,最终压垮 JVM。另外,Neo4j 默认堆内存是 512MB,对上千节点就有点勉强。

解决:在neo4j.conf里把堆内存调大,同时脚本里每 200 条执行一次tx.commit(),不要攒到最后一次性提交。配置修改如下:

dbms.memory.heap.initial_size=2g dbms.memory.heap.max_size=2g dbms.memory.pagecache.size=1g

这组配置并不激进,适用 8GB 内存的开发机。如果机器内存紧张,可以降到 1g + 512m,但要额外保证循环内每 100 条commit一次。从那以后我看到py2neo代码里出现无 commit 的长事务,都会条件反射地去看数据量。

5.2 实体识别总把“高血压”拆成“高血”和“压”

现象:用户问“高血压要注意什么”,jieba.cut结果输出['高血', '压', '要', '注意', '什么'],图谱查询返回空,兜底语句直接把问题堵死了。

原因:jieba 的默认词典是通用语料,医疗实体占比极低。虽然代码里调了jieba.add_word("高血压"),但如果 add_word 调用发生在实际分词之前,并且没有设置force=True,部分场景下自定义词仍会被强制拆分。

解决:实体加载时用jieba.add_word(name, freq=2000, force=True),同时把实体列表导出成本地词典文件:jieba.load_userdict("medical_dict.txt")一次加载全部实体。第二个加分项是在实体识别函数里,先对问句做一次“图谱实体优先匹配”,统一用字段名和疾病名直接构造匹配串,降低分词器对长句的影响。

5.3 图谱里有数据,问答却查不到的同义词问题

现象:图谱里明明有“脑梗塞”节点,用户手动搜“脑梗”也能在 Neo4j Browser 里看到,但问答接口返回“没查到”。

原因:这是典型的别名词未归并问题。“高血压”和“hypertension”、“脑梗塞”和“脑梗”、“冠心病”和“冠状动脉粥样硬化性心脏病”在医学里是同一实体,但 CSV 里只存了一个名称,用户侧提问用的是另一个名称,实体匹配自然失败。

解决:在数据清洗阶段维护一张同义词映射表,统一成标准名再入库。问答阶段,实体识别前先对用户输入做一遍同义替换:

SYNONYMS = { "脑梗": "脑梗塞", "高血压病": "高血压", "hypertension": "高血压", "冠心病": "冠状动脉粥样硬化性心脏病", } def normalize_question(question): for alias, standard in SYNONYMS.items(): if alias in question: question = question.replace(alias, standard) return question

注意替换顺序:先替换长词、再替换短词,否则“高血压病”会被先替换成“高血压病”,反而丢失信息。另外同义词表要和清洗阶段用同一份,我自己更倾向把它放在data/synonyms.csv里,改数据不改代码。

5.4 端口冲突、认证错误与中文乱码

现象:python server.py --port 5000启动报address already in use;py2neo 连接报auth failed;在 Windows 命令行打印问答结果中文乱码。

原因:Flask 默认端口 5000 很常用,本地其他程序容易占用;Neo4j 4.x 之后默认认证从neo4j/neo4j改成首次登录自定义密码;Windows 下控制台输出乱码则是因为 Python 输出的编码和终端不一致。

解决:端口通过环境变量注入:export APP_PORT=5001;密码修改后同步更新config.py里的NEO4J_AUTH;Windows 下在 Python 脚本头部加# -*- coding: utf-8 -*-,并在启动时执行chcp 65001切换终端编码。这三类问题看起来小,但答辩现场任何一个都能打断演示节奏,提前在测试环境跑一遍很值得。

6. 验证与优化:体检集测试和图谱可视化展示

6.1 用体检集给问答系统算一次准确率

源码能跑通只是起点,答辩时被问“准确率多少”是必答题。我给这套系统加了一个轻量评估脚本:手工整理 40 条测试问句,覆盖五类意图,每一条标注标准答案,跑一遍问答流程,按是否包含标准答案作为命中标准。

TEST_CASES = [ ("高血压应该挂什么科", "心血管内科"), ("脑梗吃什么药", "氯吡格雷"), ("冠心病有什么症状", "胸痛"), ] def evaluate(test_cases): total = len(test_cases) hit = 0 for question, expected in test_cases: answer = answer_question(question) if expected in answer: hit += 1 else: print(f"未命中: {question} -> {answer}") return hit / total

注意评估时用的是“答案包含关键实体”而不是“全字段相等”,因为自然语言生成的多候选答案只要把科室或药物说对就算命中,避免字符串匹配过严导致误判。我把这套测试集放在eval_cases.csv里,后续每次改规则模板或者加数据,都重新跑一遍,准确率从最初的 60% 提高到 82% 左右,其中大部分漏答来自同义词问题,归并之后明显改善。

6.2 用可视化让图谱在报告里“看得见”

知识图谱系统的答辩演示,Neo4j Browser 是最好的可视化工具。请在答辩前把下面这条查询缓存成一个文件,现场展示时直接在输入框里回车:

MATCH (d:Disease {name: "高血压"})-[rel]->(n) RETURN d, rel, n LIMIT 20

这会把“高血压”节点以及它的症状、科室、药物、检查全部展示成一张星型图,一眼就能看懂实体关系。更好的脚本是查一跳或两跳的路径,展示“高血压 → 心血管内科 → 能治疗的疾病”这种多跳联系,白板上的 ER 图和这个效果完全不同,评审会直观感受到图数据库的价值。

另外我建议把评估结果和可视化截图一起生成 PDF 放在说明文档的附录里,答辩演示时有一套数据支撑,比现场翻代码更有说服力。这个项目本身在线代码完整、文档可以支撑整篇论文的写作,多花一小时跑完这些验证,整套毕设材料就圆满了。从那以后我每次做知识图谱项目,都会强制走一遍数据体检四步:实体空值、重复、同义词归并、关系方向,确认四条全过再导入 Neo4j,宁可慢十分钟也不把脏数据导进去。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 21:02:34

eUICC:认识 eSIM芯片

引言 eSIM(embedded SIM,嵌入式 SIM)把传统 SIM 卡的“硬件载体”和“签约数据”分离开来——签约数据(Profile)可以远程下载、远程更换,无需物理换卡。一、eSIM 的物理载体:eUICC 安全芯片&…

作者头像 李华
网站建设 2026/10/11 21:00:45

从备份到可用库:DB2异机恢复完整流程与避坑指南

简介:数据库异机恢复是运维中的常见难题。这份操作文档以NetBackup备份环境为背景,系统梳理了数据库异机恢复的完整配置流程,面向负责数据库备份与恢复的运维人员,旨在解决跨主机恢复时备份链路不通、日志归档不完整等实际问题。资…

作者头像 李华
网站建设 2026/10/11 20:58:52

数智研发平台如何实现无限适配?新能源制造一体化实践

去年秋天,我去一家做电池箱体的制造企业聊数字化项目。车间里一台样机装到一半,工艺主管蹲在地上翻图纸,越翻越急:“这个孔位设计那边到底改没改?车间手里拿的还是上个月的版本。” 这个场景我印象很深。做了几年制造…

作者头像 李华
网站建设 2026/10/11 20:58:29

用不好Claude Code?避开这两个误区,掌握这套代理式编程工作流

如果你试过 Claude Code,第一反应是“这也太难用了吧,问它点东西只会给一堆正确的废话”,那我强烈建议你先把删除命令收回去。我在过去一段时间里见过太多人吐槽这个工具,但深入聊下来发现,绝大多数“不好用”的体验&a…

作者头像 李华