简介:基于Python的知识图谱医疗领域问答系统项目,面向Python学习者和知识图谱入门者,提供一套可完整运行的医疗领域问答系统源码与配套数据,适合作为期末大作业或课程设计参考。整个项目针对医疗知识结构化表示与自动问答实现进行了项目级整合,可帮助读者快速理解知识图谱在垂直场景中的应用。压缩包整体大小约19MB,采用zip格式,内含可直接运行的源码以及相关数据文件;由于本地编译验证通过,下载后参照文档配置好运行环境即可启动项目,免去复杂的环境调试过程。截至目前已有448人学习/下载,常用于期末大作业、毕业设计或自学实践。整体难度适中,内容经助教老师审定,不仅覆盖知识图谱构建、实体关系抽取等关键环节,也便于学习者在真实数据集上进行问答测试,是兼顾教学要求与工程实现的实用型资源。
1. 医疗问答为什么需要知识图谱而不是关键词匹配
医疗领域的问答不像搜“感冒吃什么药”那么直接。同一个症状,背后可能是多种疾病;同一个药,也可能对应多个科室。关键词检索能返回一堆相关页面,却给不出“症状→疾病→用药→禁忌”这条完整链路。知识图谱把实体和关系建模成图,天然适合这类多跳推理。这套基于 Python 的医疗知识图谱问答系统,把数据、导入脚本、问答主链路和 Web 页面打包在一个项目里,下载后按照文档配置好 Neo4j 和 Python 环境就能直接运行。对正在做期末大作业、或者刚入门知识图谱构建与问答的同学来说,是一个可以完整复现的样例。
2. 图数据建模与导入:症状、疾病、药品如何在 Neo4j 里关联
2.1 实体类型与关系设计
医疗知识图谱问答系统的核心是“图”而不是“表”。关系型数据库更适合精确查询,图数据库则更适合沿着关系做多跳遍历。项目在 Neo4j 中把疾病、症状、药品、检查、科室这五类实体建模为节点,实体之间的语义关系建模为边。设计图模型时先问自己一个问题:用户可能问哪些问句?答案决定了你需要哪些实体和关系。项目里的问句涵盖了“高血压有什么症状”“头痛可能是什么病”“这个药治什么”等常见类型,所以实体和关系至少要支撑这些路径。
| 节点类型 | 关键字段 | 说明 |
|---|---|---|
| Disease | name, alias, department | 疾病名称、别名、所属科室 |
| Symptom | name, alias | 症状名称、别名 |
| Drug | name, alias | 药品名称、别名 |
| Check | name, alias | 检查项目名称 |
| Department | name | 科室名称 |
关系设计的原则是“一个关系只表达一个语义”,避免把多个含义塞进同一条边。项目里常用到的关系如下表。
| 关系 | 起点 → 终点 | 语义 |
|---|---|---|
| HAS_SYMPTOM | Disease → Symptom | 疾病具有该症状 |
| DRUG_FOR | Drug → Disease | 药品用于治疗该疾病 |
| CHECK_FOR | Check → Disease | 检查用于辅助诊断该疾病 |
| BELONG_TO | Disease → 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, entity=entity)传入。用参数化而不是字符串拼接,一方面避免特殊字符破坏 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, key=lambda 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解析请求体里的 JSON,get("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),这类写法会导致索引失效。
本文还有配套的精品资源,点击获取