简介:本资源是一套基于neo4j图数据库构建的简易医疗问答知识图谱项目,面向希望入门知识图谱、图数据库应用及医疗信息检索的开发者与研究人员。项目数据从ask120医疗问答平台爬取,涵盖疾病、症状、治疗方法等实体及其关联关系,帮助读者理解从数据采集到图谱查询的完整流程。压缩包共37个文件,约78KB,以Python脚本为主,包含爬虫、数据处理与Django Web应用模块,辅以pyc编译文件、xml配置、html页面及少量js与图片资源,结构清晰便于按模块学习。目前已有5461人学习下载。通过该项目,读者可掌握数据清洗与标准化、节点与关系建模、Cypher查询语言使用,以及基于图谱的问答应用开发思路,适合作为知识图谱入门实践与课程设计的参考案例。
1. 从一次急诊分诊的对话说起:这个医疗问答知识图谱到底能干什么
设想一个场景:半夜发烧,你打开一个问答窗口输入“头痛、发热、脖子硬”,系统没有甩给你一堆百科链接,而是沿着“症状→可能疾病→推荐科室→检查项”这条链路,把最相关的几个疾病节点和对应科室按置信度排出来。这就是基于 Neo4j 的简易医疗问答知识图谱要解决的事——它不靠大模型硬答,而是把医疗领域里的实体和关系显式存进图数据库,用图查询把答案“算”出来。
这个项目适合两类人:一类是想入门知识图谱、又不想一上来就啃工业级框架的开发者,另一类是手里有医疗问答需求、想先跑通“实体抽取→图存储→问答检索”最小闭环的技术同学。它不追求覆盖全科,而是用一套清晰的结构把图谱问答的骨架搭出来,改起来不费劲。
2. 技术选型与整体架构:为什么是 Neo4j 而不是关系库
2.1 图数据库在这个场景里的不可替代性
医疗问答的核心是“多跳关系”。比如“高血压患者能不能吃某类感冒药”,要经过“高血压→禁忌药物类别→具体药物”两跳甚至三跳。用 MySQL 做这件事,你得写多层 JOIN,表一多查询就崩,而且每加一种关系就要改表结构。Neo4j 用节点和关系直接建模,查询语言 Cypher 描述路径非常自然,MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:Symptom)这种写法几乎就是人脑的推理方式。
常见做法是:实体存成节点,属性挂在节点上,实体间的关系存成带类型的边。这个项目里主要节点类型包括疾病、症状、药品、科室、检查项,关系类型包括“疾病-症状”“疾病-药品”“疾病-科室”“疾病-检查”。选 Neo4j 的另一个现实原因是它社区版免费、单机部署简单,对“简易”定位来说刚好够用。
2.2 分层架构拆解
整个项目我一般会拆成四层,从下往上依次是:
- 数据层:Neo4j 图数据库,负责持久化节点和关系。
- 构建层:负责把原始医疗数据(CSV、JSON 或爬取结果)清洗后导入图库,包含实体识别和关系抽取逻辑。
- 检索层:接收用户自然语言问题,做意图识别和实体链接,转成 Cypher 查询。
- 交互层:Web 或命令行问答入口,把查询结果组织成自然语言返回。
这个分层的好处是每层可以独立替换。比如你后面想把规则抽取换成模型抽取,只动构建层;想把命令行换成 Web,只动交互层。对于二次开发来说,这种边界清晰的结构比一坨脚本强太多。
2.3 目录结构长什么样
一个典型的工程目录大致是这样组织的:
medical-kg/ ├── data/ # 原始数据与清洗后数据 │ ├── raw/ # 原始 CSV/JSON │ └── processed/ # 清洗后的三元组文件 ├── src/ │ ├── build/ # 图谱构建 │ │ ├── extractor.py # 实体与关系抽取 │ │ └── importer.py # 导入 Neo4j │ ├── qa/ # 问答检索 │ │ ├── intent.py # 意图识别 │ │ ├── linker.py # 实体链接 │ │ └── query.py # Cypher 生成与执行 │ └── web/ # 交互层 │ └── app.py ├── config/ │ └── settings.py # 数据库连接、路径配置 ├── requirements.txt └── README.mddata/raw放原始素材,data/processed放抽取后的三元组,src/build负责把三元组灌进图库,src/qa负责问答链路。这个划分让“数据准备”和“问答逻辑”互不干扰,调试时能快速定位是哪一层出了问题。
3. 图谱构建实战:从原始数据到 Neo4j 节点关系
3.1 数据清洗与三元组抽取
原始医疗数据往往是一堆非结构化文本或半结构化表格。构建层要做的第一件事是把它们变成“头实体-关系-尾实体”的三元组。下面这段代码演示了从一条疾病描述里抽取症状关系的逻辑:
import re # 症状词典,实际项目中可从数据中统计生成 SYMPTOM_DICT = ["发热", "头痛", "咳嗽", "乏力", "恶心", "呕吐", "皮疹"] def extract_symptom_relations(text): """ 从疾病描述文本中抽取 (疾病, 症状) 关系对 :param text: 形如 "高血压:常见症状包括头痛、头晕、乏力" :return: [(disease, symptom), ...] """ relations = [] # 按冒号切出疾病名和描述部分 if ":" not in text: return relations disease, desc = text.split(":", 1) disease = disease.strip() # 在描述中匹配症状词典里的词 for symptom in SYMPTOM_DICT: if symptom in desc: relations.append((disease, symptom)) return relations # 测试 sample = "高血压:常见症状包括头痛、头晕、乏力" print(extract_symptom_relations(sample)) # 输出: [('高血压', '头痛'), ('高血压', '乏力')]这段代码的逻辑很直白:先按冒号把疾病名和描述分开,再拿症状词典去描述里做匹配。SYMPTOM_DICT是关键词表,实际项目里可以从标注数据里统计高频症状词来扩充。extract_symptom_relations返回的是二元组列表,每个二元组对应图里的一条边。注意这里用的是简单字符串匹配,优点是快、可解释,缺点是漏召回和误召回都有,后面在避坑章节会讲怎么缓解。
3.2 用 Cypher 批量导入节点和关系
抽出来的三元组要写进 Neo4j。最直接的方式是用MERGE语句,保证节点不重复创建。下面是一个导入函数:
from neo4j import GraphDatabase class KGImporter: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def close(self): self.driver.close() def import_relations(self, relations): """ 批量导入 (疾病, 症状) 关系 :param relations: [(disease, symptom), ...] """ with self.driver.session() as session: for disease, symptom in relations: session.run( """ MERGE (d:Disease {name: $disease}) MERGE (s:Symptom {name: $symptom}) MERGE (d)-[:HAS_SYMPTOM]->(s) """, disease=disease, symptom=symptom ) # 使用示例 importer = KGImporter("bolt://localhost:7687", "neo4j", "your_password") importer.import_relations([("高血压", "头痛"), ("高血压", "乏力")]) importer.close()MERGE是关键,它等价于“存在就匹配,不存在就创建”,避免同一个疾病被重复建成多个节点。session.run里的 Cypher 用了参数化查询,$disease和$symptom是占位符,这样能防止特殊字符导致的语法错误,也避免拼接字符串带来的注入风险。HAS_SYMPTOM是关系类型名,你可以按同样模式扩展HAS_DRUG、BELONGS_TO_DEPARTMENT等。
3.3 验证图谱是否建对
导入完别急着写问答,先用几条 Cypher 确认图结构正确:
// 查看某个疾病的所有症状 MATCH (d:Disease {name: '高血压'})-[:HAS_SYMPTOM]->(s:Symptom) RETURN d.name, s.name; // 统计节点和关系数量 MATCH (n) RETURN labels(n), count(n); MATCH ()-[r]->() RETURN type(r), count(r);第一条查高血压关联的症状,第二条统计各类节点和关系的数量。如果症状数量明显偏少,说明抽取阶段漏了;如果疾病节点数量异常多,可能是MERGE没生效或者疾病名没做归一化。这一步是构建层的“验收测试”,花两分钟跑一下能省后面大量排查时间。
4. 问答检索链路:把自然语言问题翻译成 Cypher
4.1 意图识别与实体链接
用户输入“高血压有哪些症状”,系统要先判断这是“查症状”意图,再从中识别出实体“高血压”。意图识别用规则或轻量分类器都行,实体链接则是把识别出的词映射到图里真实存在的节点名。下面是一个基于规则的实现:
# 意图模式表:关键词 -> 意图类型 INTENT_PATTERNS = { "症状": "query_symptom", "吃什么药": "query_drug", "挂什么科": "query_department", "做什么检查": "query_exam" } def recognize_intent(question): """识别用户问题意图""" for keyword, intent in INTENT_PATTERNS.items(): if keyword in question: return intent return "unknown" def link_entity(question, disease_list): """ 从问题中链接出疾病实体 :param disease_list: 图库中所有疾病名列表 """ for disease in disease_list: if disease in question: return disease return None # 测试 q = "高血压有哪些症状" print(recognize_intent(q)) # query_symptom print(link_entity(q, ["高血压", "糖尿病"])) # 高血压INTENT_PATTERNS是意图关键词到意图类型的映射,遍历匹配即可。link_entity用图库里的疾病名去问题里做子串匹配,命中就返回。这种做法的局限是疾病名必须完整出现在问题里,用户说“血压高”就匹配不上,实际项目里一般会加同义词表或别名映射来兜底。
4.2 按意图生成 Cypher 并执行
意图和实体都拿到后,就可以拼查询了。不同意图对应不同的 Cypher 模板:
QUERY_TEMPLATES = { "query_symptom": """ MATCH (d:Disease {name: $disease})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS answer """, "query_drug": """ MATCH (d:Disease {name: $disease})-[:HAS_DRUG]->(m:Drug) RETURN m.name AS answer """, "query_department": """ MATCH (d:Disease {name: $disease})-[:BELONGS_TO]->(dept:Department) RETURN dept.name AS answer """ } def answer_question(driver, question, disease_list): intent = recognize_intent(question) disease = link_entity(question, disease_list) if intent == "unknown" or not disease: return "抱歉,暂时无法理解这个问题" cypher = QUERY_TEMPLATES.get(intent) if not cypher: return "暂不支持该类型的问题" with driver.session() as session: result = session.run(cypher, disease=disease) answers = [record["answer"] for record in result] return "、".join(answers) if answers else "图谱中暂无相关数据"QUERY_TEMPLATES把意图映射到 Cypher 模板,模板里用$disease占位。answer_question串起意图识别、实体链接、查询执行三步,最后把结果列表拼成字符串返回。这个结构的好处是加新意图只需加一条模板和一个关键词,不用改主流程。参数driver是 Neo4j 连接对象,disease_list用于实体链接,实际项目里可以从图库动态拉取或缓存。
4.3 多跳查询的扩展写法
单跳查询只能回答直接关系,遇到“高血压患者不能吃什么药”这种需要经过禁忌关系的问题,就要写多跳:
// 查询与高血压有禁忌关系的药物 MATCH (d:Disease {name: '高血压'})-[:HAS_CONTRAINDICATION]->(m:Drug) RETURN m.name;如果禁忌关系不是直接挂在疾病上,而是通过药物类别中转,就写成(d)-[:HAS_CONTRAINDICATION]->(c:DrugCategory)<-[:BELONGS_TO]-(m:Drug)。多跳查询是图数据库的强项,但要注意路径别写太长,超过三跳后结果相关性会明显下降,实际项目里一般限制在两到三跳。
5. 避坑与常见问题排查:那些我踩过的坑
5.1 症状抽取漏召回严重
现象:导入后发现很多疾病只关联了一两个症状,明显偏少。原因:症状词典覆盖不全,且简单字符串匹配对“头晕目眩”“头部胀痛”这类变体无能为力。解决:扩充同义词表,把“头晕目眩”映射到“头晕”;或者引入分词工具先切词再匹配,召回率能提升不少。
5.2 实体名不统一导致节点重复
现象:图里出现“高血压”和“高血压病”两个节点,查询只命中一个。原因:原始数据里同一实体有多种写法,MERGE按 name 精确匹配,写法不同就建了新节点。解决:导入前做实体归一化,维护一张别名到标准名的映射表,导入时统一转成标准名。
5.3 Cypher 查询返回空但数据明明存在
现象:问答返回“暂无数据”,但手动查图库能看到关系。原因:多半是实体链接没匹配上,比如问题里是“血压高”,图里存的是“高血压”。解决:在实体链接阶段加别名匹配,或者用全文索引做模糊匹配。Neo4j 的全文索引可以用db.index.fulltext.createNodeIndex创建,查询时用CALL db.index.fulltext.queryNodes。
5.4 批量导入速度慢
现象:几万条关系导入要跑十几分钟。原因:每条关系单独开一个 session 执行,网络往返开销大。解决:用UNWIND把批量数据一次性传入,在一条 Cypher 里完成多条 MERGE:
UNWIND $relations AS rel MERGE (d:Disease {name: rel.disease}) MERGE (s:Symptom {name: rel.symptom}) MERGE (d)-[:HAS_SYMPTOM]->(s)把 relations 作为参数列表传入,一次执行搞定,速度能快一个数量级。
5.5 问答结果排序缺失
现象:一个问题返回十几个症状,用户不知道哪个更相关。原因:查询没有排序逻辑,返回顺序随机。解决:给关系加权重属性,比如HAS_SYMPTOM上挂frequency表示该症状在该疾病中的出现频率,查询时ORDER BY r.frequency DESC,把最常见的症状排前面。
6. 二次开发改造思路:从简易图谱到可用的问答系统
这个项目跑通之后,最值得动手改的方向有三个。第一个是抽取层升级:把规则匹配换成基于预训练模型的命名实体识别,用标注数据微调一个 NER 模型,召回率和准确率都会有明显提升。改造时保持extractor.py的输入输出接口不变,内部实现替换即可,这样上层导入逻辑完全不用动。
第二个是查询层加缓存和兜底。高频问题(比如“感冒症状”)的查询结果可以缓存到 Redis,减少图库压力;实体链接失败时,不要直接返回“无法理解”,而是用全文索引做一次模糊匹配,把最接近的节点名返回给用户确认。这个兜底逻辑我一般写在linker.py里,作为link_entity的 fallback 分支。
第三个是图谱补全。简易版只覆盖了疾病-症状-药品-科室这几类关系,实际用起来用户还会问“这个病怎么预防”“饮食要注意什么”。扩展方式是加新的关系类型和对应的抽取规则,比如从描述文本里抽“预防措施”和“饮食建议”,存成HAS_PREVENTION和HAS_DIET关系。每加一类关系,同步加一条 Cypher 模板和一个意图关键词,整个链路是线性扩展的。
验证改造效果时,我习惯准备一组标注好的问答对,跑一遍看命中率和准确率。比如 50 个问题里有多少能正确识别意图、多少能链接到正确实体、多少返回了合理答案。这三个指标分开看,能快速定位是意图识别的问题还是实体链接的问题。
提示:改造前先给现有图谱做一次快照备份,Neo4j 社区版可以用
neo4j-admin dump导出,改崩了能快速回滚。
从那以后我每次动图谱结构之前,都强制先跑一遍全量问答测试集留个基线,不然改完根本说不清是变好了还是变差了。希望这个简易图谱能帮你把知识图谱问答的骨架先搭起来,剩下的就是往里填肉的事。
本文还有配套的精品资源,点击获取