最近在折腾一个开源项目,名为QASystemOnMedicalKG-master,简单说就是一套基于医疗知识图谱的问答系统。它做的事,是把医学领域的知识整理成“实体-关系-实体”的三元组结构(比如 “糖尿病 - 并发症 - 视网膜病变”),然后让用户用自然语言提问,比如“糖尿病的早期症状有哪些?”系统通过实体识别和关系匹配,到图数据库里把答案捞出来,再返回给用户。这个方向我关注挺久了,医疗信息检索一直有个痛点:传统的关键词搜索只能返回一堆文档链接,用户得自己翻找答案,而知识图谱问答能直接给出精准答案,体验完全不同。这篇记录适合三种人看:一是正在做知识图谱项目的开发者,想参考一套完整的落地流程;二是对智能问诊、辅助诊断感兴趣的产品经理;三是想找个实战项目入门NLP和图数据库的初学者。我会把这套系统的架构、核心模块、实操踩坑过程全部梳理出来,尽量把关键细节讲透。
1. 项目整体认知:这个系统到底怎么工作
1.1 医疗知识图谱的核心价值
医疗知识图谱的本质,是把散落在医学百科、临床指南、论文中的信息,抽成结构化数据。为什么要这么做?因为医学知识的关系极其复杂,一个疾病往往关联多个症状、药物、检查项目和并发症,这些关系用传统关系型数据库表达会很痛苦,每次查询都要写多表关联SQL,而且扩展性差。图数据库用节点和边直接建模,天然贴合这种网状结构。
我一开始也犹豫过:直接用Elasticsearch全文搜索不就行了?干吗非要图数据库?实际对比下来就明白了。知识图谱能回答“A导致B,B又引发C”这种多跳问题,比如用户问“高血压患者长期吃某类药物可能出现什么并发症”,搜索引擎只能匹配关键词,图谱问答却可以沿着“高血压 -> 用药 -> 药物副作用 -> 并发症”的路径逐层推理。这套系统选择Neo4j作为存储引擎,看中的就是它的遍历能力和Cypher查询语言,几行代码就能表达复杂关系。
另一个价值在于数据复用。知识图谱构建好之后,不只是问答系统可以用,临床决策支持、科研检索、药物相互作用提醒都能复用同一份数据。我见过不少团队做了图谱只为了展示“看我们有个图”,但真正把图谱用起来的第一站,就是问答系统,因为它最贴近用户,也最容易验证图谱质量。
1.2 问答系统的整体架构
探索这个系统时,我习惯先从整体架构入手,避免一头扎进代码细节出不来。这套QA系统的流程可以概括为四步:问题理解、实体识别、图谱查询、答案生成。
具体拆开看:
- 用户输入自然语言问题,系统先做预处理,包括分词、去停用词、提取关键词。
- 通过命名实体识别(NER)定位问题中的疾病、症状、药物、检查项目等实体。比如“感冒有哪些症状”里,“感冒”就是疾病实体。
- 根据识别出的实体和问题中的疑问词,匹配预设的问答模板,确定用户的意图是问“病因”“症状”“饮食”还是“药品”。
- 把模板映射成Cypher查询语句,到Neo4j图数据库里执行,拿到结果后经过简单格式化返回。
这套流程比纯端到端的深度问答模型更好落地。原因很实在:医学领域标注数据贵,医疗实体识别模型需要大量标注语料,而基于规则和模板的方法在垂直领域里完全够用,准确率还容易控制。系统作者把模板扩展做得比较轻量,新增一种问题类型只需加模板和对应的Cypher语句,这对我们二次开发非常友好。
2. 核心技术模块拆解
2.1 知识图谱构建:从数据到三元组
整个系统里投入工作量最大的部分,就是图谱构建。我看了下项目的数据来源和构建脚本,思路很清晰:先准备医学词典和语料,然后抽取实体和关系,最后写入Neo4j。
医学实体识别是第一步。系统没有直接用深度学习模型,而是用基于词典的前向最大匹配算法来识别疾病、症状等实体。这种方案的优势是快、可解释、不依赖GPU。但缺点是词典覆盖直接决定了识别上限。项目自带了一个医学实体词典,大概包含几万个实体词条,覆盖常见内科、外科疾病和症状。我在探索时发现,词典质量是整条链路的天花板,如果实体覆盖率不够,后面的问答效果会断崖式下跌。
关系抽取则采用半自动方式。项目先定义了一组核心关系类型:疾病_症状、疾病_并发症、疾病_常用药物、疾病_检查项目、疾病_所属科室、疾病_饮食建议等。然后从结构化数据源里直接解析字段,比如从医学百科的“疾病信息卡”里抓取“症状”“常用药物”等字段,再映射成图谱关系。
我在实际复现时,建议你先把数据清洗干净。原项目的数据已经整理过一轮,但仍有重复和噪声。我写了个小脚本做实体对齐,把“慢性阻塞性肺疾病”和“慢阻肺”合并成同一实体。这一步很关键,否则图数据库里会出现两个看似独立实则同一个疾病节点,问答时会返回重复结果。
图谱最终以三元组形式导入Neo4j。我贴一部分导入的关键配置供参考:
from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) # 创建疾病节点 disease_node = Node("Disease", name="糖尿病") graph.create(disease_node) # 创建症状节点 symptom_node = Node("Symptom", name="多饮") graph.create(symptom_node) # 建立“临床表现”关系 rel = Relationship(disease_node, "临床表现", symptom_node) graph.create(rel)这个系统里用的是py2neo库,版本兼容很挑剔,新版对Cypher参数传递有变化,我后面在问题排查部分会专门讲。
2.2 命名实体识别与关系抽取的取舍
很多初学者一听到“命名实体识别”,马上想到Bert-BiLSTM-CRF。但在这个项目里,实际用的方案是更朴素的词典匹配加规则修正。为什么不追热点?我分析下来有三个原因:一是医学实体普遍较长,比如“上呼吸道感染”,BERT类模型如果训练语料不足,反而容易把实体切碎;二是词典匹配能保证实体边界稳定,尤其是在用户口语化提问时,比如“我最近老咳嗽,是不是肺炎”,用词典+规则能稳定抽出“咳嗽”和“肺炎”;三是效果好维护,发现漏识别时只需往词典加词,而调模型则要重新标注和训练。
但词典匹配也有硬伤——歧义。比如“感冒”既是疾病名,也可能是口语里的“着凉”。系统采用双向最大匹配加自定义词典权重来缓解:优先匹配多字词,匹配到疾病词库后再匹配症状词库。实际测试下来,对医疗领域规范提问的识别准确率能到90%左右,但问法太口语化时(比如“嗓子疼怎么办”),如果不把“嗓子疼”加入症状词典,就识别不出来。
关系抽取部分项目使用了基于依存句法的辅助规则。举个例子,“糖尿病患者会出现多饮”这句话,分词后“患者”和“出现”形成主谓关系,“出现”和“多饮”形成动宾关系,系统利用预先定义的依存关系规则抽取出“糖尿病 -> 临床表现 -> 多饮”三元组。这种半自动抽取在纯规则和全监督模型之间找到了平衡点。
2.3 问答匹配与答案生成的设计思路
问答模块是这个系统的“最后一公里”,也是体现设计精细度的地方。项目没有用生成式大模型,而是基于意图分类加模板匹配。核心设计分两层:
第一层是意图分类。系统定义了几种常见医疗意图:寻找病因、寻找症状、寻找治疗方案、寻找用药建议、寻找检查项目、寻找科室。分类依据是问题里的疑问词和关键词。比如“原因”“引起”“导致”指向病因;“什么药”“用什么”指向用药;“挂什么科”“哪个科室”指向科室。
第二层是模板到Cypher的映射。每个意图对应多类问题模板,每个模板又绑定一组Cypher查询模板。拿“疾病症状查询”举例:
MATCH (d:Disease {name: $disease_name})-[:临床表现]->(s:Symptom) RETURN s.name模板规定了参数绑定方式:识别到的疾病实体作为$disease_name,查询结果就是症状列表。这种设计的好处是答案可控、可解释,不会出现大模型那种一本正经胡说八道的情况。
我在探索中发现,问答效果差往往不是模板不够,而是前面的实体识别错了。比如“阿司匹林能治头痛吗”,系统先识别到药物实体“阿司匹林”和症状实体“头痛”,但意图模板如果只设计了“疾病-药物”方向,没有反向的“药物-适应症”查询,就会答不出来。所以模板设计时不能只考虑正向,要覆盖常见的反向问法。这也是我强烈建议二次开发者扩展的第一块。
3. 实操记录:从零跑通项目的完整过程
3.1 环境准备与依赖安装
我是在一台8GB内存的Ubuntu服务器上跑的,系统整体对资源要求不高,主要消耗在Neo4j和Python服务上。我用的版本组合是:Python 3.8 + Neo4j Community 4.4 + py2neo 4.3.2。这里必须强调版本匹配问题,原项目是在Python 3.6时代开发的,新版py2neo的API变动不小,照老代码直接跑必报错。
我的安装过程记录如下:
# 安装neo4j(用docker最省事) docker run -d --name neo4j -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTH=neo4j/test_password \ neo4j:4.4-community # 安装Python依赖 pip install py2neo==4.3.2 flask==1.1.4 jieba==0.42.1有几个依赖坑要提醒:
- 原项目可能要求
tensorflow,其实如果你不打算跑深度模型,纯词典匹配模式完全不需要,千万别装CPU版TF白白浪费几GB空间。 flask版本不宜太高,新版对JSON和请求解析改动较大,项目老代码容易不兼容。- 如果使用Python 3.9以上,要注意
jieba初始化时的缓存目录权限问题,加上--user安装通常能解决。
Neo4j启动后,访问http://localhost:7474,用浏览器登录,初始密码改成自己的。这一步不做的话,Python连接时经常因为认证失败报错。
3.2 数据准备与图谱构建
数据是整个系统的燃料。项目自带了一份医疗知识数据,但我建议你先看下目录结构再决定是否直接用。我把数据文件归类后发现主要包含三类:实体词典、关系数据、问答语料。
实体词典是纯文本文件,每行一个词,例如:
糖尿病 慢性阻塞性肺疾病 高血压 多饮 多尿 视网膜病变关系数据是CSV或JSON格式,每个条目是“头实体-关系-尾实体”的映射。系统自带数据已经覆盖了几百种疾病,但偏科明显,有些疾病只覆盖了“症状”一个维度,缺少“用药”和“饮食”信息。我做了一轮扩充,从公开的医疗百科爬取结构化信息,然后清洗对齐,耗时大约两个晚上。扩充后的图谱节点数从原来的几千上升到两万多,关系数翻了三倍。
构建图谱时,我专门写了一个去重校验函数:导入前先查询Neo4j里是否已存在同名节点,存在就跳过,不存在则创建。否则重复导入会导致节点膨胀,问答结果里出现重复答案。
完整导入流程如下:
- 读取实体词典,生成疾病和症状节点。
- 读取关系数据,建立节点间的有向关系。
- 检查所有节点是否有孤立节点(没有关系连接的,多半是数据脏或词典误识别),输出到日志。
- 对孤儿节点做人工复核,是脏数据的删除,是漏关系的补充。
这个过程里我踩的最大的坑是Neo4j连接超时。数据量大时逐条创建节点效率极低,一天都导不完。后来我改成批量方式:先在Python里把节点和关系整理成列表,用graph.merge批量创建,或者直接用Cypher的LOAD CSV导入。两个方案对比下来,LOAD CSV明显更稳更块,尤其适合已经整理好的结构化文件。
3.3 启动问答服务与测试
服务本身是个Flask应用,启动入口非常简单:
python qa_system.py启动成功后,服务默认跑在http://localhost:5000。我习惯用 curl 做快速验证,避免被前端页面干扰:
curl -X POST http://localhost:5000/qa \ -H "Content-Type: application/json" \ -d '{"question": "糖尿病的症状有哪些?"}'返回结果大概是这样的JSON:
{ "question": "糖尿病的症状有哪些?", "answer": "多饮、多尿、体重减少、视力模糊", "entities": ["糖尿病"] }这个结果说明整条链路已经打通:问题分词识别出“糖尿病”,意图分类归到“症状查询”,Cypher查询返回了症状列表。如果返回为空,多半是实体没识别出来或者图谱里没有对应关系。
我建议你准备一个测试用例表,覆盖各种意图,跑一遍看看哪些通过哪些失败。我测试时用了大概20个问题,覆盖症状、用药、科室、饮食、检查五大方向。初步跑下来,规范提问的准确率不错,但口语化提问比如“老犯困,是血糖高吗”基本识别不了,因为“犯困”没进词典,“血糖高”也不是正规的症状实体。这个问题后面调优时再说。
4. 常见问题与排查技巧实录
4.1 环境与依赖问题
这类问题最多,我直接列一个速查表:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
连接Neo4j报Authorization Required | 密码没改或认证参数错误 | 检查auth=("neo4j", "密码"),确保密码正确 |
py2neo报AttributeError: 'Graph' object has no attribute 'find' | py2neo版本过高 | 降到4.3.2 |
Flask启动报ImportError: No module named flask_cors | 缺少依赖 | pip install flask-cors |
| 中文分词结果混乱 | jieba没加载自定义词典 | 初始化时jieba.initialize(),并加载项目词典 |
| Neo4j启动后端口被占 | 8080/7474被其他容器占用 | 更换映射端口 |
我特别想强调:不要盲目使用项目环境配置。老项目依赖的某些库已经严重过时,甚至存在安全隐患。我在搭建时直接摒弃了原版的requirements,按自己的版本组合来。探索项目不是复刻项目,用“最小可用”原则,保留核心功能就好。
4.2 数据与图谱导入问题
图谱导入阶段,最容易遇到的问题是“数量对了但关系不对”。比如疾病节点和症状节点都创建了,但查询时返回空,我遇到过的情况是:关系数据里的尾部实体名和节点名称不一致,因为一个叫“視力模糊”繁体,一个叫“视力模糊”简体,在浏览器里看不出来,Cypher查询却精确匹配失败。
解决办法是统一规范文本:大小写统一、繁简转换、去掉全角空格。我写了个预处理函数,对所有实体名做了标准化再入库,从此这类问题再没出现过。
另外一个常见问题是Neo4j导入时Relationship类型名大小写敏感。如果你在关系数据里写“属于_科室”,而查询时写的“属于_科室”,看似一样其实不一样。建议项目里所有关系类型用统一风格命名,我这边统一用了“实体类型_关系域”的格式,比如疾病_常见症状、疾病_常用药物,这样查询时不容易出错。
4.3 问答效果调优的独家技巧
效果调优是探索这个项目最有价值的部分。我分享几个实战经验,这些在原项目文档里不可能写。
第一,实体词典不能只在项目里加,要让jieba真正用上。系统里的实体识别虽然是自写的最大匹配,但分词阶段依赖jieba。如果jieba没加载项目词典,分词就会把“慢性阻塞性肺疾病”切成“慢性/阻塞/性/肺/疾病”,实体匹配直接失败。解决办法是在项目初始化代码里加入:
import jieba jieba.load_userdict("medical_dict.txt")第二,问答模板要覆盖“否定问法”和“反问法”。比如“心脏病不能吃什么药?”这种问题,如果模板里没有“禁忌”这个意图,系统会错误地匹配为“常见用药”,给出一堆错误推荐。我在模板系统里新增了“禁忌”意图,对应Cypher查询里用(禁忌)关系而不是(常用药物)。这类问题在真实的患者提问中占比不低,请务必处理。
第三,多实体问题要设置优先级。用户可能会问“糖尿病患者出现视网膜病变该怎么治”,这句话里有两个疾病实体“糖尿病”和“视网膜病变”,系统如果随机选一个实体去查询,答案就不可控。我建议设定规则:优先匹配“疾病”实体,如果是并发症问题则识别两个实体之间的路径。这个虽然需要更复杂的模板,但值得做。
第四,验证图谱数据质量的一个简单方法:随机抽几个疾病节点,查它们的关联关系数。如果绝大多数疾病的关联关系数少于3条,说明图谱太稀疏,问答系统的召回率会很低。这时候不是急着调模板,而是先补数据。
5. 一些个人经验和本项目之后还能怎么玩
先讲数据扩充的体会。这个项目的医疗知识图谱基于公开语料,实体覆盖率有限,尤其对中医类、罕见病类支持很弱。如果你要做的是面向大众的科普问答,现有规模勉强能用;如果想做辅助决策,数据量至少要翻十倍,而且必须引入临床指南和药品说明书这类更权威的数据源,错一个关系就是医疗事故级别的风险。
关于系统架构,我的判断是:模板匹配方案在封闭域下依然是最稳的选择。不要因为大模型火就什么都拿去套,大模型在医疗领域有严重幻觉问题,而且推理成本高。这套系统的价值在于“可控”,你可以完全追踪到每个答案的图谱路径。如果想把大模型嵌入进来,我建议只做“灵活问法转标准问题”这一步,比如用大模型把“我老咳嗽,该去哪个科室”改写为“咳嗽挂什么科”,然后把规则问法交给模板系统,这既能改善体验又能保证可解释性。
最后说一个值得扩展的方向:把对话升级成多轮。当前系统是单轮,用户问“糖尿病有什么症状”之后追问“能治好吗”,系统会完全当作新问题处理,丢失了上下文。我在自己的分支里加了一个简单的上下文槽位,记录上一轮提到的疾病实体,当下一轮没有识别到新实体时,自动复用上一轮的实体。这个改动不大,但体验提升非常明显。
还有一个更实用的扩展:接入语音输入。医疗问答的场景很多在移动端,老人不太会打字。语音识别转文字后,再交给现在的问答管线,基本不用改下游代码。我实测过用云端语音接口,识别中文医学名词准确率还可以,只是一些生僻药名会被转成同音错别字,需要在接进来之前加一步“医学名词纠错”。
整个项目探索下来,我的整体感受是:医疗知识图谱问答不是模型问题,而是数据工程问题。图谱的质量、实体词典的覆盖、模板的完善程度,每一项都远比模型选型影响大。如果你也想跑通甚至改进这个项目,建议先花60%时间搞数据,20%时间搞图谱构建,最后20%去做问答层调优,这个比例大概率不会错。