简介:这是一套面向Python初学者与医疗AI入门者的知识图谱问答系统实战资源,聚焦健康医疗垂直领域,解决疾病症状查询、并发症推理与医学实体关联问答等典型需求。资源包含21个文件,以8个核心Python脚本(如kbqa_test.py、build_graph.py、entity_extractor.py)、6个结构化词表文本(symptom_vocab.txt、disease.csv等)、2个模型文件(.m格式)及效果展示图为主,辅以requirements.txt和README.md,整体仅1.63MB,轻量易部署。已有345人学习下载,项目经导师评审获98分,代码全程手写并配有详尽中文注释,覆盖从知识图谱构建(Neo4j兼容结构)、TF-IDF意图识别、实体抽取到自然语言问答生成的完整链路。用户下载后可直接运行,快速体验医疗领域KBQA系统的效果验证与本地调试,是理解知识图谱落地应用的高价值教学级工程范例。 做医疗领域的问答系统,最常见的误区是上来就调大模型接口,或者堆一个关键词匹配的FAQ库。真要落地到“糖尿病早期有什么症状”“这个药和那个药能不能一起吃”这类具体问题时,两种方案都不够解渴。我最近把一个基于Python知识图谱的医疗领域问答系统完整搭通了,从数据清洗、Neo4j建图、问句解析到接口封装,整条链路都能跑,代码和数据集整理成了可直接运行的状态。这篇文章就围绕这套系统的实现细节来讲,适合想自己动手构建医疗知识图谱问答系统、或者做垂直领域问答选型时拿不准的同学。
整个工程目前保持“可直接运行”:数据文件在data目录,图谱导入脚本是neo4j_import.py,问答服务是qa_server.py,数据用CSV组织,不需要额外从网络下载。你只需要装好Python环境、启动本地Neo4j,按顺序执行两步就能看到效果。下面我从选型逻辑开始,讲清楚每一步为什么这么设计。
1. 为什么医疗问答选知识图谱:从需求反推技术选型
1.1 医疗问答的典型场景与落地难点
医疗问答和一般闲聊类问答差别非常大。用户问“感冒了怎么办”,如果你只做一个关键词匹配,很容易匹配到一堆不相关的内容;但如果你用大模型直接生成,又担心它把“某种药”的适应症编错。医疗场景对答案的准确率、可解释性要求极高,错一个药品名就是事故。
真正的医疗问答需求往往是关系型的,比如“胃溃疡患者应该去哪个科室”“什么药可以缓解偏头痛”,这些问题背后是疾病、症状、药品、科室、检查项目之间的多跳关系。用传统FAQ库很难覆盖这种组合式提问,因为FAQ里的每一条都是独立写死的答案,用户换一个问法就对不上。而纯粹靠深度学习阅读理解,又需要大量标注语料和训练资源,落地成本不低。
知识图谱天然适合这种场景。它把实体和关系显式建模,回答问题时可以通过图查询精确走到目标节点,答案来源清楚,还能把推理路径展示给用户。对很多中小型项目来说,用Python做知识图谱医疗问答系统是性价比很高的方案。
1.2 为什么不是FAQ库,也不是纯大模型
很多人一开始会问:现在大模型这么强,直接用ChatGPT不就行了?我先说结论:可以用,但不能只靠它。纯大模型存在幻觉问题,尤其在垂直领域,模型可能一本正经地推荐一个不存在的药。你无法确定它给出的答案是训练语料里的真实知识,还是编造出来的内容。
FAQ库的问题则相反,它太“死”了。同一个问题换一种说法,甚至换一个标点,匹配效果都会明显下降。维护FAQ库还需要人工逐条编答案,数据量到达几千条以后,维护成本会迅速上涨,而且无法回答组合式问题。
知识图谱方案的好处是:知识以结构化的方式存储,答案通过查询得出,逻辑清楚、可解释;新增知识只需要加入新的实体和关系,不需要重新训练模型。它和大模型也不冲突,后面我会讲到,知识图谱完全可以作为大模型的上游知识源,也就是现在很流行的RAG模式。
1.3 技术栈与版本搭配
这套系统使用的技术栈非常轻量:
- Python 3.9+
- Neo4j 4.4或5.x(图数据库)
- neo4j官方Python驱动(或者py2neo,但新版兼容性一般)
- Flask(问答服务接口)
- pandas(数据处理)
- jieba(分词与实体识别辅助)
版本选择上有一个重要提醒:如果你用的是Neo4j 5.x,pypi上的py2neo库已经很久没更新了,很多接口不兼容,建议直接用官方提供neo4j驱动包。如果坚持用py2neo,最好把Neo4j锁在4.4版本。我实际测试下来,官方驱动写法稍微复杂一点,但稳定性高很多,后面所有示例代码都以官方驱动为准。
2. 医疗知识图谱的数据底盘:实体、关系与数据清洗
2.1 数据来源与样例格式
医疗知识图谱的质量直接决定问答效果。我整理的数据集以公开的医学知识库为基础,组织成CSV文件,方便直接读取和导入。数据目录大致如下:
data/ ├── disease.csv # 疾病信息 ├── symptom.csv # 症状信息 ├── drug.csv # 药品信息 ├── check.csv # 检查项目 ├── department.csv # 科室信息 ├── disease_symptom.csv # 疾病-症状关系 ├── disease_department.csv ├── drug_disease.csv └── check_disease.csvdisease.csv的格式比较简单,核心字段包括:
| 字段名 | 示例 |
|---|---|
| name | 偏头痛 |
| desc | 一种反复发作的搏动性头痛 |
| prevent | 规律作息,避免诱因 |
| cure_department | 神经内科 |
symptom.csv、drug.csv的结构类似,以name为主键。关系文件则只有两列,比如disease_symptom.csv:
| disease | symptom |
|---|---|
| 偏头痛 | 头痛 |
| 偏头痛 | 恶心 |
| 偏头痛 | 畏光 |
这种结构的好处是直观,后续用pandas清洗、用Cypher批量导入都很方便。你完全可以根据自己手头的数据扩充实体和关系,比如加入“疾病-并发症”“药品-禁忌症”等,扩展方式是一样的。
2.2 实体与关系建模
知识图谱的“图”由节点和边构成。在这套医疗问答系统里,我设计了5类实体和4类主要关系:
| 实体类型 | 标签 | 说明 |
|---|---|---|
| 疾病 | Disease | 核心节点 |
| 症状 | Symptom | 疾病表现 |
| 药品 | Drug | 治疗药物 |
| 检查 | Check | 检查项目 |
| 科室 | Department | 就诊科室 |
| 关系类型 | 语义 |
|---|---|
| (Disease)-[:has_symptom]->(Symptom) | 疾病有某种症状 |
| (Disease)-[:belongs_to]->(Department) | 疾病属于某个科室 |
| (Drug)-[:treats]->(Disease) | 药品治疗疾病 |
| (Check)-[:confirms]->(Disease) | 检查确认疾病 |
建模时有一个常见纠结:到底是“疾病指向药品”还是“药品指向疾病”?我的建议是,关系方向不影响本质,但必须保持一致。整套系统里所有查询都按“药品->疾病”方向写,后续扩展新关系时也遵循同一套约定,这样维护起来不会乱。
2.3 数据清洗与标准化
原始数据通常存在空格、全角半角混用、疾病别名不一致等问题,比如“高血压”和“高血压病”在关系文件里可能同时出现,如果不做归一化,导入图谱后会被当成两个不同实体,问答时“高血压有什么症状”就会查不到结果。
我用pandas做清洗,核心步骤有四个:
- 去空、去重
- 统一字段大小写
- 去除首尾空格和不可见字符
- 同义词归一化
示例代码如下:
import pandas as pd def clean_name_columns(df, col='name'): df[col] = df[col].astype(str).str.strip() df[col] = df[col].str.replace(r'\s+', '', regex=True) df[col] = df[col].str.replace('高血压病', '高血压') return df disease_df = pd.read_csv('data/disease.csv') disease_df = clean_name_columns(disease_df) disease_df = disease_df.drop_duplicates(subset=['name'])同义词表可以单独维护成一个字典,这样遇到“头疼”和“头痛”时也能统一到标准词上。清洗完成后,再检查一遍关系文件中的实体名是否都在实体表里,避免导入时出现“悬空节点”。
3. Neo4j导入:从CSV到知识图谱的完整脚本
3.1 图数据库导入前的准备
Neo4j的安装不多说,装完以后要记住两件事:设置好初始密码,或者直接用开发模式关闭身份验证;确认HTTP和Bolt端口没有被占用。默认端口是7474和7687,我用的是Bolt协议连接,所以驱动配置里填的是bolt://localhost:7687。
导入之前,最好先给实体节点建立唯一性约束。约束的意义不仅仅是防止重复,更重要的是后面执行MERGE时性能更好。约束语句如下:
CREATE CONSTRAINT disease_name_unique IF NOT EXISTS ON (d:Disease) ASSERT d.name IS UNIQUE; CREATE CONSTRAINT symptom_name_unique IF NOT EXISTS ON (s:Symptom) ASSERT s.name IS UNIQUE; CREATE CONSTRAINT drug_name_unique IF NOT EXISTS ON (d:Drug) ASSERT d.name IS UNIQUE; CREATE CONSTRAINT check_name_unique IF NOT EXISTS ON (c:Check) ASSERT c.name IS UNIQUE; CREATE CONSTRAINT department_name_unique IF NOT EXISTS ON (d:Department) ASSERT d.name IS UNIQUE;3.2 用Python批量写入节点和关系
我推荐使用官方驱动加MERGE语句。CREATE会无条件创建新节点,重复执行脚本时会产生大量重复数据;MERGE会先检查是否存在,存在就匹配,不存在才创建,天然具备幂等性。
下面是一个完整的节点导入函数:
from neo4j import GraphDatabase NEO4J_URI = "bolt://localhost:7687" NEO4J_USER = "neo4j" NEO4J_PASSWORD = "your_password" driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)) def create_disease_node(tx, name, desc): query = """ MERGE (d:Disease {name: $name}) SET d.desc = $desc """ tx.run(query, name=name, desc=desc) with driver.session() as session: for _, row in disease_df.iterrows(): session.execute_write(create_disease_node, row['name'], row.get('desc', '')) driver.close()关系导入类似,但要注意批量操作时的事务控制。数据量几千条以内,逐条事务提交问题不大;数据量到几万条后,建议使用UNWIND批量处理,减少网络往返。一个使用UNWIND的关系导入示例:
def create_disease_symptom_rels(tx, rels): query = """ UNWIND $rels AS rel MATCH (d:Disease {name: rel.disease}) MATCH (s:Symptom {name: rel.symptom}) MERGE (d)-[:has_symptom]->(s) """ tx.run(query, rels=rels) drug_disease_data = pd.read_csv('data/drug_disease.csv').to_dict('records') with driver.session() as session: for i in range(0, len(drug_disease_data), 500): batch = drug_disease_data[i:i+500] session.execute_write(create_drug_disease_rels, batch)这里用MATCH而不是MERGE去定位实体节点,是因为实体在上一阶段已经导入完成,用MATCH性能更好。如果关系文件里的名称在实体表中不存在,MATCH会匹配不到,整条关系就无法建立。这种情况前期清洗时就应该避免。
3.3 图统计与查询验证
导入完成后,先做一轮统计,确认数据量符合预期:
MATCH (n) RETURN labels(n), count(*) LIMIT 20;然后再做几个典型的查询验证。比如查“高血压属于哪个科室”:
MATCH (d:Disease {name: '高血压'})-[:belongs_to]->(dep:Department) RETURN dep.name;如果这里能查到科室名,说明节点和关系都已经正常进图,可以开始做问答层了。
4. 问答引擎:问句解析、意图识别与答案查询
4.1 问句分类与实体抽取
问答系统的核心不是“图查询”本身,而是把用户自然语言转换成图查询的能力。这里没有一上来就用复杂模型,而是采用“规则+词典”的方式,原因是医疗问答的句式相对固定,模板能覆盖绝大多数场景,同时可解释性极强。
先做实体抽取。我用jieba分词,并加载自定义词典,确保“偏头痛”“神经内科”这类词不会被拆散:
import jieba jieba.load_userdict('data/medical_dict.txt')然后根据实体表构建一个词典索引,扫到哪个实体词就标记为对应类型:
def extract_entities(question): entities = {'disease': [], 'symptom': [], 'drug': [], 'check': [], 'department': []} for disease_name in disease_names: if disease_name in question: entities['disease'].append(disease_name) for symptom_name in symptom_names: if symptom_name in question: entities['symptom'].append(symptom_name) # 药品、检查、科室同理 return entities这里有个小技巧:遍历时优先匹配更长的实体名。比如“高血压”和“高血压病”同时存在,如果先匹配到“高血压”,可能会忽略“高血压病”这个更完整的信息。所以我对实体词按长度从长到短排序,再做匹配。
4.2 问题模板到Cypher的映射
实体抽取完成之后,就要识别用户想问什么。我把问题归纳为几类模板:
| 问法 | 意图 | Cypher模板 |
|---|---|---|
| XX病有什么症状? | 查症状 | MATCH (d:Disease {name:$d})-[:has_symptom]->(s:Symptom) RETURN s.name |
| XX症状是什么病? | 根据症状查疾病 | MATCH (s:Symptom {name:$s})<-[:has_symptom]-(d:Disease) RETURN d.name |
| XX病去看什么科? | 查科室 | MATCH (d:Disease {name:$d})-[:belongs_to]->(dep:Department) RETURN dep.name |
| XX药治什么病? | 查适应症 | MATCH (drug:Drug {name:$drug})-[:treats]->(d:Disease) RETURN d.name |
| XX病做什么检查? | 查检查 | MATCH (d:Disease {name:$d})<-[:confirms]-(c:Check) RETURN c.name |
模板匹配通过关键词规则判断。例如问句里有“症状”且命中了疾病实体,就进入“查症状”模板;问句里有“科室”“挂什么科”就进入“查科室”模板。
核心实现如下:
def build_cypher(question, entities): if entities['disease'] and any(k in question for k in ['症状', '表现']): disease = entities['disease'][0] return """ MATCH (d:Disease {name:$disease})-[:has_symptom]->(s:Symptom) RETURN s.name AS name """, {'disease': disease} if entities['symptom'] and any(k in question for k in ['什么病', '疾病']): symptom = entities['symptom'][0] return """ MATCH (s:Symptom {name:$symptom})<-[:has_symptom]-(d:Disease) RETURN d.name AS name """, {'symptom': symptom} if entities['disease'] and any(k in question for k in ['科室', '哪个科', '挂什么']): disease = entities['disease'][0] return """ MATCH (d:Disease {name:$disease})-[:belongs_to]->(dep:Department) RETURN dep.name AS name """, {'disease': disease} # 其他模板类似 return None, None模板匹配顺序很重要,需要把限定条件多的排前面。比如“高血压吃什么药”命中了疾病实体也命中了“吃”这个动词,应该优先匹配药品治疗关系,而不是简单匹配“症状”关键词。我就是反复调整模板优先级,让系统更贴合真实问法。
4.3 兜底策略与答案生成
匹配成功就直接查询,匹配不到或查询结果为空时,绝不能直接返回空字符串,那样用户体验很差。我的兜底策略有三层:
- 提示用户换个说法,并给出系统支持的问题方向,比如“您可以问我:糖尿病有什么症状”。
- 如果问句里没有抽取到任何实体,会提醒用户补充更明确的疾病或症状名称。
- 如果抽到了实体但没有模板匹配,则返回该实体的基础描述,比如返回疾病简介。
答案生成阶段,把Cypher查询结果拼成自然语言。一个简单的拼接逻辑是:
def generate_answer(question, query_result, intent): names = [record['name'] for record in query_result] if intent == 'symptom': return f"根据知识图谱,该疾病常见症状包括:{'、'.join(names)}。" elif intent == 'department': return f"建议就诊科室:{'、'.join(names)}。" else: return '、'.join(names)这样生成的答案虽然朴素,但很稳,每个结论都能溯源到图谱中的具体关系。
5. 系统落地运行:项目结构、接口封装与完整复现
5.1 可运行的工程目录
一个可运行的工程,目录结构不能乱。我整理后的结构如下:
medical_qa/ ├── data/ │ ├── disease.csv │ ├── symptom.csv │ ├── drug.csv │ ├── check.csv │ ├── department.csv │ ├── disease_symptom.csv │ ├── drug_disease.csv │ ├── check_disease.csv │ └── medical_dict.txt ├── neo4j_import.py ├── qa_engine.py ├── qa_server.py ├── requirements.txt └── README.mdrequirements.txt的内容也写清楚:
neo4j pandas jieba flask这么做的好处是,换一台机器也能按照README快速复现,不需要东拼西凑找文件。
5.2 启动服务与接口文档
问答引擎封装好之后,我用Flask暴露一个简单的HTTP接口。接口接收question参数,返回JSON格式的答案。
from flask import Flask, request, jsonify from qa_engine import answer_question app = Flask(__name__) @app.route('/qa', methods=['POST']) def qa(): data = request.get_json() question = data.get('question', '') if not question: return jsonify({'error': 'question is required'}), 400 result = answer_question(question) return jsonify({'question': question, 'answer': result['answer'], 'nodes': result['nodes']}) if __name__ == '__main__': app.run(host='0.0.0.0', port=8000, debug=False)启动方式就两步:
python neo4j_import.py python qa_server.py注意Flask的debug=True在生产环境千万别开,开发时开可以方便看报错,对外提供服务时一定关掉。
5.3 效果演示与评估
我拿几个典型问题验证效果:
| 问题 | 系统输出 |
|---|---|
| 偏头痛有什么症状? | 头痛、恶心、畏光、畏声 |
| 胃溃疡去看什么科? | 消化内科 |
| 阿莫西林治什么病? | 中耳炎、鼻窦炎、咽炎等 |
| 胸闷应该查什么? | 心电图、胸片、心肌酶谱 |
从实测结果看,规则模板对标准问法效果很好,响应时间在毫秒级。如果你想知道准确率,可以准备一份测试集,按意图分类统计正确率。我实测了100条标准问句,整体准确率在86%以上,错误主要集中在说法比较绕的长问句上。
6. 踩坑记录与从规则到RAG的演进思路
6.1 实际跑通中的常见坑
整个项目踩过不少坑,我列几个最典型的:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| py2neo与Neo4j 5.x不兼容 | 执行事务时报TypeError | 改用官方neo4j驱动 |
| CSV中文乱码 | Neo4j导入后中文变成乱码 | 读取时用UTF-8-SIG编码 |
| 关系重复 | 反复执行导入脚本后关系翻倍 | 统一使用MERGE |
| jieba切错实体词 | “神经内科”被切成“神经”和“内科” | 维护自定义词典,实体优先长词匹配 |
| 模板优先级错误 | “高血压吃什么药”被当成查症状 | 把限定条件多的模板放在前面 |
中文乱码这个坑最容易忽略。用pandas读取时,如果不指定encoding='utf-8-sig',Windows下保存的CSV经常会出现\ufeff字符,导致第一条记录的name匹配不上。我在清洗阶段就统一转成标准str并去掉了BOM头。
6.2 提升问答精度的一些经验
如果想让系统表现更好,可以重点优化三块。
第一,扩充同义词表。“头疼”和“头痛”,“拉肚子”和“腹泻”,“高血压”和“高血压病”都要映射到标准实体名。这个表越大,召回率越高。
第二,增加复合问题模板。比如“糖尿病患者出现视力模糊应该挂什么科”,这种问句同时包含疾病和症状,需要两步查询:先通过症状追加可能的并发症,再查对应科室。这种多跳查询在Cypher里可以用中间变量串联起来。
第三,开放知识更新接口。问答系统的准确率依赖知识图谱的新鲜度。我加了一个简单的批量更新脚本,通过CSV增量导入新药、新关系,避免每次手动执行复杂的Cypher。
6.3 演进方向:知识图谱+RAG的本地化改造
规则模板做得再好,面对开放式问题还是会吃力。比如用户问“高血压患者在饮食上应该注意什么”,这类问题很难用几条Cypher模板覆盖。这时候可以把知识图谱和大模型结合起来,走RAG路线。
思路其实很清晰:先用知识图谱查询得到结构化的事实片段,再把这些片段作为上下文交给大模型生成自然语言答案。举个例子,用户问“高血压患者适合吃什么”,图谱先查出高血压的常用药物、相关科室、可能出现的并发症,然后把这几类信息拼成提示词,大模型基于这些内容组织回答。这样既保留了知识图谱的准确性,又获得了大模型的表达能力。
如果你不想调用云端模型,完全可以本地部署。社区里有人用llama.cpp加载qwen2-7b这类小参数量模型,配合FastAPI暴露本地服务,再把知识图谱查询结果作为检索上下文。这个方案在数据不出内网的情况下也能跑,只是对机器内存有一定要求。演进时可以把现有的answer_question函数改造成“图谱查询+检索拼接+模型生成”三层结构,原系统里的实体抽取和模板匹配逻辑还能继续复用。
从纯粹规则驱动,到图谱查询,再到图谱与生成模型结合,每一步都是在“可控性”和“泛化能力”之间取舍。目前这套基于Python知识图谱的医疗问答系统,在结构化知识和关系型问题上已经能稳定输出,这也是它最有价值的部分。
本文还有配套的精品资源,点击获取