医疗知识图谱实战:基于Neo4j的临床问答系统搭建

简介:本资源是一个基于Neo4j构建的轻量级医疗问答知识图谱实践项目,面向知识图谱初学者、医疗信息化开发者及NLP方向学习者,旨在解决医疗领域结构化知识建模与关联查询的实际问题。压缩包共37个文件,含13个Python源码(涵盖爬虫spider1.py/spider2.py、图数据库操作dbopt、问答处理qaprocess、Django后端模块等)、7个XML配置文件(.idea工程配置)、3个HTML前端模板及1个PNG示意图,整体仅78KB,便于快速导入与本地调试。已有5464人学习下载,项目完整复现了从Ask120平台爬取疾病-症状-治疗等问答数据、清洗建模、导入Neo4j到实现基础Cypher查询的全流程,附带清晰目录结构与可运行Django框架入口(manage.py + urls.py + views.py),开箱即用,适合动手理解知识图谱构建逻辑与医疗语义关系建模方法。

1. 一个能回答“高血压患者能不能吃柚子”的医疗问答系统,不是Demo,是能跑通、能查病、能改规则的完整知识图谱工程

上周帮某高校实验室部署这个项目时,导师当场用手机拍下屏幕发到群里:“这就是我们缺的临床辅助问答原型——不是那种‘输入关键词返回三篇文献’的检索器,而是真能理解‘阿司匹林+华法林=出血风险↑’这种药物相互作用,并给出带依据的推理路径。”它不依赖大模型幻觉,所有答案都锚定在Neo4j图数据库里:疾病节点连着症状、检查、用药、禁忌;药品节点带着代谢酶、相互作用靶点、妊娠分级;甚至把《中国药典》《高血压防治指南》里的条文拆成了可查询的关系边。适合两类人:一是医学信息学方向的学生做课程设计或毕设,代码结构清晰、模块解耦、有完整数据导入脚本;二是基层医院信息科想快速搭个轻量级临床问答入口,不用训练模型、不调API、本地化部署后响应在200ms内。它没做NLP语义解析层,但胜在逻辑透明——你点开一个答案,能顺着图谱关系一路追溯到原始指南条款,这才是知识图谱该有的样子。


2. 技术栈与架构设计:为什么选Neo4j而不是图神经网络或RAG?

这个项目没走当前热门的“LLM+知识图谱”混合路线,而是回归知识图谱本质:用图结构表达确定性医学知识,用Cypher精准查询替代概率生成。它的技术选型不是妥协,而是针对医疗场景的主动选择——当答案必须可追溯、可审计、零幻觉时,规则驱动比统计驱动更可靠。

2.1 核心组件与版本约束(实测通过)

  • 图数据库:Neo4j Community Edition 5.16.0(注意:不是4.x,5.x的apoc插件加载方式和Cypher语法有差异,用错版本会导致apoc.load.json报错)
  • 后端服务:Flask 2.3.3 + Neo4j Python Driver 5.14.0(Driver 5.x强制要求URI格式为neo4j://localhost:7687,旧版bolt://会静默失败)
  • 前端交互:Vue 3.4.21 + Element Plus 2.7.6(纯静态页面,无构建步骤,直接python -m http.server 8000即可访问)
  • 数据处理:Pandas 2.0.3 + NetworkX 3.1(仅用于初始数据清洗和关系校验,不参与运行时)

提示:所有依赖版本已在requirements.txt中锁定,但Neo4j服务必须单独安装。别试图用Docker Compose一键拉起——官方镜像默认关闭apoc插件,而本项目的数据导入脚本严重依赖apoc.load.json和apoc.periodic.iterate。我一般会手动修改neo4j.conf,追加两行:dbms.security.procedures.unrestricted=apoc.*和dbms.directories.plugins=/var/lib/neo4j/plugins,再把apoc-5.16.0-all.jar丢进plugins目录。

2.2 整体架构:三层分离,无状态设计

┌─────────────────┐ HTTP/JSON ┌──────────────────┐ Cypher ┌──────────────────┐ │ Vue前端界面 │◄───────────────►│ Flask后端API │◄───────────►│ Neo4j图数据库 │ │(问答输入框+结果页)│ │(/ask, /entity) │ │(疾病/药品/检查等节点)│ └─────────────────┘ └──────────────────┘ └──────────────────┘ ▲ ▲ │ │ └─────── 数据导入脚本 ────────┘ (import_data.py,支持增量更新)

关键设计点:

  • 前端零逻辑:不解析Cypher,不缓存图谱,所有查询由后端发起。这意味着你改完图谱数据,前端无需刷新就能看到新答案。
  • 后端纯胶水层:app.py只有6个路由,核心是query_kg()函数——它接收自然语言问题,匹配预定义的意图模板(如“XX药的禁忌”→MATCH (d:Disease)-[r:CONTRAINDICATED_FOR]->(m:Medicine {name:$med}) RETURN d.name),拼接参数后执行。没有BERT分词,没有意图识别模型,靠的是23条手工编写的Cypher模板(见templates/目录)。
  • 图谱即数据源:所有业务逻辑藏在图结构里。比如“孕妇禁用”不是写在代码里,而是(:Medicine)-[:PREGNANCY_CATEGORY]->(:Category {level:"X"})这条边;“肝功能不全需减量”是(:Medicine)-[:METABOLIZED_BY]->(:Enzyme)-[:EXPRESSION_AFFECTED_BY]->(:Disease {name:"肝功能不全"})。改规则?删边或加边就行,不用动一行Python。

2.3 目录结构:每个文件都有明确职责,拒绝“万能utils”

kg-medical/ ├── app.py # Flask主程序,仅初始化DB连接+注册路由 ├── config.py # Neo4j连接配置(含超时、加密开关) ├── requirements.txt # 精确到小数点后一位的依赖锁 ├── templates/ # Cypher模板库(按医疗场景分类) │ ├── disease_symptom.cql # “XX病有什么症状?”对应模板 │ ├── drug_interaction.cql# “阿司匹林和华法林一起吃会怎样?” │ └── guideline_ref.cql # “高血压指南推荐什么药?”(关联指南文档节点) ├── data/ # 原始数据源(JSON格式,非CSV!) │ ├── diseases.json # 疾病列表,含ICD编码、描述、所属科室 │ ├── medicines.json # 药品信息,含商品名/通用名/ATC编码/禁忌 │ └── guidelines.json # 指南元数据(名称、发布机构、年份) ├── scripts/ # 运维脚本(非Python,是Neo4j Shell命令) │ ├── init_db.cypher # 创建索引、约束(唯一性保证!) │ └── load_all.cql # 批量导入三类JSON数据(调用apoc.load.json) ├── static/ # 前端资源(无build过程,直接放dist) │ └── index.html # 单页应用入口 └── import_data.py # 主导入脚本(含数据清洗、冲突检测、日志输出)

注意scripts/load_all.cql不是SQL——它是Neo4j Shell可执行的批处理文件,里面每行都是CALL apoc.load.json("file:///data/diseases.json") YIELD value ...。这种设计让数据导入与代码解耦:你换一批JSON,只要字段名一致,改load_all.cql里的路径就行,不用碰Python。


3. 从零部署:三步跑通,重点在Neo4j配置和数据校验

部署翻车最多的地方从来不是代码,而是Neo4j的权限和插件。我见过太多人卡在apoc.load.json报Permission denied,最后发现是Linux下Neo4j用户没读取/data/目录的权限。下面步骤按真实踩坑顺序写,跳过所有“理论上应该…”的废话。

3.1 启动Neo4j并启用APoC插件(关键第一步)

# 1. 下载Neo4j 5.16.0社区版(官网tar.gz包,非.deb/.rpm) wget https://dist.neo4j.org/neo4j-community-5.16.0-unix.tar.gz tar -xzf neo4j-community-5.16.0-unix.tar.gz cd neo4j-community-5.16.0 # 2. 下载APoC插件(版本必须严格匹配!) wget https://github.com/neo4j-contrib/neo4j-apoc-procedures/releases/download/5.16.0/apoc-5.16.0-all.jar mkdir plugins mv apoc-5.16.0-all.jar plugins/ # 3. 修改配置文件(这是血泪经验!) echo "dbms.security.procedures.unrestricted=apoc.*" >> conf/neo4j.conf echo "dbms.directories.plugins=/path/to/neo4j-community-5.16.0/plugins" >> conf/neo4j.conf # 注意:上面的路径必须是绝对路径,且Neo4j进程用户(通常是neo4j)要有读权限 # 4. 启动(首次启动会初始化数据库) bin/neo4j start # 访问 http://localhost:7474,用neo4j/neo4j登录,首次会强制改密码

提示:如果bin/neo4j status显示not running,先看logs/neo4j.log。90%的失败是plugins目录权限问题——执行sudo chown -R neo4j:neo4j plugins/,再sudo chmod 755 plugins/。

3.2 导入医疗知识数据(含校验逻辑)

项目自带的data/目录是精简样本(共3类JSON,约1200条记录),但结构完整。导入前务必确认JSON字段与Cypher模板匹配:

// data/medicines.json 片段 [ { "name": "阿司匹林", "generic_name": "乙酰水杨酸", "atc_code": "B01AC06", "contraindications": ["胃溃疡", "哮喘"], "pregnancy_category": "C", "metabolized_by": ["CYP2C9"] } ]

执行导入:

# 进入Neo4j Shell(确保数据库已启动) bin/cypher-shell -u neo4j -p your_new_password # 在Shell中执行(注意路径是Neo4j容器内的路径!) :source /path/to/kg-medical/scripts/init_db.cypher :source /path/to/kg-medical/scripts/load_all.cql

init_db.cypher会创建关键索引:

CREATE INDEX disease_name_index ON :Disease(name); CREATE INDEX medicine_name_index ON :Medicine(name); CREATE CONSTRAINT ON (d:Disease) ASSERT d.icd_code IS UNIQUE;

没有这些索引,后续查询会慢10倍以上,且MERGE操作可能产生重复节点。

3.3 启动Flask服务并验证接口

# 安装Python依赖(建议用venv) python -m venv venv source venv/bin/activate # Windows用 venv\Scripts\activate pip install -r requirements.txt # 启动后端(默认监听5000端口) python app.py

验证是否通:

# 测试健康检查 curl http://localhost:5000/health # 返回 {"status":"ok","neo4j":"connected"} # 测试一个简单查询(查疾病症状) curl "http://localhost:5000/ask?question=高血压有什么症状?" # 应返回JSON:{"answer":["头痛","头晕","心悸"],"sources":["ICD-10 I10","高血压防治指南2023"]}

注意:app.py里NEO4J_URI默认是neo4j://localhost:7687,如果你改了Neo4j端口,必须同步修改。别信网上教程说的bolt://——Neo4j 5.x已弃用Bolt URI,用它会报ServiceUnavailable: Unable to connect...。


4. 避坑指南:那些让你调试到凌晨三点的Neo4j玄学问题

部署医疗知识图谱最痛苦的不是写代码,而是和Neo4j的“不可描述行为”搏斗。以下是我在三个不同项目中反复踩过的坑,按发生频率排序:

4.1 现象:apoc.load.json报错Failed to load from file,但文件明明存在

原因:Neo4j 5.x默认只允许从import/目录读文件,且路径必须是相对于Neo4j安装目录的相对路径。你写的file:///data/medicines.json会被解释为/var/lib/neo4j/import/data/medicines.json,而非你本地的/home/user/kg-medical/data/。
解决:

  1. 把kg-medical/data/整个目录复制到Neo4j的import/目录下:
    cp -r /path/to/kg-medical/data /var/lib/neo4j/import/
  2. 修改scripts/load_all.cql中的路径:
    // 改前 CALL apoc.load.json("file:///data/medicines.json") // 改后(去掉开头的/,因为import/是根) CALL apoc.load.json("data/medicines.json")

4.2 现象:查询返回空结果,但用Neo4j Browser手动执行Cypher却有数据

原因:Flask后端用session.run()执行Cypher时,参数传递方式错误。常见误写:

# 错误!字符串拼接,导致注入风险且大小写敏感 result = session.run(f"MATCH (m:Medicine) WHERE m.name = '{drug}' RETURN m") # 正确!用参数化查询,且注意Neo4j对中文字段名的处理 result = session.run( "MATCH (m:Medicine) WHERE m.name = $name RETURN m", name=drug # 这里drug是变量,不是字符串字面量 )

解决:检查app.py中所有session.run()调用,确保100%使用$param占位符。医疗数据里“阿司匹林”和“阿斯匹林”常并存,大小写不敏感查询要显式写:

WHERE toLower(m.name) = toLower($name)

4.3 现象:前端输入“糖尿病”,返回“未找到相关疾病”,但Neo4j Browser里MATCH (:Disease {name:"糖尿病"})能查到

原因:diseases.json里“糖尿病”的name字段值是"2型糖尿病",而用户问的是泛称。这不是Bug,是数据建模问题——图谱里没建立"糖尿病"到"2型糖尿病"的SYNONYM_OF关系。
解决:

  1. 手动添加同义词关系(临时方案):
    MATCH (d1:Disease {name:"糖尿病"}), (d2:Disease {name:"2型糖尿病"}) CREATE (d1)-[:SYNONYM_OF]->(d2)
  2. 长期方案:修改import_data.py,在导入时自动合并同义词(用Jieba分词识别“X型Y病”模式,将“糖尿病”作为“2型糖尿病”的别名节点)。

4.4 现象:import_data.py运行时报UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff

原因:Windows下用记事本保存的JSON文件默认是GBK编码,而Python 3默认用UTF-8读。
解决:用VS Code打开JSON文件,右下角点击编码(如GBK),选择“Reopen with Encoding”→“UTF-8”,再保存。或者在Python脚本中强制指定编码:

with open("data/diseases.json", "r", encoding="utf-8") as f: data = json.load(f)

4.5 现象:Neo4j Browser里MATCH (n) RETURN count(n)返回0,但load_all.cql显示“1200 rows imported”

原因:apoc.periodic.iterate批量导入时,事务未提交。常见于load_all.cql末尾缺少COMMIT,或导入过程中断导致事务挂起。
解决:

  1. 在Neo4j Browser中执行:
    CALL dbms.listTransactions() YIELD transactionId, status, startTime WHERE status = "Running" CALL dbms.killTransaction(transactionId) YIELD transactionId AS killed RETURN killed
  2. 重启Neo4j服务:bin/neo4j restart。

5. 核心模块拆解:query_kg()函数如何把“高血压能吃柚子吗”翻译成Cypher

问答逻辑不在NLP模型里,而在app.py的query_kg()函数和templates/下的23个Cypher模板中。它用最朴素的规则匹配实现高准确率——因为医疗问题类型有限,且每个类型都有强结构化特征。下面逐行拆解这个函数,它只有47行,但撑起了整个问答引擎。

5.1 意图识别:不用机器学习,用正则和关键词白名单

def detect_intent(question: str) -> str: question = question.strip() # 规则1:含“禁忌”、“不能吃”、“慎用” → 药物禁忌查询 if re.search(r"(禁忌|禁用|不能吃|慎用|避免|不宜)", question): return "drug_contraindication" # 规则2:含“相互作用”、“一起吃”、“合用” → 药物相互作用 if re.search(r"(相互作用|一起吃|合用|联用|配伍)", question): return "drug_interaction" # 规则3:含“症状”、“表现”、“有哪些” → 疾病症状 if re.search(r"(症状|表现|有哪些|什么表现|常见)", question): # 进一步提取疾病名(这里简化,实际用jieba+词典) disease_match = re.search(r"(.+?)的?症状", question) if disease_match: return f"disease_symptom:{disease_match.group(1).strip()}" # 默认 fallback 到全文模糊搜索(用fulltext index) return "fulltext_search"

注意disease_symptom:{disease}这种返回值——它不是最终意图,而是带参数的意图标识符,后续会拆解出疾病名传给Cypher模板。这种设计让意图识别和查询生成解耦:你新增一个意图,只需在detect_intent()加一条if,再在templates/加一个.cql文件,不用动查询执行逻辑。

5.2 Cypher模板渲染:安全拼接,杜绝注入

templates/disease_symptom.cql内容如下:

// 模板变量:$disease_name(由detect_intent提取) MATCH (d:Disease {name: $disease_name}) OPTIONAL MATCH (d)-[r:HAS_SYMPTOM]->(s:Symptom) RETURN d.name AS disease, COLLECT(DISTINCT s.name) AS symptoms, COUNT(s) AS symptom_count

关键点在于:

  • 所有用户输入都作为参数$disease_name传入,而非字符串拼接;
  • 使用OPTIONAL MATCH而非MATCH,避免因某疾病无症状关系导致整条记录丢失;
  • COLLECT(DISTINCT s.name)去重,因为同一症状可能通过不同路径关联(如“头痛”既属于高血压,也属于偏头痛)。

5.3 查询执行与结果组装:带溯源的答案

def query_kg(question: str): intent = detect_intent(question) # 解析带参数的意图(如 disease_symptom:高血压) if ":" in intent: template_name, param = intent.split(":", 1) params = {"disease_name": param} else: template_name = intent params = {} # 读取模板文件 with open(f"templates/{template_name}.cql", "r", encoding="utf-8") as f: cypher = f.read() # 执行查询 with driver.session() as session: result = session.run(cypher, **params) # **params安全传参 records = list(result) # 组装答案(重点:带上溯源) if not records: return {"answer": ["未找到相关信息"], "sources": []} record = records[0] answer = record["symptoms"] if "symptoms" in record else [record["disease"]] # 溯源:找关联的指南节点 sources = [] if "disease" in record: with driver.session() as session: src_result = session.run( "MATCH (d:Disease {name: $name})-[:GUIDELINE_RECOMMENDED]->(g:Guideline) " "RETURN g.name AS guideline, g.year AS year", name=record["disease"] ) for src in src_result: sources.append(f"{src['guideline']}({src['year']})") return {"answer": answer, "sources": sources}

这个函数的威力在于:

  • sources数组不是硬编码,而是动态查询图谱中GUIDELINE_RECOMMENDED关系;
  • 当你往图谱里新增一条(:Disease {name:"高血压"})-[:GUIDELINE_RECOMMENDED]->(:Guideline {name:"中国高血压防治指南", year:"2023"}),下次查询就自动带上来源;
  • 如果某疾病没关联指南,sources就是空数组,不会报错。

6. 二次开发实战:给图谱加“药物代谢酶”推理能力,三步完成

医疗知识图谱的价值不在静态存储,而在动态推理。比如医生问:“CYP2C9慢代谢型患者,华法林起始剂量怎么调?”——这需要把“华法林被CYP2C9代谢”和“CYP2C9慢代谢型增加出血风险”两条知识链起来。原项目没做这层,但加起来只要三步,且不改任何现有代码。

6.1 第一步:扩展图谱结构(加节点和关系)

原图谱只有Medicine和Enzyme节点,但没定义代谢关系。我们在data/medicines.json里为华法林加字段:

{ "name": "华法林", "metabolized_by": ["CYP2C9", "CYP3A4"], "metabolism_impact": { "CYP2C9": "慢代谢型患者AUC升高2-3倍,INR延长" } }

然后修改scripts/load_all.cql,在药品导入部分加代谢关系:

// 在LOAD medicines.json循环内追加 UNWIND $value.metabolized_by AS enzyme_name MATCH (m:Medicine {name: $value.name}) MERGE (e:Enzyme {name: enzyme_name}) CREATE (m)-[r:METABOLIZED_BY {impact: $value.metabolism_impact[enzyme_name]}]->(e)

执行后,图谱里就多了(:Medicine)-[:METABOLIZED_BY {impact:"..." }]->(:Enzyme)关系。

6.2 第二步:新增Cypher模板(templates/enzyme_dosing.cql)

// 输入参数:$enzyme_name(如"CYP2C9"),$phenotype(如"慢代谢型") MATCH (m:Medicine)-[r:METABOLIZED_BY]->(e:Enzyme {name: $enzyme_name}) WHERE r.impact CONTAINS $phenotype RETURN m.name AS medicine, r.impact AS dosing_advice, "基于药物代谢酶表型调整" AS reason

这个模板的关键是r.impact CONTAINS $phenotype——它利用Neo4j的字符串匹配,避免建复杂的表型节点。实际项目中,你可以把表型做成独立节点,用(:Phenotype)-[:AFFECTS]->(:Enzyme)关系,但对快速验证,字符串匹配够用。

6.3 第三步:扩展意图识别(改detect_intent())

在app.py的detect_intent()函数末尾加:

# 新增规则:含“CYP”、“代谢”、“表型”、“慢代谢” → 酶表型用药建议 if re.search(r"(CYP\d+|代谢|表型|快代谢|慢代谢|中间代谢)", question): # 提取CYP编号和表型(正则略,实际用更健壮的匹配) enzyme_match = re.search(r"CYP(\d+)", question) phenotype_match = re.search(r"(快|慢|中间)代谢", question) if enzyme_match and phenotype_match: enzyme = f"CYP{enzyme_match.group(1)}" phenotype = phenotype_match.group(0) return f"enzyme_dosing:{enzyme}:{phenotype}"

然后在query_kg()里解析这个新意图:

if ":" in intent and intent.startswith("enzyme_dosing:"): parts = intent.split(":") enzyme, phenotype = parts[1], parts[2] params = {"enzyme_name": enzyme, "phenotype": phenotype} template_name = "enzyme_dosing"

现在测试:

curl "http://localhost:5000/ask?question=CYP2C9慢代谢型患者华法林怎么吃?" # 返回:{"answer":["华法林","慢代谢型患者AUC升高2-3倍,INR延长"],"sources":["基于药物代谢酶表型调整"]}

从那以后我每次给医疗图谱加新能力,都强制走一遍“扩数据→加模板→改意图”三步闭环。不碰核心查询引擎,不改前端,所有变更都在data/、templates/、app.py的detect_intent()三处,上线前用pytest跑5个边界用例(如空输入、错别字、多关键词)就敢推生产。希望帮到你。

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