基于Neo4j的医疗知识图谱问答系统:从实体识别到Cypher查询

发布时间:2026/9/12 18:40:51
基于Neo4j的医疗知识图谱问答系统:从实体识别到Cypher查询 简介基于Python的知识图谱医疗领域问答系统项目面向Python学习者和知识图谱入门者提供一套可完整运行的医疗领域问答系统源码与配套数据适合作为期末大作业或课程设计参考。整个项目针对医疗知识结构化表示与自动问答实现进行了项目级整合可帮助读者快速理解知识图谱在垂直场景中的应用。压缩包整体大小约19MB采用zip格式内含可直接运行的源码以及相关数据文件由于本地编译验证通过下载后参照文档配置好运行环境即可启动项目免去复杂的环境调试过程。截至目前已有448人学习/下载常用于期末大作业、毕业设计或自学实践。整体难度适中内容经助教老师审定不仅覆盖知识图谱构建、实体关系抽取等关键环节也便于学习者在真实数据集上进行问答测试是兼顾教学要求与工程实现的实用型资源。1. 医疗问答为什么需要知识图谱而不是关键词匹配医疗领域的问答不像搜“感冒吃什么药”那么直接。同一个症状背后可能是多种疾病同一个药也可能对应多个科室。关键词检索能返回一堆相关页面却给不出“症状→疾病→用药→禁忌”这条完整链路。知识图谱把实体和关系建模成图天然适合这类多跳推理。这套基于 Python 的医疗知识图谱问答系统把数据、导入脚本、问答主链路和 Web 页面打包在一个项目里下载后按照文档配置好 Neo4j 和 Python 环境就能直接运行。对正在做期末大作业、或者刚入门知识图谱构建与问答的同学来说是一个可以完整复现的样例。2. 图数据建模与导入症状、疾病、药品如何在 Neo4j 里关联2.1 实体类型与关系设计医疗知识图谱问答系统的核心是“图”而不是“表”。关系型数据库更适合精确查询图数据库则更适合沿着关系做多跳遍历。项目在 Neo4j 中把疾病、症状、药品、检查、科室这五类实体建模为节点实体之间的语义关系建模为边。设计图模型时先问自己一个问题用户可能问哪些问句答案决定了你需要哪些实体和关系。项目里的问句涵盖了“高血压有什么症状”“头痛可能是什么病”“这个药治什么”等常见类型所以实体和关系至少要支撑这些路径。节点类型关键字段说明Diseasename, alias, department疾病名称、别名、所属科室Symptomname, alias症状名称、别名Drugname, alias药品名称、别名Checkname, alias检查项目名称Departmentname科室名称关系设计的原则是“一个关系只表达一个语义”避免把多个含义塞进同一条边。项目里常用到的关系如下表。关系起点 → 终点语义HAS_SYMPTOMDisease → Symptom疾病具有该症状DRUG_FORDrug → Disease药品用于治疗该疾病CHECK_FORCheck → Disease检查用于辅助诊断该疾病BELONG_TODisease → Department疾病归属于某个科室实际项目中我会在关系上补充一个 weight 属性用来表达“这个症状在诊断里有多重要”。权重值是浮点数后续做答案排序时可以直接按关系属性排序而不需要再回到节点上找额外字段。节点设计则要注意一个细节不要把所有信息都塞进 name 字段。比如“高血压”的别名“血压高”“essential hypertension”应当放到 alias 字段实体识别阶段再统一做别名映射。2.2 CSV 数据准备与 Neo4j 导入原始数据整理成 CSV 后放入 Neo4j 的 import 目录是知识图谱构建最常用的方式。相比在 Python 里逐条调用 py2neo 创建节点LOAD CSV的导入速度更快而且重复执行时可以通过MERGE避免产生重复实体。数据文件至少要有三个疾病表、症状表、疾病-症状关系表。字段保持扁平的列结构列名不要带空格首行不要有 BOM。导入节点的 Cypher 示例LOAD CSV WITH HEADERS FROM file:///medical/disease.csv AS row MERGE (d:Disease {name: row.name}) ON CREATE SET d.alias row.alias, d.department row.department;参数说明WITH HEADERS表示把第一行当作字段名MERGE以 name 作为唯一键重复执行时不会创建两个同名节点ON CREATE SET只在节点首次创建时写入属性避免覆盖已有数据。如果你希望每次导入都覆盖旧数据可以先执行MATCH (n:Disease) DETACH DELETE n清空该类型节点。导入关系的 Cypher 示例LOAD CSV WITH HEADERS FROM file:///medical/disease_symptom.csv AS row MATCH (d:Disease {name: row.disease}) MATCH (s:Symptom {name: row.symptom}) MERGE (d)-[r:HAS_SYMPTOM {weight: toFloat(row.weight)}]-(s);这段代码里的MATCH先把关系两端的节点找出来MERGE负责创建关系。toFloat(row.weight)把 CSV 里读到的字符串转成浮点数方便后续排序。这里最容易翻车的地方是 CSV 里某一行引用了不存在的节点名MATCH匹配不到节点时整行会被跳过但系统不会给出明显报错。数据量不大时可以在导入前用脚本统计 CSV 里的实体名是否都存在于节点表中。2.3 验证导入结果导入完成后不要急着写问答逻辑先在 Neo4j Browser 里跑几条查询验证图结构。比如查“高血压”的所有症状MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE d.name 高血压 RETURN collect(s.name) AS symptoms;collect把结果聚合成一个列表返回给 Python 端时可以直接拼成“高血压的症状包括……”这样的句子。验证的重点是关系方向(d)-[:HAS_SYMPTOM]-(s)与(s)-[:HAS_SYMPTOM]-(d)是两回事写反了查询结果就是空的。如果发现某些疾病没有任何关系说明 CSV 里对应行没有匹配成功回到数据源检查实体名是否完全一致。3. 问答链路实现实体识别、意图分类与 Cypher 模板映射3.1 实体识别自定义词典与最大匹配用户问句进来后第一步是从自然语言里捞出图谱中存在的实体。项目里最实用的做法是 jieba 自定义词典加精确匹配。把图谱里所有实体的 name 和 alias 写入自定义词典再做一次匹配能够覆盖绝大多数口语化表达。jieba 默认分词对医疗术语支持有限比如“高血压”“2型糖尿病”这类词可能被切碎所以词典文件里要给出词频和词性。import jieba jieba.load_userdict(data/medical_dict.txt) def extract_entities(question): entities [] for word in jieba.lcut(question): if word in entity_set: entities.append(word) return entities逻辑说明entity_set是启动时从 Neo4j 查询所有实体名构建的集合也可以直接由 CSV 生成。识别结果会包含别名后续查询前需要把别名替换成标准名称。这里需要注意单实体识别不是句子的全部语义比如“高血压吃什么药”只需要识别出“高血压”而“咳嗽和头痛是什么病”要同时识别“咳嗽”“头痛”两个症状两个实体都要列表里保留供查询模板拼接。3.2 意图识别规则关键词与模板匹配实体识别负责“找到图谱里的东西”意图识别负责“判断用户到底想问什么”。项目是单轮问答用规则关键词就足够不需要训练分类模型。意图类型要根据图模型和可回答的问题范围设计比如症状查疾病、疾病查症状、疾病查用药、药品查适应证、疾病查科室五类。常见做法是维护意图关键词表匹配优先级高的意图在前。意图标识触发关键词示例查询动作disease_by_symptom什么病、怎么回事由症状返回疾病集合symptom_by_disease症状、表现、有哪些由疾病返回症状集合drug_by_disease吃什么药、用什么药由疾病返回药品集合disease_by_drug治什么、能治由药品返回适应证疾病department_by_disease挂什么科、去哪个科由疾病返回科室实现时我一般把关键词表和逻辑拆开关键词放在一个字典里而不是写成一长串if else。这样新增一种问法不需要改主逻辑只改配置。还有一个容易被忽视的点用户问题里可能同时出现多个意图关键词比如“高血压吃什么药挂什么科”这时候要定义优先级或者直接判定为复合意图并提示用户一次问一个问题避免 Cypher 查询结果变得不可控。3.3 查询模板设计把问题变成一条 Cypher意图识别完成后系统知道该查哪个图路径接下来要做的是把实体和模板拼成一条可执行的 Cypher 查询。项目里维护一个模板字典比在业务代码里到处拼字符串更清晰。模板里使用参数占位符避免 f-string 直接把用户输入嵌进查询语句。CQL_TEMPLATES { symptom_by_disease: ( MATCH (d:Disease {name: $entity})-[:HAS_SYMPTOM]-(s:Symptom) RETURN collect(s.name) AS answer ), drug_by_disease: ( MATCH (d:Disease {name: $entity})-[:DRUG_FOR]-(dr:Drug) RETURN collect(dr.name) AS answer ), department_by_disease: ( MATCH (d:Disease {name: $entity})-[:BELONG_TO]-(dep:Department) RETURN dep.name AS answer ), }参数说明$entity是参数化查询的占位符实际执行时通过 py2neo 的run(cql, entityentity)传入。用参数化而不是字符串拼接一方面避免特殊字符破坏 Cypher 语法另一方面形成好习惯在以后接 Web 接口时不必担心查询注入问题。模板设计要遵循一个原则每条模板只返回一个 answer 字段后端拿到结果后统一转为自然语言而不是让每条模板返回不同格式。3.4 兜底逻辑图谱覆盖不到的边界处理再完整的图谱也覆盖不了所有问法。项目里兜底分两层处理意图识别不到时返回“这个问题我还不会回答换个问法试试”实体识别不到时则尝试给出相近实体。相近实体可以用编辑距离计算这里使用 python-Levenshtein 库阈值设定在 0.7 左右低于阈值建议用户检查症状名称是否正确。import Levenshtein def fuzzy_hint(user_entity, all_entities): candidates [] for e in all_entities: score Levenshtein.ratio(user_entity, e) if score 0.7: candidates.append((e, score)) return sorted(candidates, keylambda x: -x[1])[:3]这段代码里Levenshtein.ratio返回两个字符串的相似度越接近 1 越相似。兜底逻辑的价值不只是“不报错”而是把失败变成下一次可改进的路径。实际调试时我会把没有被兜底命中的问题记录下来定期补充词典和模板。项目里的实体识别、意图分类和查询模板三层是解耦的每一层的错误可以用日志单独追踪。4. 把问答系统跑成 Web 服务Flask 接口与运行文档4.1 项目结构与依赖环境问答链路跑通后还需要一个让用户能访问的入口。项目采用 Flask 提供 HTTP 接口前端是单个 HTML 页面。运行前需要安装的依赖主要是 flask、py2neo、jieba、python-Levenshtein。建议使用 Python 3.8 到 3.10 的版本py2neo 与 Neo4j 有版本兼容要求Neo4j 4.4 配合 py2neo 2021.2.3 是比较省事的组合。如果使用更高版本的 Neo4j连接认证方式要确认是否兼容。项目目录结构大致如下运行时只需要启动后端服务数据已经预先导入 Neo4j。在下载的资源包里一般会自带 requirements.txt执行如下命令安装pip install -r requirements.txt安装完成后确认 Neo4j 服务已经启动。项目里的配置类会读取 Neo4j 的地址、用户名和密码默认是bolt://127.0.0.1:7687用户名neo4j。如果你改过密码只需要修改配置文件里的对应字段不用改动业务代码。4.2 后端接口设计与请求参数Web 服务的核心接口是/chat接收前端 POST 过来的 JSON 数据。接口只处理一个字段question返回结构固定为code和answer。这样设计的好处是前端逻辑简单后续如果要扩展历史记录、用户 ID 等字段也不用改接口结构。from flask import Flask, request, jsonify app Flask(__name__) qa_pipeline QAPipeline() app.route(/chat, methods[POST]) def chat(): payload request.get_json() question payload.get(question, ).strip() if not question: return jsonify({code: 1, msg: question is empty}) answer qa_pipeline.run(question) return jsonify({code: 0, answer: answer})参数说明get_json解析请求体里的 JSONget(question, )在字段缺失时不会抛异常。answer是问答链路返回的自然语言文本。需要关注的点是qa_pipeline在路由外实例化这样加载一次 jieba 词典和实体集合就能服务所有请求。如果把它放进chat函数里每次请求都重新加载词典接口延迟会明显上升。4.3 前端页面与联调方法前端页面不需要复杂框架一个文本框加一个聊天记录区足够。页面通过原生 Ajax 把问题发给后端收到响应后把答案追加到对话列表。运行项目时先启动 Neo4j再运行python app.py看到 Flask 默认的启动日志后用浏览器打开http://127.0.0.1:5000即可测试。接口联调时直接用 curl 比浏览器更高效。命令示例如下curl -X POST http://127.0.0.1:5000/chat \ -H Content-Type: application/json \ -d {question: 高血压有什么症状}返回结果通常是这样{answer: 高血压的症状包括头晕、头痛、乏力、心悸等。, code: 0}联调时建议先测三类用例正常问句、空问题、图谱覆盖不到的问法。空问题返回 code 1覆盖不到返回兜底话术。前端只负责渲染不参与判断逻辑。这样后续替换前端框架或者接入微信客服接口时后端代码完全不用动。5. 评测问答质量与两个关键排错技巧5.1 构造测试集并统计无结果率问答系统不能靠“看起来答对了”评价。常见做法是准备 100 条测试问句为每条人工标注标准答案跑完全部样本后统计准确率和无结果率。把失败样本按“实体识别错误”“意图识别错误”“图谱缺数据”三类归类。实际项目中意图识别错误最隐蔽因为问句里包含关键词但语义其实是另一个方向比如“这个药高血压能用吗”触发了疾病查用药模板实际是禁忌查询。5.2 坑一别名缺失导致实体匹配失败用户口语里说“血压高”图谱里存的是“高血压”前缀匹配和分词都救不回来。解决办法是在数据导入阶段把别名统一写入 alias 字段实体识别后先做别名替换再进入查询模板。可以用一条查询检查别名覆盖率MATCH (d:Disease) WHERE d.alias IS NULL OR d.alias RETURN d.name;返回结果就是所有缺少别名的疾病节点补完再重新导入。这一步看起来简单但直接影响问答系统的可用性。5.3 坑二数据量增大后查询超时知识图谱数据量增加后不带索引的MATCH (d:Disease {name: $entity})会做全表扫描。给节点唯一键建索引能显著减少查询的展开节点数量创建语句CREATE INDEX disease_name_index FOR (d:Disease) ON (d.name); CREATE INDEX symptom_name_index FOR (s:Symptom) ON (s.name);索引建好后先用EXPLAIN查看查询计划确认索引被命中。如果查询计划里展开的节点数没有下降检查 WHERE 条件是不是用了带函数的表达式比如toLower(d.name)这类写法会导致索引失效。本文还有配套的精品资源点击获取