简介:本资源是一套基于neo4j图数据库构建的简易医疗问答知识图谱项目源码,面向希望入门知识图谱、图数据库应用与医疗数据挖掘的学生和开发者。项目数据从ask120医疗问答平台爬取,涵盖问题、答案、疾病、症状与治疗方法等信息,通过节点与关系建模实现医疗知识的关联检索与智能推荐。压缩包共37个文件,约78KB,以Python脚本为主,包含爬虫、数据处理与Django Web应用模块,另有pyc编译文件、xml配置、html页面及少量js、png等资源,结构上分为数据采集、预处理、图谱导入与查询展示等部分。目前已有5461人学习下载。读者可借此掌握从数据爬取、清洗建模到neo4j导入与Cypher查询的完整流程,理解疾病与症状、治疗手段之间的图谱关系,并参考现有目录结构快速搭建自己的医疗问答知识图谱原型。
1. 从零搭一套医疗问答知识图谱:为什么我选 Neo4j 而不是 MySQL
去年帮某实验室做一套模拟医疗问答系统时,我第一反应是用 MySQL 建几张表:疾病表、症状表、药品表、关系表。写到第三张关联表的时候我就放弃了——"高血压"可能关联十几个症状、七八种并发症、五六类禁忌药,每加一个查询维度就要多一层 JOIN,SQL 写到后面自己都看不懂。这就是关系型数据库处理图结构数据的经典翻车现场:数据本身是网状的,你硬要把它拍平成表格,查询复杂度会指数级上升。
知识图谱要解决的核心问题就是"多跳关联查询"。比如用户问"高血压患者能不能吃某类感冒药",系统需要走这样一条路径:疾病 → 并发症 → 禁忌药物 → 药物成分 → 相互作用。在 Neo4j 里这就是一句 Cypher 的事,在 MySQL 里你得写四层嵌套子查询。这套基于 Neo4j 的简易医疗问答知识图谱,本质上是把医疗领域的实体(疾病、症状、药品、检查项目)和它们之间的关系(导致、治疗、禁忌、并发)存成图结构,再通过自然语言问题解析 + Cypher 查询模板匹配,把用户的口语化提问转成图查询语句,最终返回结构化答案。
它适合谁?一是想入门知识图谱但不知道从哪下手的新手,这套东西体量小、依赖少,一台笔记本就能跑起来;二是做医疗信息化、智能导诊、健康管理类产品的开发者,可以直接拿这套 schema 和查询逻辑做原型验证;三是想理解图数据库和关系型数据库本质差异的工程师,用这个场景做对比实验比看十篇科普文都管用。下面我从环境搭建、数据建模、问答解析、避坑排查一路拆到进阶优化,每一步都给出可复现的命令和代码。
2. 环境搭建与数据建模:从 Neo4j 安装到医疗实体关系落地
2.1 Neo4j 安装与 Python 驱动配置
Neo4j 有社区版和企业版,做这套简易问答系统社区版完全够用。我一般用 Docker 起,省得配 Java 环境。注意 Neo4j 5.x 和 4.x 的 Cypher 语法有差异,下面以 5.x 社区版为例。
# 拉取 Neo4j 5 社区版镜像,映射三个端口:7474 浏览器、7687 Bolt 协议 docker run -d \ --name neo4j-medical \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/medgraph123 \ -e NEO4J_PLUGINS='["apoc"]' \ -v neo4j_medical_data:/data \ neo4j:5-community参数说明:NEO4J_AUTH设置初始用户名密码,生产环境别用这么简单的;NEO4J_PLUGINS装了 APOC 插件,后面做批量导入和路径查询会用到;-v把数据挂到命名卷,容器删了数据还在。启动后浏览器打开http://localhost:7474,用neo4j / medgraph123登录,能看到 Neo4j Browser 界面就成功了。
Python 侧用官方驱动:
# pip install neo4j==5.* from neo4j import GraphDatabase class MedicalGraph: def __init__(self, uri, user, password): # 建立驱动连接,注意 Neo4j 5 的驱动是线程安全的,全局一个实例即可 self.driver = GraphDatabase.driver(uri, auth=(user, password)) def close(self): self.driver.close() def run_query(self, cypher, params=None): # 用 execute_query 自动处理会话和重试,比手动开 session 省心 with self.driver.session() as session: result = session.run(cypher, params or {}) return [record.data() for record in result] # 测试连接 g = MedicalGraph("bolt://localhost:7687", "neo4j", "medgraph123") print(g.run_query("RETURN 'connected' AS status")) g.close()这里有个细节:Neo4j 5 的 Python 驱动推荐用driver.execute_query()而不是手动session.run(),前者内置了自动重试和连接池管理。我上面为了展示 session 用法还是用了传统写法,实际项目里可以换。
2.2 医疗知识图谱的 Schema 设计
Schema 设计是整套系统的地基,地基歪了后面查询怎么写都别扭。医疗问答场景下,我一般会抽象出这几类节点和关系:
| 节点标签 | 属性 | 说明 |
|---|---|---|
| Disease | name, category, severity | 疾病,如"高血压""2型糖尿病" |
| Symptom | name, body_part | 症状,如"头痛""多饮" |
| Drug | name, category, dosage | 药品,如"阿司匹林""二甲双胍" |
| Check | name, department | 检查项目,如"血糖检测""CT" |
| Food | name, type | 食物,如"高盐食品""酒精" |
| 关系类型 | 方向 | 说明 |
|---|---|---|
| HAS_SYMPTOM | Disease → Symptom | 疾病表现出的症状 |
| TREATS_WITH | Disease → Drug | 疾病常用治疗药物 |
| CONTRAINDICATES | Disease → Drug | 疾病禁忌药物 |
| NEEDS_CHECK | Disease → Check | 疾病所需检查 |
| COMPLICATES | Disease → Disease | 并发症关系 |
| AVOIDS_FOOD | Disease → Food | 疾病忌口食物 |
这个 schema 的好处是查询路径清晰。用户问"高血压有什么症状",就是MATCH (d:Disease {name:'高血压'})-[:HAS_SYMPTOM]->(s) RETURN s.name;问"高血压不能吃什么",就是MATCH (d:Disease {name:'高血压'})-[:AVOIDS_FOOD]->(f) RETURN f.name。每条自然语言问题都能映射到一条固定模式的 Cypher 路径。
2.3 用 Cypher 批量导入医疗数据
数据来源可以是公开的医疗百科结构化数据,也可以手工整理一份小规模种子数据。我一般先用 CSV 准备数据,再用LOAD CSV导入。假设有三个文件:diseases.csv、symptoms.csv、disease_symptom.csv。
// 1. 创建疾病节点,用 MERGE 避免重复插入 LOAD CSV WITH HEADERS FROM 'file:///diseases.csv' AS row MERGE (d:Disease {name: row.name}) SET d.category = row.category, d.severity = row.severity; // 2. 创建症状节点 LOAD CSV WITH HEADERS FROM 'file:///symptoms.csv' AS row MERGE (s:Symptom {name: row.name}) SET s.body_part = row.body_part; // 3. 建立疾病-症状关系 LOAD CSV WITH HEADERS FROM 'file:///disease_symptom.csv' AS row MATCH (d:Disease {name: row.disease}) MATCH (s:Symptom {name: row.symptom}) MERGE (d)-[:HAS_SYMPTOM]->(s);注意LOAD CSV的文件要放在 Neo4j 的import目录下,Docker 部署的话需要额外挂载一个卷把 CSV 目录映射进去,否则会报"Couldn't load the external resource"错误。这是新手最常踩的坑之一,后面避坑章节会细说。
导入完成后验证一下:
// 统计各类节点数量 MATCH (d:Disease) RETURN count(d) AS disease_count; MATCH (s:Symptom) RETURN count(s) AS symptom_count; // 查看某个疾病的所有关联 MATCH (d:Disease {name:'高血压'})-[r]->(n) RETURN type(r), labels(n), n.name;如果disease_count是 0,先检查 CSV 文件路径和编码(必须是 UTF-8 无 BOM),再检查LOAD CSV语句里字段名是否和 CSV 表头完全一致——大小写敏感。
3. 问答解析引擎:把自然语言问题转成 Cypher 查询
3.1 基于模板匹配的问题分类
简易问答系统不需要上大模型,用模板匹配 + 关键词提取就能覆盖 80% 的常见问题。核心思路是:先识别问题中的疾病实体,再识别问题意图(问症状、问药物、问禁忌、问检查),最后拼装成 Cypher。
import re # 意图模板:正则模式 → Cypher 查询模板 INTENT_PATTERNS = [ { "name": "query_symptom", "pattern": r"(.*?)(有什么|有哪些|啥)(症状|表现|症状表现)", "cypher": "MATCH (d:Disease {name: $disease})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name AS answer" }, { "name": "query_drug", "pattern": r"(.*?)(吃什么药|用什么药|治疗药物|怎么治)", "cypher": "MATCH (d:Disease {name: $disease})-[:TREATS_WITH]->(dr:Drug) RETURN dr.name AS answer" }, { "name": "query_contraindication", "pattern": r"(.*?)(不能吃|禁忌|忌用)(什么药|哪些药)?", "cypher": "MATCH (d:Disease {name: $disease})-[:CONTRAINDICATES]->(dr:Drug) RETURN dr.name AS answer" }, { "name": "query_check", "pattern": r"(.*?)(做什么检查|需要检查|检查项目)", "cypher": "MATCH (d:Disease {name: $disease})-[:NEEDS_CHECK]->(c:Check) RETURN c.name AS answer" }, ] def parse_question(question, disease_list): """解析用户问题,返回意图和实体""" # 先匹配疾病实体:遍历已知疾病列表,看问题里包含哪个 matched_disease = None for disease in disease_list: if disease in question: matched_disease = disease break if not matched_disease: return None, None # 再匹配意图 for intent in INTENT_PATTERNS: if re.search(intent["pattern"], question): return intent, matched_disease return None, matched_disease这段代码的逻辑说明:disease_list是从 Neo4j 里查出来的所有疾病名称列表,启动时加载到内存。匹配时优先匹配长名称(比如"2型糖尿病"要优先于"糖尿病"),否则会出现实体识别错误。意图匹配用正则,按优先级从上到下匹配,第一个命中的就是最终意图。
3.2 实体识别与查询执行
实体识别这块,简易版直接用字符串包含判断就够了,但要注意一个坑:疾病名称有包含关系。比如"糖尿病"和"2型糖尿病",如果先匹配到"糖尿病",那"2型糖尿病"的问题就会被错误解析。解决办法是按名称长度降序排列后再匹配。
def build_disease_list(graph): """从图数据库加载所有疾病名称,按长度降序排列""" result = graph.run_query("MATCH (d:Disease) RETURN d.name AS name") names = [r["name"] for r in result] # 关键:长名称优先匹配,避免"糖尿病"截胡"2型糖尿病" return sorted(names, key=len, reverse=True) def answer_question(graph, question, disease_list): """完整问答流程:解析 → 查询 → 格式化答案""" intent, disease = parse_question(question, disease_list) if not disease: return "抱歉,没有识别到您提到的疾病,请确认疾病名称是否正确。" if not intent: return f"识别到疾病「{disease}」,但暂时不支持这类问题的查询。" # 执行 Cypher 查询 results = graph.run_query(intent["cypher"], {"disease": disease}) answers = [r["answer"] for r in results] if not answers: return f"关于「{disease}」的{intent['name']}信息,数据库中暂未收录。" return f"「{disease}」的查询结果:{'、'.join(answers)}"参数说明:intent["cypher"]里的$disease是参数化查询占位符,Neo4j 驱动会自动做转义,千万不要用字符串拼接,否则会有 Cypher 注入风险。answers列表可能为空,说明数据库里没有对应关系,这时候要给用户一个友好的提示而不是直接报错。
3.3 多跳查询的实现
单跳查询只能回答"高血压有什么症状"这种简单问题。真正体现知识图谱价值的是多跳查询,比如"高血压的并发症有哪些症状"——这需要走Disease → COMPLICATES → Disease → HAS_SYMPTOM → Symptom这条路径。
# 多跳查询模板:并发症的症状 MULTI_HOP_TEMPLATES = [ { "name": "complication_symptom", "pattern": r"(.*?)的(并发症|并发)(有什么症状|有哪些症状|症状)", "cypher": """ MATCH (d:Disease {name: $disease})-[:COMPLICATES]->(c:Disease)-[:HAS_SYMPTOM]->(s:Symptom) RETURN c.name + ':' + collect(s.name)[0..3] AS answer """ }, { "name": "drug_interaction", "pattern": r"(.*?)患者?(不能|禁忌)(吃|用)(.*?)", "cypher": """ MATCH (d:Disease {name: $disease})-[:CONTRAINDICATES]->(dr:Drug) WHERE dr.name CONTAINS $drug_keyword RETURN dr.name AS answer """ }, ]多跳查询的 Cypher 里用了collect()聚合函数和切片[0..3],意思是每个并发症只取前三个症状,避免返回结果过长。drug_interaction模板里用了CONTAINS做模糊匹配,因为用户可能只记得药品名的一部分。这里有个性能注意点:CONTAINS无法走索引,数据量大了会全表扫描,生产环境建议用全文索引替代。
4. 避坑与排查:Neo4j 医疗图谱落地时最容易翻车的五个地方
4.1 坑一:LOAD CSV 报 "Couldn't load the external resource"
现象:执行LOAD CSV FROM 'file:///xxx.csv'时直接报错,提示无法加载外部资源。
原因:Neo4j 默认只允许从import目录读取文件,Docker 部署时这个目录在容器内部,宿主机上的 CSV 文件根本没挂载进去。
解决:启动容器时加一个卷映射-v /宿主机/csv目录:/var/lib/neo4j/import,把 CSV 放到宿主机对应目录下。另外确认文件权限,Neo4j 容器内进程用户需要有读权限。如果还不行,检查neo4j.conf里的dbms.security.allow_csv_import_from_file_urls是否为true。
4.2 坑二:中文实体匹配时"糖尿病"截胡"2型糖尿病"
现象:用户问"2型糖尿病吃什么药",系统返回的是"糖尿病"的查询结果。
原因:实体识别时按列表顺序匹配,如果"糖尿病"排在"2型糖尿病"前面,if disease in question会先命中短的。
解决:加载疾病列表时按名称长度降序排列,确保长名称优先匹配。更稳妥的做法是用 AC 自动机做多模式匹配,但简易版排序就够了。这个坑我在三个项目里都遇到过,血泪经验就是:只要涉及中文实体识别,永远先按长度排序。
4.3 坑三:Cypher 参数化查询写成字符串拼接导致注入
现象:用户输入里带单引号或特殊字符时查询报错,甚至能执行非预期查询。
原因:用了 f-string 或+拼接 Cypher 语句,比如"MATCH (d:Disease {name:'" + disease + "'})"。
解决:永远用参数化查询$param,驱动会自动转义。如果确实需要动态拼接标签名或关系类型(这些不支持参数化),必须用白名单校验,只允许预定义的标签和关系类型通过。
4.4 坑四:MERGE 导致重复创建节点
现象:多次执行导入脚本后,节点数量翻倍。
原因:MERGE的匹配逻辑是基于整个模式匹配的。如果MERGE (d:Disease {name: row.name})里 name 属性有前后空格差异,或者大小写不一致,就会认为是不同节点。
解决:导入前对 CSV 数据做清洗,strip()去空格、统一大小写。另外给Disease.name建唯一约束:CREATE CONSTRAINT FOR (d:Disease) REQUIRE d.name IS UNIQUE,这样重复插入会直接报错而不是静默创建重复节点。
4.5 坑五:多跳查询返回笛卡尔积导致结果爆炸
现象:查询"高血压并发症的症状"时返回了几百条结果,大量重复。
原因:多跳路径中如果某个节点有多个关联,Cypher 会返回所有路径的组合,形成笛卡尔积。
解决:用collect()聚合 +DISTINCT去重,或者在MATCH后加WITH DISTINCT中间聚合。更根本的办法是控制查询深度,简易问答系统里超过三跳的查询建议拆成多次单跳查询在应用层组合。
5. 进阶技巧:用 APOC 插件做路径查询和性能验证
5.1 APOC 路径扩展查询
前面第二章装 Neo4j 时已经带了 APOC 插件,这里说几个医疗问答场景下特别实用的 APOC 过程。最常用的是apoc.path.expandConfig,它能按配置灵活扩展路径,比手写多跳 Cypher 简洁得多。
// 从"高血压"出发,最多扩展3跳,只走指定关系类型 MATCH (start:Disease {name:'高血压'}) CALL apoc.path.expandConfig(start, { relationshipFilter: 'COMPLICATES>|HAS_SYMPTOM>', minLevel: 1, maxLevel: 3, bfs: true }) YIELD path RETURN path, [node IN nodes(path) | node.name] AS node_names, length(path) AS depth ORDER BY depth LIMIT 20;参数说明:relationshipFilter里>表示方向,COMPLICATES>|HAS_SYMPTOM>意思是只沿出边方向走这两种关系;bfs: true是广度优先,适合找最短路径;maxLevel: 3限制最大深度,防止在大图上跑飞。这个查询能一次性拿到"高血压 → 并发症 → 症状"的所有路径,比手写多跳 Cypher 灵活得多。
5.2 用 PROFILE 验证查询性能
写完查询别急着上线,先用PROFILE看执行计划。这是我从一个线上事故里学到的习惯——当时一个没加索引的查询在测试环境数据量小的时候毫秒级返回,上线后数据量涨到十万级直接超时。
PROFILE MATCH (d:Disease {name:'高血压'})-[:HAS_SYMPTOM]->(s:Symptom) RETURN s.name;执行后看两个关键指标:db hits和rows。如果db hits远大于返回的rows数量,说明有大量无效扫描。重点看执行计划里有没有NodeByLabelScan——如果有,说明没走索引,需要给Disease.name建索引:
CREATE INDEX disease_name_index FOR (d:Disease) ON (d.name); CREATE INDEX symptom_name_index FOR (s:Symptom) ON (s.name);建完索引再PROFILE一次,应该能看到NodeIndexSeek替代了NodeByLabelScan,db hits会大幅下降。
5.3 问答准确率的验证方法
系统搭好了怎么验证它靠不靠谱?我一般准备一组测试问题集,覆盖各种意图和边界情况,然后跑批量测试统计准确率。
| 测试类型 | 示例问题 | 预期结果 |
|---|---|---|
| 单跳-症状 | 高血压有什么症状 | 返回症状列表 |
| 单跳-药物 | 糖尿病吃什么药 | 返回药物列表 |
| 单跳-禁忌 | 高血压不能吃什么药 | 返回禁忌药物 |
| 多跳-并发症 | 糖尿病的并发症有什么症状 | 返回并发症及症状 |
| 边界-未收录 | 某罕见病有什么症状 | 友好提示未收录 |
| 边界-实体错误 | 高压血有什么症状 | 提示疾病名称未识别 |
跑完统计准确率,低于 80% 就回去检查实体识别和意图模板。我一般会把这个测试集做成脚本,每次改完模板就跑一遍,防止改 A 坏 B。
从那以后我每次改意图模板或者加新关系类型,都强制走一遍这个测试集,再PROFILE一下核心查询确认没退化成全表扫描。这套简易医疗问答知识图谱虽然体量不大,但把实体建模、意图解析、多跳查询、性能验证这条链路完整跑通一遍,比看再多理论都实在。希望帮到你。
本文还有配套的精品资源,点击获取