简介:一套基于Neo4j图数据库构建的简易医疗问答知识图谱项目,面向希望掌握知识图谱落地、医疗数据爬取与可视化查询的Python开发者和数据分析学习者。资源从ask120平台爬取真实问答数据,经过清洗与建模后导入Neo4j,实现疾病、症状、药物等实体及其关系的图结构存储。压缩包共含37个文件,以Python源码为主(13个py,辅以10个pyc、7个xml、3个html等),涵盖爬虫脚本(spider1.py/spider2.py)、Django配置、数据库操作模块及前端页面,整体体积仅78KB,轻量易部署。目前已有5457人学习下载。通过本项目可掌握知识图谱核心概念、Cypher查询语法及Python与Neo4j整合方法,并获取一套可运行的医疗问答图谱原型,适合作为课程设计或入门实践参考。 先说一个我自己的经历。早先给一个健康类应用做后端,数据层用的是常见的关系型数据库加全文索引。用户问“最近总是失眠,还带着头痛,该挂哪个科”,数据库返回的结果基本靠关键词硬撞,绕来绕去查不准。真正的转折点是我把医疗数据重构成图结构,用Neo4j落地了一个简易医疗问答知识图谱,才把“多跳查询”和“答案可解释”这两件事彻底理顺。这个项目不算大,但麻雀虽小五脏俱全,正好适合想入坑知识图谱、又不想一上来就啃复杂框架的开发者参考。
文章后面会完整走一遍:医学数据怎么建模、CSV怎么批量导入Neo4j、问答接口怎么写、Vue3前端怎么做关系图可视化、以及实测中遇到的那些坑。整体链路跑通之后,你会对知识图谱的技术栈和价值有一个非常直观的体感。
1. 医疗问答这类场景,关系型数据库为什么顶不住
1.1 医疗知识天生就是一张网
先看医疗领域的真实提问方式:用户不会只问“胃疼挂什么科”,更多时候问的是“最近老是反酸、烧心,可能是啥问题?该检查什么?” 这类问题在数据层面要串联多个节点——症状指向疾病,疾病关联科室、检查项目、药物和饮食注意。
用关系型数据库表达这张网也可以,无非是多建几张中间表,但业务查询一旦涉及“几跳”以上,SQL就会变得非常痛苦。比如“头痛患者常吃的药物里,有哪些又和腹泻症状相关”,在关系模型里可能需要四五个JOIN嵌套,写出来又长又难维护,查询效率也随数据量直线下降。
1.2 图数据库把“路径”变成一等公民
Neo4j这种图数据库不一样。它建模的方式是“节点-关系-属性”,用户脑子里的“这个症状可能是哪些病”“这个病通常开哪些药”在图里就是一条条真实存在的边。
对比一下同一条查询的两种写法。假设要查“胃疼相关疾病以及对应科室”,关系型SQL大致长这样:
SELECT d.name, dep.name FROM disease d JOIN disease_symptom ds ON ds.disease_id = d.id JOIN symptom s ON s.id = ds.symptom_id JOIN disease_department dd ON dd.disease_id = d.id JOIN department dep ON dep.id = dd.department_id WHERE s.name = '胃疼';在Neo4j里,用Cypher写是这样:
MATCH (s:Symptom {name:'胃疼'})<-[:HAS_SYMPTOM]-(d:Disease)-[:VISIT_DEPARTMENT]->(dep:Department) RETURN d.name, dep.name直观程度完全不同。而且图数据库的遍历天然就是沿着关系走,不需要靠索引一层层回表,查询路径越长优势越明显。
1.3 可解释性也是关键加分项
知识图谱问答还有一个隐形价值:返回结果时可以顺带把“推理路径”交给前端。系统告诉用户“根据你的症状,怀疑是胃炎,所以推荐去消化内科”,这比一个冷冰冰的结果列表可信得多。图结构里,这条路径就是一段真实的查询轨迹,想说服人非常容易。
这也是我选择Neo4j的核心理由:它不只是存储,更是把领域知识的结构直接映射成了数据结构。下面对比做一个简单总结:
| 维度 | 关系型数据库 | Neo4j知识图谱 |
|---|---|---|
| 多跳查询 | SQL JOIN嵌套,难写难维护 | Cypher沿边遍历,天然表达 |
| 路径可解释 | 需额外编码 | 查询路径即答案依据 |
| 模式演进 | 加字段要改表结构 | 加节点加关系即可 |
| 适合场景 | 事务强、结构化表单 | 关系密集、探索式分析 |
2. 医学本体建模:实体、关系、属性我如何设计
2.1 实体类型从真实问题反推
建模不能拍脑袋,我拿了一批真实的医疗咨询问题做分析,发现用户关心的实体就六类:疾病、症状、药物、科室、检查项目、饮食建议。
于是本体里定义了六类节点标签:
- Disease:疾病,比如“胃炎”“偏头痛”
- Symptom:症状,比如“胃疼”“头痛”
- Drug:药物,比如“奥美拉唑”“布洛芬”
- Department:科室,比如“消化内科”“神经内科”
- Check:检查项目,比如“胃镜”“脑CT”
- Food:饮食建议,比如“小米粥”“辛辣食物”
每类节点都保留最常用的属性,Disease节点我加了name、alias、description、treatment_principle四个字段。alias字段很重要,后面问答模块做归一化要依赖它。
2.2 关系设计围绕“患者怎么问”展开
关系类型是从用户真实提问模式里提炼的。设计关系时问自己一个问题:用户在问答里会需要什么样的边?
最终我定义了五类核心关系:
(Disease)-[:HAS_SYMPTOM]->(Symptom):疾病表现出哪些症状(Disease)-[:TREAT_WITH]->(Drug):疾病常用什么药(Disease)-[:VISIT_DEPARTMENT]->(Department):疾病就诊科室(Disease)-[:NEED_CHECK]->(Check):疾病需要做什么检查(Disease)-[:SHOULD_EAT]->(Food)和(Disease)-[:NOT_EAT]->(Food):饮食宜忌
这里有个容易踩的坑:不要把“疾病-症状”关系建成双向。真实问题是“我从症状反推疾病”,查询方向就是从Symptom出发反向找Disease。但知识图谱建模时仍保留Disease指向Symptom的方向,用反向匹配来查。这样设计的好处是语义统一,后续加“典型症状”和“罕见症状”等不同权重时不用改结构。
2.3 数据来源与清洗
数据我主要参考了公开的医学知识库和健康科普网站的公开信息,整理出约2000种常见疾病、5000多个症状词、3000种常用药物,三元组总量超过2万。这个规模对演示项目来说足够有说服力,也不会让导入过程太慢。
清洗阶段最耗时的是实体对齐。比如“头疼”和“头痛”、“拉肚子”和“腹泻”,必须映射到同一个标准节点。处理方式很朴素:建一张同义词表,写入每个标准实体的alias属性,导入时统一做归一化。
2.4 给数据补上“属性”维度
实体和关系搭好之后,属性是让图谱更“聪明”的细节。我在关系上加了weight字段,比如“胃疼”是“胃炎”的典型症状,权重就设为0.9;“食欲不振”这类非特异症状,权重设为0.5。后面做问答排序时,这个字段能帮上大忙。
属性设计的原则是“按查询需求反推”:问答系统需要什么,就存什么。盲目堆属性会让数据维护成本变高。
3. 数据准备与Neo4j导入:从CSV到知识图谱的完整链路
3.1 环境选择:Community版够用
Neo4j有社区版和企业版之分,本地开发我选的是 Neo4j Community 4.4.x,配JDK 11。Community版没有集群能力,但单机跑十几万节点毫无压力,对个人项目和教学场景非常够用。
安装建议直接用Desktop版本,Windows下体验最省心。装好之后创建数据库,注意设置好初始密码,后面Java/Python连接都要用。如果不想装桌面版,也可以用Docker:
docker run --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/yourpassword -d neo4j:4.47474端口是浏览器管理界面,7687是Bolt协议连接端口,这两个端口后面都要用到。
3.2 CSV文件放对位置是第一个坑
Neo4j导入CSV有个路径约束:LOAD CSV默认只能读取import目录下的文件。Community版安装后的默认导入目录是neo4j安装目录/import,你可以把整理好的csv文件统一丢进去。
比如我的目录结构是这样的:
import/ disease.csv symptom.csv disease_symptom.csv disease_drug.csv disease_department.csvCSV的格式我统一用UTF-8编码,第一行是列名。这里特别强调一个细节:在Windows下用记事本保存CSV容易带BOM头,导入后第一列会出现一个看不见的“\ufeff”前缀,导致节点名全部匹配不上。我的做法是用VS Code另存为“UTF-8”格式,绕开这个坑。
3.3 LOAD CSV批量导入实测
导入节点和关系,核心两兄弟是LOAD CSV和MERGE。MERGE和CREATE的区别很关键:MERGE会先查节点是否存在,存在就跳过,不存在才创建,保证幂等。重复执行导入脚本不会生成重复节点。
导入疾病节点的常用写法:
LOAD CSV WITH HEADERS FROM 'file:///disease.csv' AS line MERGE (d:Disease {name: trim(line.name)}) ON CREATE SET d.alias = line.alias, d.description = line.description, d.treatment_principle = line.treatment_principle;导入关系时,先MERGE两端的节点,再MERGE关系,避免重复生成:
LOAD CSV WITH HEADERS FROM 'file:///disease_symptom.csv' AS line MATCH (d:Disease {name: trim(line.disease)}) MATCH (s:Symptom {name: trim(line.symptom)}) MERGE (d)-[:HAS_SYMPTOM {weight: toFloat(line.weight)}]->(s);这里有两个性能细节值得分享。第一,洗好的CSV建议几百KB一批,太大时在LOAD CSV前加USING PERIODIC COMMIT 500,分批提交事务,否则内存很容易被打满。第二,导入前必须给实体建唯一约束,否则重复执行脚本会造出大量重复节点,后面查啥都不对:
CREATE CONSTRAINT disease_name_unique ON (d:Disease) ASSERT d.name IS UNIQUE; CREATE CONSTRAINT symptom_name_unique ON (s:Symptom) ASSERT s.name IS UNIQUE; CREATE CONSTRAINT drug_name_unique ON (d:Drug) ASSERT d.name IS UNIQUE; CREATE CONSTRAINT department_name_unique ON (dep:Department) ASSERT dep.name IS UNIQUE;3.4 导入后的验证方法
导入完成后别急着写接口,先在Neo4j Browser里跑几条验证查询。比如检查节点总数、随机抓一条完整路径:
MATCH (s:Symptom {name:'胃疼'})<-[:HAS_SYMPTOM]-(d:Disease)-[r]->(n) RETURN s.name, d.name, type(r), n.name LIMIT 20;如果返回的结果里能清晰看到“胃疼-胃炎-消化内科”这样的链路,说明数据导入链路已经没问题,可以进入问答引擎开发了。
4. 核心问答引擎:从患者口语到Cypher查询的转换逻辑
4.1 整体流程拆解
问答引擎的职责是:用户输入一句自然语言问题,系统输出一句答案。整体流程我拆成了五步:分词、实体链接、意图识别、Cypher生成、结果格式化。
用户问题 -> 分词 + 实体识别 -> 意图识别 -> 模板匹配生成Cypher -> 执行查询 -> 格式化答案这个方案没有用复杂的深度学习模型,核心是词典和规则模板的组合。做医疗问答这种垂直领域,规则模板在数据量可控、问题模式有限的情况下,效果和可维护性都很不错。
4.2 实体链接:自定义词典是关键
文本分词我用的是HanLP的Python接口,配上自定义的医疗词典。词典内容就是把上一章整理的5000多个症状词、3000多个药物词全部导成txt,分词时优先匹配。
实体链接是问答系统最重要的一环,因为用户说的是口语,库里存的是标准术语。比如“发烧”要能映射到“发热”,“拉肚子”要能映射到“腹泻”。我的做法是在实体识别阶段做一次归一化:分词得到词条后,去查同义词表,统一换算成标准实体名。
这一步直接在HanLP词库里多塞一些“黑话”也能解决,但更推荐维护独立同义词表,因为它不仅用于分词,后面做答案展示时也能给出更友好的文案。
4.3 意图模板设计
用户问题的句式是有限的,我按“意图槽位”的方式设计了十几个模板。每个模板包含:匹配正则、意图类型、以及需要抽取的实体类型。
举个例子,“XX应该挂什么科”和“XX需要看哪个科室”这两类问法,统一归为DEPARTMENT_QUERY意图:
patterns = { "DEPARTMENT_QUERY": [ r"(.*?)应该挂什么科", r"(.*?)需要看哪个科室", r"(.*?)挂哪个科", ], "TREAT_QUERY": [ r"(.*?)能吃什么药", r"(.*?)吃什么药好", r"(.*?)用什么药", ], "NOT_EAT_QUERY": [ r"(.*?)不能吃(.*)", r"(.*?)忌口(.*)", ] }正则里的(.*?)就是实体槽位,识别出来的文本再做实体链接,拿到标准实体名。
4.4 动态生成Cypher并执行
意图和实体都确定了,Cypher生成就变得非常机械。比如“胃疼应该挂什么科”,意图是DEPARTMENT_QUERY,实体是“胃疼”且属于Symptom,那查询模板就是:
MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:Symptom {name:'胃疼'}) MATCH (d)-[:VISIT_DEPARTMENT]->(dep:Department) RETURN d.name, dep.name, s.weight ORDER BY s.weight DESC LIMIT 3;在Python里拼接时,我写了一个简单的模板函数:
def generate_cypher(intent, entity_name, entity_type): if intent == "DEPARTMENT_QUERY": return f""" MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:{entity_type} {{name:'{entity_name}'}}) MATCH (d)-[:VISIT_DEPARTMENT]->(dep:Department) RETURN d.name AS disease, dep.name AS department ORDER BY d.name """ # 其他意图同理整个匹配过程我用FastAPI包了一层HTTP接口,前端传question文本进来,后端返回答案文本、涉及的实体名以及Cypher路径。路径单独返回是为了给可视化前端展示用的。
4.5 无答案兜底策略
问答系统一定会遇到答不上来的问题。我做了两层兜底:第一层是“模糊匹配”,如果精确匹配失败,就用CONTAINS去查包含该实体名的Disease节点,尽量给个候选;第二层是“引导话术”,返回“抱歉,当前知识库还未收录这个问题的答案,您可以试试换一种问法,比如‘胃疼挂什么科’”。
宁可给引导话术,也不能硬凑结果。硬凑的答案用户一眼就能识破,反而拉低整个系统印象分。
5. 图谱可视化:Vue3项目里用ECharts把问答结果画出来
5.1 为什么必须做可视化
很多入门项目做到问答接口就停了,用户体验其实很单薄。知识图谱的价值之一就是“看得见”——用户搜“胃疼”后,如果能看到以“胃疼”为中心、辐射出来的关联疾病和科室网络,会更容易理解这个答案的来由。
可视化我选了Vue3 + ECharts。ECharts的graph系列本身就是为关系图设计的,支持力导向布局、拖拽、点击联动,代码量比vis-network更少,且文档更友好。
5.2 后端把图谱数据转成JSON
后端不能直接把Cypher结果丢给前端,需要转成前端约定的JSON结构。核心就是两个数组:nodes和links。
{ "nodes": [ { "id": "Symptom_胃疼", "name": "胃疼", "category": "Symptom" }, { "id": "Disease_胃炎", "name": "胃炎", "category": "Disease" }, { "id": "Department_消化内科", "name": "消化内科", "category": "Department" } ], "links": [ { "source": "Symptom_胃疼", "target": "Disease_胃炎", "relation": "HAS_SYMPTOM" }, { "source": "Disease_胃炎", "target": "Department_消化内科", "relation": "VISIT_DEPARTMENT" } ] }节点ID一定要带类型前缀,比如Symptom_胃疼和Disease_胃疼如果同名也不会冲突。这个问题我在后面“同名歧义”里还会再具体讲。
5.3 Vue3里的ECharts关系图配置
Vue3里安装echarts之后,直接在组件里定义chart配置:
import * as echarts from 'echarts'; const chart = echarts.init(document.getElementById('graph')); chart.setOption({ tooltip: {}, legend: { data: ['Symptom', 'Disease', 'Department'] }, series: [{ type: 'graph', layout: 'force', roam: true, draggable: true, data: graphData.nodes, links: graphData.links, categories: categories, // 按节点类型分类,用于着色 force: { repulsion: 300, edgeLength: 120 }, label: { show: true, position: 'right' } }] });关键配置点有三个:layout: 'force'开启力导向布局,节点会自动散开;roam: true允许缩放和拖拽,数据多的时候必须有;categories让不同实体类型自动着色,用户一眼能分清症状、疾病和科室。
5.4 点击节点联动问答
可视化不只是看的,我还做了一个交互:点击图中的任意节点,会把它当成新的查询词再次触发问答接口,从而扩充图谱。这样等于给用户一个“探索入口”,从胃炎点进去,能继续看到胃炎的相关药物、检查项目,整个界面就变成了一棵可生长的知识树。
前端点击事件改造也不复杂,ECharts的click事件里取到params.data.name,重新调用后端问答接口,再把返回的nodes/links做合并去重,重新setOption。
6. 实测中的几个坑:同名歧义、口语化表达和查询性能
6.1 同名不同类的歧义
开发过程中遇到的第一个坑是“同名实体”问题。比如“感冒”既是一种疾病(Disease),也是一个症状描述词(用户会说“我感冒了”);再比如“胃痛”这个症状,可能对应胃炎、胃溃疡、十二指肠溃疡多种疾病。
我之前的实体链接代码看到“感冒”就直接定位到Disease节点,导致查“感冒挂什么科”这种问法匹配到的路径很不稳定。
解决方案分两步:第一步是在实体链接时保留“实体类型候选列表”,先不急着选死,等意图识别出来后结合上下文消歧;第二步是在Cypher模板里强制标注实体类型,比如s:Symptom {name:'胃痛'}、d:Disease {name:'胃炎'},让图数据库在带标签的集合里精确查找,避免类型串味。
6.2 患者口语和标准术语之间的gap
第二个坑是口语表达。真实用户不可能按百科词条提问,他们说的是“吃啥药好”“烧得厉害”“拉肚子快虚脱了”。如果只按标准术语建立词典,这些口语词全部会变成未登录词。
我的做法是维护纯手工扩展的“口语-标准术语”映射表,放到实体链接阶段做一次替换,再进Neo4j查询。比如“发烧”先换成“发热”,“拉肚子”先换成“腹泻”,“吃啥药”先换成“吃什么药”。
这个映射表要长期迭代。初期我只有一百来条,后来通过收集问答日志,每周补一批新出现的口语词,现在累计两三百条,准确率提升非常明显。
6.3 查询性能与结果量控制
图数据库虽然多跳查询有优势,但如果不加限制,一条Cypher可能把整张大图拉出来,前端渲染直接卡死。我的解决办法:所有查询模板末尾强制加LIMIT,常用的是LIMIT 3或LIMIT 10,视场景而定。
还要给Neo4j查询设置超时,防止某些极端遍历把数据库线程占满。我在Python客户端连接Neo4j时设置了连接超时:
from neo4j import GraphDatabase driver = GraphDatabase.driver( "bolt://localhost:7687", auth=("neo4j", "yourpassword"), connection_timeout=10 )另外,对name属性建唯一约束之后,等值查询性能已经足够快,实测2万条三元组数据量下,单条查询基本在几十毫秒内返回。如果数据量继续涨到几十万级,就开始考虑把“高频查询路径”的结果做一层Redis缓存。
6.4 一组常见的测试用例
调试完成后,我保留了一组冒烟测试用例,每次改动后都会跑一遍。分享几个典型的:
| 用户问题 | 期望答案 |
|---|---|
| 胃疼应该挂什么科 | 消化内科 |
| 胃炎能吃什么药 | 奥美拉唑、枸橼酸铋钾等 |
| 高血压不能吃什么 | 高盐食物、腌制食品等 |
| 孩子发烧咳嗽老不好,该看哪个科 | 儿科 / 呼吸内科 |
最后挑几个测试用例跑下来,整体链路已经非常顺畅。整个项目验证完之后,我最大的体会是:医疗问答知识图谱的难点不在Neo4j本身,而在实体链接和意图模板这些“脏活累活”上。图谱这把锤子很好用,但先把钉子、木板这些素材整理干净,锤子才有用武之地。
另外再分享一个我后面计划做的扩展方向:把时间维度加进去。很多疾病症状是动态变化的,比如“持续低热一周”和“高热三天”,对应的疾病范围完全不一样。这种时序语义在图上可以用带时间属性的关系来表达,算是图结构相对传统表结构又一个很有潜力的延伸。
本文还有配套的精品资源,点击获取