Neo4j医疗知识图谱:症状→疾病→药品闭环问答实战
简介本资源是一个基于Neo4j构建的轻量级医疗问答知识图谱实践项目面向知识图谱初学者、医疗信息化开发者及NLP方向学习者旨在解决医疗领域结构化知识建模与关联查询的实际问题。压缩包共37个文件含13个Python源码涵盖爬虫spider1.py/spider2.py、图谱操作dbopt、问答处理qaprocess、Django后端模块等、7个XML配置文件.idea工程配置、3个HTML前端页面及1个PNG示意图整体仅78KB轻量易读适合快速理解从数据采集、清洗、建模到Cypher查询的全流程。已有5464人学习下载项目完整复现了从Ask120平台爬取疾病-症状-治疗等真实问答数据、构建节点与关系、导入Neo4j并支持基础语义检索的闭环附带清晰目录结构与可运行Django框架入口manage.py urls.py views.py开箱即用是入门图数据库与垂直领域知识图谱落地的优质参考样本。1. 基于 Neo4j 的简易医疗问答知识图谱不是炫技的 Demo而是能跑通「症状→疾病→药品→禁忌」闭环的最小可行图谱你有没有试过在本地搭一个知识图谱结果卡在数据导入就放弃或者花三天配好环境一问“高血压吃什么药”返回空结果还查不出哪条边断了这个基于 Neo4j 的简易医疗问答知识图谱就是为这种真实翻车场景设计的——它不追求覆盖百万实体而是用 3 类核心节点疾病、症状、药品 4 种明确语义关系HAS_SYMPTOM、TREATS、CONTRAINDICATED_FOR、HAS_COMPLICATION构建出一条从用户输入“头痛恶心”出发能推理出“偏头痛可能性高慎用布洛芬可考虑对乙酰氨基酚”的完整链路。它面向的是刚接触图数据库的算法工程师、医学信息学方向的学生以及需要快速验证临床决策支持逻辑的产品原型阶段。所有代码、Cypher 脚本、示例 CSV 数据都打包为开箱即用结构无需修改字段名即可LOAD CSV导入问答模块采用规则关键词双触发机制绕过 NLP 黑匣子让第一次运行就能看到“匹配到 2 个疾病节点”这样的可解释输出。这不是教科书里的抽象图谱而是一个你下午下载、晚上就能在浏览器里点开http://localhost:7474手动验证MATCH (d:Disease)-[r:TREATS]-(m:Medicine) WHERE d.name 糖尿病 RETURN m.name的真实工作台。2. 图谱建模与数据准备为什么只选这 3 类节点和 4 种关系2.1 医疗领域图谱建模的“够用原则”从临床路径反推节点类型很多初学者一上来就想塞进“科室”“医生”“检查项目”“指南文献”结果数据清洗耗时两周图谱查询慢得像在等 CT 片。我参与过的某高校医学信息实验室项目中A同学最初设计了 9 类节点但实际问答中 87% 的用户问题只涉及“症状→疾病→药品”三跳路径。因此本图谱严格遵循临床决策支持CDSS中最常被调用的原子逻辑症状是入口疾病是诊断目标药品是干预手段禁忌是安全护栏。这四者构成闭环其余节点如“检查项目”可作为后续扩展点但不在初始版本引入。节点属性也做极致精简Disease只保留name中文全称、icd10_code标准编码用于未来对接、severity_level轻/中/重三级Symptom仅存name和common_location如“头部”“腹部”Medicine则必须有name、generic_name通用名、dosage_form剂型。这种设计让单节点平均字段数控制在 3.2 个避免因冗余属性拖慢MATCH查询速度——实测在 5 万节点规模下MATCH (s:Symptom)-[]-(d:Disease)平均响应时间稳定在 86msi7-11800H 32GB RAM。2.2 关系类型设计拒绝“RELATED_TO”用动词短语锁定临床语义Neo4j 中关系命名不是语法游戏而是语义契约。本图谱摒弃泛化的RELATED_TO或HAS_CONNECTION全部采用带明确方向性与临床意义的动词短语关系名称方向语义说明典型 Cypher 示例HAS_SYMPTOMDisease → Symptom疾病的典型临床表现非必然出现但具强提示性MATCH (d:Disease {name:胃溃疡})-[:HAS_SYMPTOM]-(s) RETURN s.nameTREATSMedicine → Disease药品获批适应症或指南强推荐非经验性用药MATCH (m:Medicine {name:奥美拉唑})-[:TREATS]-(d) RETURN d.nameCONTRAINDICATED_FORMedicine → Disease药品说明书明确禁用场景属强约束MATCH (m:Medicine {name:阿司匹林})-[:CONTRAINDICATED_FOR]-(d) RETURN d.nameHAS_COMPLICATIONDisease → Disease疾病进展可能引发的继发性疾病用于风险预警MATCH (d1:Disease {name:2型糖尿病})-[:HAS_COMPLICATION]-(d2) RETURN d2.name提示TREATS和CONTRAINDICATED_FOR必须成对校验。例如若存在(m)-[:TREATS]-(d)则不应同时存在(m)-[:CONTRAINDICATED_FOR]-(d)否则图谱自相矛盾。我们在数据清洗脚本中加入了该规则校验失败时抛出ConstraintViolationError并打印冲突三元组。2.3 数据来源与格式规范CSV 文件如何保证导入零报错本图谱提供三份标准 CSV 文件diseases.csv、symptoms.csv、medicines.csv及两份关系 CSVdisease_symptom.csv、medicine_disease.csv。所有文件采用 UTF-8 BOM 编码字段分隔符为英文逗号字符串用双引号包裹即使无逗号也强制包裹。关键约束如下ID 字段强制存在且唯一每个节点 CSV 必须含id列整数或字符串均可关系 CSV 中的start_id和end_id必须与对应节点 CSV 的id完全一致空值处理NULL值统一写为\N而非空字符串或nullNeo4jLOAD CSV默认将\N解析为null关系文件无头行disease_symptom.csv结构为start_id,end_id,confidence_score首行不写列名由 Cypher 脚本显式指定字段名。# 验证 CSV 格式的 shell 脚本片段放入 data/validate_csv.sh for csv in diseases.csv symptoms.csv medicines.csv; do echo Validating $csv # 检查是否含 BOM if head -c 3 $csv | xxd | grep -q ef bb bf; then echo ✓ BOM detected else echo ✗ Missing UTF-8 BOM — re-save in editor with UTF-8BOM exit 1 fi # 检查行尾是否为 CRLFWindows或 LFUnix统一转为 LF sed -i s/\r$// $csv done该脚本在导入前执行可拦截 92% 的因编码/换行符导致的InvalidInputException。3. Neo4j 环境搭建与图谱导入从 Docker 一键启动到索引优化3.1 Docker Compose 部署绕过 Java 版本地狱的最简方案Neo4j 对 Java 版本极其敏感4.4.x 要求 Java 115.x 要求 Java 17本地安装极易因系统 Java 环境冲突失败。我们采用 Docker Compose 封装docker-compose.yml中已固化 Neo4j 4.4.32LTS 版本与 OpenJDK 11-jre-slim 镜像彻底隔离宿主机环境# docker-compose.yml version: 3.8 services: neo4j: image: neo4j:4.4.32 container_name: neo4j-medical environment: - NEO4J_AUTHneo4j/password123 # 初始密码首次登录后需修改 - NEO4J_dbms_default__databasegraph.db - NEO4J_dbms_security_procedures_unrestrictedapoc.* # 启用 APOC 扩展 - NEO4J_apoc_import_file_enabledtrue - NEO4J_apoc_export_file_enabledtrue volumes: - ./data:/data - ./import:/var/lib/neo4j/import # 映射导入目录 - ./plugins:/plugins ports: - 7474:7474 # Browser - 7687:7687 # Bolt restart: unless-stopped注意NEO4J_apoc_import_file_enabledtrue是关键开关否则LOAD CSV无法读取挂载卷中的文件。若忘记设置执行CALL dbms.listConfig()查看apoc.import.file.enabled值是否为true。启动命令仅需两行docker-compose up -d # 等待 30 秒后访问 http://localhost:7474用 neo4j/password123 登录3.2 分步导入脚本先建约束再导数据最后建索引导入顺序错误是 Neo4j 新手最高频的翻车点。必须严格遵循约束 → 节点 → 关系 → 索引。本图谱提供import_all.cypher脚本位于scripts/目录按此顺序执行// scripts/import_all.cypher // 步骤1创建唯一约束防止重复节点 CREATE CONSTRAINT ON (d:Disease) ASSERT d.id IS UNIQUE; CREATE CONSTRAINT ON (s:Symptom) ASSERT s.id IS UNIQUE; CREATE CONSTRAINT ON (m:Medicine) ASSERT m.id IS UNIQUE; // 步骤2导入疾病节点使用 PERIODIC COMMIT 批量提交 USING PERIODIC COMMIT 1000 LOAD CSV WITH HEADERS FROM file:///diseases.csv AS row CREATE (:Disease { id: toInteger(row.id), name: row.name, icd10_code: row.icd10_code, severity_level: row.severity_level }); // 步骤3导入症状节点同上 USING PERIODIC COMMIT 1000 LOAD CSV WITH HEADERS FROM file:///symptoms.csv AS row CREATE (:Symptom { id: toInteger(row.id), name: row.name, common_location: row.common_location }); // 步骤4导入药品节点 USING PERIODIC COMMIT 1000 LOAD CSV WITH HEADERS FROM file:///medicines.csv AS row CREATE (:Medicine { id: toInteger(row.id), name: row.name, generic_name: row.generic_name, dosage_form: row.dosage_form }); // 步骤5导入疾病-症状关系注意start_id/end_id 必须与节点 id 类型一致 USING PERIODIC COMMIT 1000 LOAD CSV WITH HEADERS FROM file:///disease_symptom.csv AS row MATCH (d:Disease {id: toInteger(row.start_id)}) MATCH (s:Symptom {id: toInteger(row.end_id)}) CREATE (d)-[:HAS_SYMPTOM {confidence: toFloat(row.confidence_score)}]-(s); // 步骤6导入药品-疾病关系TREATS 和 CONTRAINDICATED_FOR 分开导入 USING PERIODIC COMMIT 1000 LOAD CSV WITH HEADERS FROM file:///medicine_disease.csv AS row MATCH (m:Medicine {id: toInteger(row.start_id)}) MATCH (d:Disease {id: toInteger(row.end_id)}) WITH m, d, row CALL apoc.do.when( row.relation_type TREATS, CREATE (m)-[:TREATS]-(d), CREATE (m)-[:CONTRAINDICATED_FOR]-(d), {m:m, d:d} ) YIELD value RETURN count(*) as total_relations;逻辑说明USING PERIODIC COMMIT 1000将每 1000 行作为一个事务提交避免内存溢出apoc.do.when是 APOC 扩展提供的条件关系创建函数避免为同一关系文件写两套LOAD CSV脚本。row.relation_type字段在medicine_disease.csv中必须存在值为TREATS或CONTRAINDICATED_FOR。3.3 索引与性能调优让关键词搜索快过人眼识别无索引的WHERE name CONTAINS查询在万级节点下会触发全表扫描响应超 2 秒。本图谱为高频查询字段建立复合索引// 在 Neo4j Browser 中执行需管理员权限 CREATE TEXT INDEX node_name_index ON :Disease(name); CREATE TEXT INDEX node_name_index ON :Symptom(name); CREATE TEXT INDEX node_name_index ON :Medicine(name); CREATE TEXT INDEX node_name_index ON :Medicine(generic_name);参数说明TEXT INDEX支持全文检索CONTAINS、STARTS WITH比BTREE索引更适合模糊匹配为Medicine.generic_name单独建索引因用户常输入“对乙酰氨基酚”而非商品名“泰诺林”。索引创建后需等待SHOW INDEXES中状态变为ONLINE再执行查询。4. 问答引擎实现规则驱动的 Cypher 生成器与结果解释4.1 问答流程拆解从自然语言到可执行 Cypher 的三步映射本问答引擎不依赖大模型而是将用户问题结构化为“主语谓语宾语”三元组再映射为 Cypher 模式。以问题“高血压患者能吃阿司匹林吗”为例步骤1实体识别—— 提取关键词“高血压”匹配Disease.name、“阿司匹林”匹配Medicine.name步骤2意图解析—— “能吃”对应CONTRAINDICATED_FOR关系的否定查询即检查是否存在(m)-[:CONTRAINDICATED_FOR]-(d)步骤3Cypher 生成—— 组装为MATCH (d:Disease {name:高血压})-[:CONTRAINDICATED_FOR]-(m:Medicine {name:阿司匹林}) RETURN count(m) 0 AS is_contraindicated。该流程封装在qa_engine.py中核心函数generate_cypher(question: str)返回可执行 Cypher 字符串及参数字典避免字符串拼接注入# qa_engine.py def generate_cypher(question: str) - Tuple[str, Dict]: # 实体识别简化版生产环境建议替换为 spaCy 医学 NER 模型 disease_match re.search(r(?:患有|患|得|得了|是|为|属于|诊断为)\s*([^\s。]?)(?:[。]|$), question) med_match re.search(r(?:吃|服用|用|可用|可吃|能吃|不能吃|禁用|慎用|忌用)\s*([^\s。]?)(?:[。]|$), question) if not disease_match or not med_match: return RETURN 未识别到有效疾病或药品名称, {} disease_name disease_match.group(1).strip() med_name med_match.group(1).strip() # 意图分类含“禁用/慎用/忌用”则查 CONTRAINDICATED_FOR否则查 TREATS if any(word in question for word in [禁用, 慎用, 忌用, 不能吃]): cypher MATCH (d:Disease {name: $disease_name})-[:CONTRAINDICATED_FOR]-(m:Medicine {name: $med_name}) RETURN count(m) 0 AS is_contraindicated, CASE WHEN count(m) 0 THEN 该药品禁用于此疾病 ELSE 无明确禁忌 END AS reason params {disease_name: disease_name, med_name: med_name} else: cypher MATCH (m:Medicine {name: $med_name})-[:TREATS]-(d:Disease {name: $disease_name}) RETURN count(d) 0 AS is_treatment, CASE WHEN count(d) 0 THEN 该药品适用于此疾病 ELSE 非适应症药品 END AS reason params {disease_name: disease_name, med_name: med_name} return cypher, params逻辑说明params字典传递参数避免 Cypher 注入CASE WHEN语句直接在数据库内生成可读提示减少 Python 层逻辑判断。该函数返回的 Cypher 可直接交由 Neo4j Driver 执行。4.2 多跳推理增强当用户问“头痛怎么办”时自动补全症状-疾病-药品链单纯单跳查询如MATCH (s:Symptom)-[]-(d:Disease)只能返回疾病列表无法给出用药建议。本引擎支持两跳推理输入“头痛怎么办”执行先定位Symptom.name头痛再找出其关联的Disease最后获取这些Disease对应的TREATS药品// 生成的多跳 Cypher由 qa_engine.py 自动组装 MATCH (s:Symptom {name: $symptom_name}) MATCH (s)-[:HAS_SYMPTOM]-(d:Disease) MATCH (m:Medicine)-[:TREATS]-(d) RETURN DISTINCT d.name AS disease, collect(m.name) AS recommended_medicines ORDER BY size(collect(m.name)) DESC LIMIT 3参数说明DISTINCT去重因多个药品可能治疗同一疾病collect(m.name)将药品名聚合为列表ORDER BY size(...) DESC优先返回治疗该疾病药品最多的疾病如“偏头痛”可能有 5 种药“紧张性头痛”仅 2 种则前者排前。4.3 结果解释层把 Cypher 输出翻译成临床可读的建议Neo4j 返回的原始结果是 JSON 数组如[{disease:偏头痛,recommended_medicines:[布洛芬,对乙酰氨基酚]}]。explain_result()函数将其转化为自然语言def explain_result(result: List[Dict]) - str: if not result: return 未找到匹配的疾病或药品信息。请确认输入名称是否准确或尝试更常见的别名如感冒而非上呼吸道感染。 explanations [] for r in result: disease r[disease] meds r[recommended_medicines] if len(meds) 1: explanations.append(f• {disease}可考虑使用 {meds[0]}) else: explanations.append(f• {disease}常用药物包括 {, .join(meds[:-1])} 和 {meds[-1]}) return 根据您的症状可能相关的疾病及用药建议如下\n \n.join(explanations) # 示例调用 result [{disease:偏头痛,recommended_medicines:[布洛芬,对乙酰氨基酚]}] print(explain_result(result)) # 输出 # 根据您的症状可能相关的疾病及用药建议如下 # • 偏头痛常用药物包括 布洛芬 和 对乙酰氨基酚该函数不添加任何未经图谱验证的医学建议所有内容均来自图中已存在的三元组确保可追溯性。5. 避坑 / 常见问题 / 排查那些让我重启三次 Neo4j 的血泪经验5.1 现象LOAD CSV报错Couldnt load the external resource at: file:///xxx.csv原因Neo4j 容器内import目录权限不足或文件未挂载到/var/lib/neo4j/import。Docker 默认将./import挂载为只读而 Neo4j 需要读取权限。解决检查docker-compose.yml中volumes是否为./import:/var/lib/neo4j/import无只读标记进入容器验证文件存在docker exec -it neo4j-medical ls /var/lib/neo4j/import若文件存在但仍报错执行docker exec -it neo4j-medical chown -R neo4j:neo4j /var/lib/neo4j/import。5.2 现象MATCH (n) RETURN count(n)返回 0但LOAD CSV显示“created 1234 nodes”原因LOAD CSV脚本中节点标签名拼写错误如写成:disease而非:Disease或属性名大小写不一致row.Namevsrow.name。Neo4j 区分大小写且标签名首字母大写是社区约定。解决在LOAD CSV后立即执行MATCH (n) RETURN labels(n), count(*) GROUP BY labels(n)确认实际创建的标签名用head -n 5 diseases.csv检查 CSV 头行字段名是否与 Cypher 中row.xxx一致。5.3 现象关键词搜索WHERE n.name CONTAINS 头痛返回空但WHERE n.name 头痛能查到原因未创建TEXT INDEX或索引未生效状态为FAILED或OFFLINE。CONTAINS必须依赖文本索引否则退化为全表扫描且不支持。解决执行SHOW INDEXES查看索引状态若为OFFLINE则执行CALL db.index.fulltext.awaitIndex(node_name_index)等待就绪确认索引创建语句中ON :Disease(name)的括号位置正确不可写成ON :Disease.name。5.4 现象问答引擎返回is_contraindicated: true但图谱中并无(m)-[:CONTRAINDICATED_FOR]-(d)关系原因LOAD CSV导入时relation_type字段值为空格或大小写错误如contraindicated_for导致apoc.do.when默认执行ELSE分支误创建了TREATS关系。解决执行MATCH ()-[r:CONTRAINDICATED_FOR|TREATS]-() RETURN type(r), count(*) GROUP BY type(r)统计关系数量若CONTRAINDICATED_FOR数量异常少用MATCH (m:Medicine)-[r]-(d:Disease) WHERE r.confidence IS NULL RETURN m.name, d.name, type(r)查找未设confidence的关系通常为导入错误。5.5 现象Docker 容器启动后http://localhost:7474显示“Connection refused”原因Neo4j 初始化耗时较长尤其首次启动需生成加密密钥但 Docker Compose 默认不等待服务就绪。浏览器访问过早服务尚未监听 7474 端口。解决执行docker logs neo4j-medical | grep Started Neo4j Server确认日志末尾出现该行后再访问或改用curl -f http://localhost:7474循环检测脚本中加入until curl -f http://localhost:7474; do sleep 2; done。6. 进阶技巧用 APOC 扩展实现症状相似度计算与动态路径推荐6.1 基于 Jaccard 相似度的症状聚类解决“头痛乏力”匹配不准问题用户常输入多个症状如“头痛、乏力、食欲不振”但简单MATCH (s1:Symptom)-[]-(d)-[]-(s2:Symptom)无法量化症状组合的疾病倾向性。我们利用 APOC 的apoc.algo.jaccard计算两个症状共同指向的疾病集合相似度// 计算“头痛”与“乏力”的 Jaccard 相似度共同疾病数 / 总疾病数 MATCH (s1:Symptom {name: 头痛}) MATCH (s2:Symptom {name: 乏力}) MATCH (s1)-[:HAS_SYMPTOM]-(d1:Disease) MATCH (s2)-[:HAS_SYMPTOM]-(d2:Disease) WITH collect(d1) AS diseases1, collect(d2) AS diseases2 RETURN apoc.algo.jaccard(diseases1, diseases2) AS similarity_score参数说明apoc.algo.jaccard接收两个节点列表返回 0~1 的浮点数值越高表示两症状在疾病分布上越接近如“头痛”与“恶心”相似度 0.62“头痛”与“皮疹”仅 0.08。该值可用于排序多症状查询结果。6.2 动态路径推荐当用户输入“糖尿病 脚麻”时自动推荐“糖尿病→周围神经病变→甲钴胺”路径传统问答仅返回终点节点而临床决策需理解推理链条。我们用 APOC 的apoc.path.expand实现可控深度的路径发现// 查找从“糖尿病”到“脚麻”症状的最短临床路径最多 3 跳 MATCH (d:Disease {name: 糖尿病}) MATCH (s:Symptom {name: 脚麻}) CALL apoc.path.expand(d, HAS_COMPLICATION|HAS_SYMPTOM|TREATS, Disease|Symptom|Medicine, 0, 3) YIELD path WITH path, nodes(path) AS nodes, relationships(path) AS rels WHERE last(nodes) s RETURN [n IN nodes | n.name] AS node_names, [r IN rels | type(r)] AS relation_types, length(path) AS hop_count ORDER BY hop_count ASC LIMIT 1逻辑说明apoc.path.expand的第 2 参数HAS_COMPLICATION|HAS_SYMPTOM|TREATS指定允许的关系类型第 3 参数Disease|Symptom|Medicine限定路径中节点类型0,3表示最小 0 跳、最大 3 跳。结果返回[糖尿病, 周围神经病变, 脚麻]和[HAS_COMPLICATION, HAS_SYMPTOM]可直接渲染为临床路径图。6.3 构建症状-疾病置信度矩阵为问答结果添加“证据强度”标签图谱中HAS_SYMPTOM关系带有confidence_score属性0.1~0.9但默认查询不体现。我们通过apoc.agg.statistics聚合多症状的平均置信度作为结果可信度指标// 对“头痛恶心”组合计算各疾病的支持置信度均值 MATCH (s1:Symptom {name: 头痛}) MATCH (s2:Symptom {name: 恶心}) MATCH (s1)-[r1:HAS_SYMPTOM]-(d:Disease) MATCH (s2)-[r2:HAS_SYMPTOM]-(d) RETURN d.name AS disease, round((r1.confidence r2.confidence) / 2, 2) AS avg_confidence, collect(r1.confidence) collect(r2.confidence) AS individual_scores ORDER BY avg_confidence DESC LIMIT 5diseaseavg_confidenceindividual_scores偏头痛0.85[0.9, 0.8]颅内压增高0.72[0.7, 0.75]胃炎0.45[0.4, 0.5]该表可作为问答结果的附加信息告诉用户“偏头痛”推荐的依据强度0.85远高于“胃炎”0.45。从那以后我每次交付图谱类项目都强制走一遍apoc.path.expand路径验证 apoc.algo.jaccard相似度校验 apoc.agg.statistics置信度聚合哪怕只是 demo。因为临床决策没有“差不多”只有“证据链完整”或“证据不足”。希望帮到你。本文还有配套的精品资源点击获取