
简介一套基于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连接都要用。如果不想装桌面版也可以用Dockerdocker run --name neo4j -p 7474:7474 -p 7687:7687 -e NEO4J_AUTHneo4j/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_timeout10 )另外对name属性建唯一约束之后等值查询性能已经足够快实测2万条三元组数据量下单条查询基本在几十毫秒内返回。如果数据量继续涨到几十万级就开始考虑把“高频查询路径”的结果做一层Redis缓存。6.4 一组常见的测试用例调试完成后我保留了一组冒烟测试用例每次改动后都会跑一遍。分享几个典型的用户问题期望答案胃疼应该挂什么科消化内科胃炎能吃什么药奥美拉唑、枸橼酸铋钾等高血压不能吃什么高盐食物、腌制食品等孩子发烧咳嗽老不好该看哪个科儿科 / 呼吸内科最后挑几个测试用例跑下来整体链路已经非常顺畅。整个项目验证完之后我最大的体会是医疗问答知识图谱的难点不在Neo4j本身而在实体链接和意图模板这些“脏活累活”上。图谱这把锤子很好用但先把钉子、木板这些素材整理干净锤子才有用武之地。另外再分享一个我后面计划做的扩展方向把时间维度加进去。很多疾病症状是动态变化的比如“持续低热一周”和“高热三天”对应的疾病范围完全不一样。这种时序语义在图上可以用带时间属性的关系来表达算是图结构相对传统表结构又一个很有潜力的延伸。本文还有配套的精品资源点击获取