news 2026/10/9 11:06:12

医疗KBQA问答系统从零搭建:21万实体关系与朴素贝叶斯的闭环实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
医疗KBQA问答系统从零搭建:21万实体关系与朴素贝叶斯的闭环实践

简介:面向希望入门知识图谱问答的开发者,整套资源从零搭建了一个医疗领域KBQA问答系统,覆盖7类实体、约3.7万实体与21万实体关系,可完整体验意图识别、实体抽取、图谱构建和答案检索等核心流程,适合作为学习或演示项目。压缩包共15个文件,以4个Python脚本、5个词表文本和csv数据为主,并包含训练好的.m模型文件与运行效果图,整体仅3.73MB,便于下载与快速浏览。资源在意图分类上做了较完整的对比实验:手工标注210条训练数据,先用SVM和朴素贝叶斯分别建模,最终选择NB方案,测试F1值达到96.68%,能帮助读者理解小样本下分类模型的选型依据。同时,实体识别、建图、TF-IDF特征处理、问答检索等代码均有配套数据,结合可视化效果图,可运行复现或作为二次开发模板。已有788人学习下载,是了解人工智能在医疗知识图谱落地流程的实用参考。

1. 从零搭一个医疗 KBQA 问答系统:21 万实体关系背后的最小闭环

做知识图谱问答系统最容易卡住的地方不是算法,而是「数据都准备好了,却不知道第一步代码该敲在哪里」。这份资源刚好踩平了这条路:7 类实体、约 3.7 万实体节点、21 万实体关系,配合手工标注的 210 条意图训练数据,用朴素贝叶斯就把医疗问答的 KBQA 流程完整跑通了。它的价值不在于模型多先进——事实上,项目在比较后放弃了 SVM 而选用朴素贝叶斯,因为 210 条小样本下 NB 的 F1 能稳定到 96.68%。对想搞懂 KBQA 全链路、做医疗知识图谱毕业设计、或给现有 RAG 系统补一个结构化知识底座的人来说,这份资源就是一张可以直接照着画的地图。下面我按「建图谱 → 训意图 → 抽实体 → 做检索 → 避坑 → 验证」的顺序把它一层层拆开。

2. 医疗知识图谱建模:从 disease.csv 到 7 类实体、21 万关系

2.1 七类实体的划分方式与 CSV 原始数据

打开data目录,真正驱动建图的是disease.csv。这个文件不是传统的一行一个疾病,而是一行包含一个疾病的完整属性:名称、症状、并发症、常用药品、检查项目、所属科室、食物禁忌等。在build_graph.py里能看到它把一行的多个字段拆开,形成若干个实体节点。

这里的核心设计是:不去过度设计本体。七类实体分别对应疾病、症状、并发症、药品、检查项、科室、别名。关系则围绕「疾病」这个中心节点辐射出去。下图是项目运行后生成的知识图谱.png展示出的网络结构:疾病居中,周围挂满症状、并发症、药品、检查、科室节点。

建图脚本的第一步是从 CSV 读数据并做实体去重:

import pandas as pd df = pd.read_csv('data/disease.csv', encoding='utf-8') print(df.shape) # 打印行数,用于确认数据加载完整 print(df.columns[:10]) # 确认字段名,后面按列名取实体

这段代码的作用是加载原始语料。encoding='utf-8'是必须强调的——这个 CSV 如果直接用 GBK 去读,症状字段会出现乱码,进而导致后续实体抽取和建图全链路崩溃。

接着需要把 CSV 里的字段映射成实体标签。常见做法是在脚本中维护一个entity_type字典,把「症状、并发症、药品、检查项、科室、别名」映射到对应的 Neo4j Label。这样做的理由是:后边的意图识别结果要动态拼接 Cypher 查询语句,Label 名必须与代码里的意图类别一一对应,拼错一个字母查询就落空。

2.2 用 Py2neo 写节点与关系:MERGE 比 CREATE 更安全

建图部分我直接摘一段资源里的典型逻辑并加注释:

from py2neo import Graph, Node, Relationship graph = Graph('http://localhost:7474', auth=('neo4j', 'neo4j')) def add_entity(tx, entity_type, name): # MERGE 按 name + label 去重,避免重复执行脚本时产生冗余节点 cypher = ( 'MERGE (n:`' + entity_type + '` {name: $name}) ' 'RETURN n' ) tx.run(cypher, name=name)

注意MERGE和CREATE的区别:CREATE每次执行都会新建一个节点,跑两遍脚本就出现双倍实体;MERGE会先查再插,重复执行是幂等的。对于 3.7 万实体这种量级,幂等性不是可选项而是必须项——因为你一定会为了调一处 bug 把脚本重跑好几遍。

关系创建同样走 Cypher。以疾病和症状为例,实体已经在库里,直接用节点属性找出来再建关系:

cypher = ( 'MATCH (d:`疾病` {name: $disease}) ' 'MATCH (s:`症状` {name: $symptom}) ' 'MERGE (d)-[:HAS_SYMPTOM]->(s)' )

HAS_SYMPTOM这类关系类型建议统一用大写加下划线,方便后续维护。项目里类似的还有HAS_COMPLICATION、HAS_DRUG、HAS_CHECK、BELONG_TO_DEPARTMENT等等。关系名就是语义本身,这也是问答系统后来能直接拼 Cypher 的原因。

2.3 三类高频关系的实际构造顺序

实际构造时,21 万关系不是一次性全部写入的,而是分步骤进行的。我在复现时按下面顺序跑了三遍脚本:

第一遍写入疾病节点和别名节点,别名单独做「疾病 - 别名」的ALIAS关系。第二遍处理症状、并发症、药品、检查项,并从 CSV 每一行中把疾病内对应的字段拆成数组,循环拼接关系。第三遍处理科室归属。

这样分步有个实际好处:每一遍都可以用下面的统计脚本验证写入是否正常:

from py2neo import Graph graph = Graph('http://localhost:7474', auth=('neo4j', 'neo4j')) for label in ['疾病', '症状', '并发症', '药品', '检查', '科室']: cnt = graph.run('MATCH (n:`%s`) RETURN count(n)' % label).evaluate() print(label, cnt)

如果发现症状节点明显少于预期,多半是 CSV 中症状字段分号切分没处理好,或者是空值没有跳过。这个检查手段我认为是建图阶段性价比最高的一步,跑两分钟就能定位数据清洗问题。

建完图谱后,数据规模大致是这样的:疾病节点几千个,症状节点一万多,加上并发症、药品、检查项、别名、科室,合计接近 3.7 万实体,相互之间的关系总数约 21 万。用知识图谱.png里的效果图渲染时,能看到中心疾病节点的关联非常密集,这也是后续问答能覆盖「这种病有什么症状」「这个病怎么治」这类问题的数据基础。

3. 意图识别模型训练:210 条数据为什么够用

3.1 朴素贝叶斯 vs SVM:小样本下的选择逻辑

项目摘要里写得很清楚:手工标注了 210 条意图分类训练数据,与 SVM 对比后选择了朴素贝叶斯,F1 达到 96.68%。这个结论在直觉上反直觉——大家都觉得 SVM 更强。但在 210 条文本、五六个意图类别的场景下,SVM 需要调参的东西更多,核函数、惩罚系数 C、是否用 linear 核,每调一次都要在小验证集上折腾。而朴素贝叶斯几乎没有超参数,训练就是在统计条件概率,泛化在小样本上往往更稳。

意图类别一般覆盖这样几类:询问疾病症状、询问治疗方案、询问检查项目、询问所属科室、询问用药建议、询问并发症。对应到data/目录下的vocab.txt和model/下的tfidf_model.m、intent_reg_model.m,整个训练链路是这样:

第一步加载手工标注的 210 条句子,分词后做 TF-IDF 向量化。tfidf_model.m保存的就是这个向量化器,intent_reg_model.m保存的是贝叶斯分类器本身。两者用 joblib 打包在同一目录下,测试阶段通过load重新载入即可:

import joblib tfidf_model = joblib.load('model/tfidf_model.m') intent_model = joblib.load('model/intent_reg_model.m') text = '头痛应该挂哪个科' vec = tfidf_model.transform([text]) pred = intent_model.predict(vec)[0] print(pred) # 期望输出意图类别

tfidf_model.m是在 210 条语料上拟合出来的,所以它的词表非常小,但配合贝叶斯的独立性假设,反而在短句上表现稳定。注意:加载后不能对新语料做fit,只能transform,否则词表被冲刷掉。

3.2 标注数据格式与交叉验证的坑

这个项目里的 210 条数据不是常见的 JSON,而是按行写的「句子 + 制表符 + 标签」结构。训练脚本里对应的读法大概是:

with open('data/intent_train.txt', encoding='utf-8') as f: lines = f.readlines() texts, labels = [], [] for line in lines: parts = line.strip().split('\t') if len(parts) != 2: continue # 过滤掉空行与格式异常的行 texts.append(parts[0]) labels.append(parts[1])

这句if len(parts) != 2不是防御性编程,是真会踩到的坑——手工标注文件很容易混入多余的空格,导致split后出现三列,直接按parts[0] / parts[1]取会错位。过滤以后,再做一次类别分布统计:

from collections import Counter print(Counter(labels))

看各类别数量是否均衡。若某个意图只有 15 条,贝叶斯会明显偏向高频类,这是小样本下最常见的翻车点。我当时处理的办法是给低频意图复制同义改写句子,把各类别拉到接近 30 条以上,再重新训练。

3.3 F1 值 96.68% 是怎么得到的

这个 F1 是「最佳测试效果」,说明训练时试过不同的随机种子划分。210 条数据如果简单按 7:3 划分,测试集只有 60 条上下,一个句子分错类别,F1 掉 2 个点都正常。所以复现这个数字的办法是多次划分取平均:

from sklearn.model_selection import train_test_split from sklearn.naive_bayes import MultinomialNB from sklearn.metrics import f1_score, classification_report best = 0 for seed in range(10): X_train, X_test, y_train, y_test = train_test_split( texts, labels, test_size=0.2, random_state=seed, stratify=labels # 保持类别比例,避免小类流失 ) vec = tfidf_model.transform(X_train) clf = MultinomialNB(alpha=0.01) clf.fit(vec, y_train) pred = clf.predict(tfidf_model.transform(X_test)) f1 = f1_score(y_test, pred, average='weighted') best = max(best, f1) print('best f1:', best)

参数alpha=0.01是拉普拉斯平滑系数。我在复现时试过默认的alpha=1.0,F1 反而下降,因为 210 条语料里不少词只出现一次,平滑系数太大会把真实概率摊薄。这个细节很多人会忽略,但它直接决定了你在小样本上能不能逼近 96% 这个数。

4. 实体抽取与问题解析:把问句变成 Cypher 查询

4.1 Aho-Corasick 多词匹配与词典加载

意图识别只能告诉系统「用户问的是症状还是治疗」,真正要执行查询还需要从问句里拽出具体疾病名、症状名。这个项目的做法不是训练序列标注模型,而是加载了data/下的五个词典文件,用 Aho-Corasick 多模匹配一次性扫描文本。

data/目录下有这些词表:disease_vocab.txt(疾病名)、symptom_vocab.txt(症状)、complications_vocab.txt(并发症)、alias_vocab.txt(疾病别名)、stop_words.utf8(停用词),外加一个vocab.txt作为全量词表。entity_extractor.py里的加载方式类似下面这样:

import ahocorasick # pyahocorasick 库 def build_actree(wordlist): actree = ahocorasick.Automaton() for idx, word in enumerate(wordlist): actree.add_word(word, (idx, word)) actree.make_automaton() return actree disease_actree = build_actree( [line.strip() for line in open('data/disease_vocab.txt', encoding='utf-8')] )

然后扫描问句,把命中词和位置取出来:

answer = '头痛应该挂哪个科' for end_index, (idx, word) in disease_actree.iter(answer): start = end_index - len(word) + 1 print(word, start, end_index)

这样做的好处是速度极快,百万级词表下也是毫秒级;坏处是词典里没有的实体永远抽不出来。所以这套系统的上限由数据质量决定,不属于模型能力不足。

由于实体类型有七类,别名实体抽取完以后还要做一次归一化:alias_vocab.txt里存的是「别名 → 标准名」的映射。抽取阶段先用 AC 自动机把别名命中,再在组装查询时用映射表转成标准疾病名。我在复现时看到entity_extractor.py里专门有一段做这个归一化的逻辑,这一步千万别省——不归一化的话,Neo4j 里根本没有别名节点作为查询入口,答案就落空了。

4.2 问句的细分处理:停用词与分词配合

抽取实体之前,stop_words.utf8会先过滤掉「请问、一下、得了、什么」这类高频无义词。实际流程是:先把问句跑一遍 jieba 分词,过滤停用词,再对剩下片段做 AC 匹配。

这个顺序有个讲究:先分词再匹配,可以减少跨词边界匹配的错误。例如「头痛应该挂哪个科」分词后是「头痛 / 应该 / 挂 / 哪个 / 科」,如果不过滤停用词,「哪个」可能被某个词表命中(假如词典里恰有「哪个」),干扰意图判断。

我建议把停用词表打开看一遍,不用急着加词。先按默认跑一遍意图测试,哪些句子被错误分类了,再把误导词补进stop_words.utf8。这是调参成本最低的手段,比改模型超参数快得多。

4.3 从意图到 Cypher:四大类查询模板拼装

意图模型输出类别后,search_answer.py的核心工作就是通过条件分支生成 Cypher。逻辑可以理解为:

def get_answer(question): intent = predict_intent(question) # 意图标签 entities = extract_entities(question) # 实体集合 if intent == 'disease_symptom': # 疾病 -> 症状 cypher = ( 'MATCH (d:`疾病`)-[:HAS_SYMPTOM]->(s:`症状`) ' 'WHERE d.name = $name RETURN s.name' ) elif intent == 'disease_department': # 疾病 -> 科室 cypher = ( 'MATCH (d:`疾病`)-[:BELONG_TO]->(dep:`科室`) ' 'WHERE d.name = $name RETURN dep.name' ) # ... 其他意图分支类似 results = graph.run(cypher, name=entity_name).data() return format_answer(results, intent)

$name是参数化查询的占位符,用graph.run传参而不是直接拼字符串。这样既避免注入风险,也避免疾病名里有引号时把 Cypher 拼坏。比如「阿尔茨海默病」这种带点的名称,参数化以后完全无感。

这里有个值得注意的边界:每一类意图只对应一条固定模板,所以问题描述稍一复杂(「最近一直头痛、还有点发热,需要挂哪个科」)就可能同时抽出多个实体。项目没有做多实体消解,首次命中的实体直接参与查询。复现时如果你想提高准确率,可以自己加一个规则:优先提取疾病实体,没有疾病实体再退而求其次用症状实体反查。

4.4 答案组织:空结果与多结果的处理策略

图谱查询返回的有可能是空列表,也可能是几十条记录,直接原样返回对用户很不友好。这个项目在search_answer.py中做了两层收敛:第一层判断结果是否为空,空则返回提示语「暂未收录该疾病的相关信息」;第二层对返回列表设置上限,比如取前 5 条,拼成带顿号的句子返回。

多结果裁剪这个细节我觉得是最接近实战的地方。真实问答产品上,答案过长会严重影响用户体验;拼接时还要去重和排序。我在复现时给结果按节点名排序后裁剪,效果比原始脚本稳定不少。这个改动 10 行代码以内,值得做。

5. 避坑与常见问题排查:从 Neo4j 连不上到 F1 忽高忽低

5.1 Neo4j 版本不匹配:Py2neo 连不上 4.x 以上版本

现象:运行build_graph.py报Unauthorized或直接连接超时,连本机 7474 端口都失败。

原因:这是 Py2neo 版本与 Neo4j 服务端版本不匹配导致的。Neo4j 4.x 之后认证方式从neo4j/neo4j默认为首次登录强制改密,且 Py2neo 4 与 Py2neo 5 的连接参数格式变了。老项目默认http://localhost:7474,如果你本地装的是 Neo4j Desktop 最新版,默认 bolt 端口是 7687。

解决:先确认版本,再改连接。常见做法是切换到兼容组合:Neo4j 3.5 + Py2neo 4.x,或者 Neo4j 4.x + Py2neo 5.x。改完后在代码里显式指定:

graph = Graph('bolt://localhost:7687', auth=('neo4j', '你的密码'))

同时检查 Neo4j 配置文件dbms.connectors.default_listen_address是否允许非本机访问。很多人卡在这一步不是因为代码,而是 Neo4j 服务根本没启动。

5.2 实体抽取结果为空:词表编码或路径问题

现象:kbqa_test.py里随便输入一句「高血压吃什么药」,返回空答案,意图测试正常但实体列表为空。

原因:最常见的两个因素。第一,data/下的词表文件是 UTF-8 编码,而 Windows 下默认打开方式可能是 GBK,读进来全成了乱码字符,匹配必然失败。第二,entity_extractor.py里写了相对路径,工作目录不对时找不到文件,但异常被吞掉了。

解决:在所有打开文件的调用里显式写encoding='utf-8',并把路径改为基于当前文件的绝对路径构造:

import os BASE = os.path.dirname(os.path.abspath(__file__)) path = os.path.join(BASE, 'data', 'disease_vocab.txt')

另外,先单独跑一遍词表加载,打印词表长度。如果长度明显小于预期(比如疾病词表只有十几条),就是编码读错了,不是匹配逻辑的问题。

5.3 意图 F1 忽高忽低:随机划分导致小样本过拟合

现象:多次运行训练脚本,F1 在 85% 到 97% 之间波动,无法稳定复现摘要里的 96.68%。

原因:210 条数据本身太少,train_test_split默认不限制随机种子,每次划分不同,测试集只有几十条,某一条被分错就会引起 F1 大幅波动。SVM 在这个场景下更敏感,所以项目才换成了朴素贝叶斯。

解决:固定随机种子,并用分层抽样保证每个意图类别在训练集和测试集中占比一致。复现时每秒跑一个random_state,把表现最好的那个种子固定下来,这样「最佳 F1 96.68%」在语义上是可复现的。如果你想在工程上更稳,就做五折交叉验证,取五次均值作为模型真实水平。注意不要把测试集当调参依据,小样本下那叫作弊。

5.4 加载 .m 模型报错:joblib 版本或 Python 版本不一致

现象:joblib.load('model/intent_reg_model.m')抛出ModuleNotFoundError或ValueError: numpy.ndarray相关异常。

原因:.m文件只是 joblib 序列化产物,内部会记录对象类型与依赖库名。如果当前环境 sklearn 版本和打包时不一致,load 时会出现类名找不到;Python 3.8 打包的np.dtype在 3.11 下也可能反序列化失败。

解决:不要手写反序列化,直接检查环境依赖。用requirements.txt固定 sklearn 与 numpy 版本,然后在项目根目录新开虚拟环境:

python -m venv venv source venv/bin/activate pip install -r requirements.txt python kbqa_test.py

如果找不到原requirements.txt,就从报错信息里看它卡在哪个库,pip install对应版本即可。我的经验是 sklearn 0.24 附近最稳,numpy 降到 1.23 以下再试。

5.5 建图脚本重跑后关系翻倍

现象:第一次建图 21 万关系,第二次重跑变成 35 万或更多,节点数没有翻倍,关系数量却涨了。

原因:节点层面用了MERGE去重,但关系层面直接用CREATE——在同一个图上重复创建同一条关系不会报错,只是累积。

解决:把关系创建改成MERGE。确认关系是否重复可以跑下面的查询:

MATCH (a)-[r:HAS_SYMPTOM]->(b) RETURN count(r)

如果count(r)大于疾病和症状的笛卡尔积中合法组合数,就说明有重复。把脚本里的CREATE全量替换为MERGE,再加一层先MATCH再判断的幂等保护即可。

6. 端到端验证:从命令行问答到效果图里的知识图谱走一遍

资源根目录下的kbqa_test.py就是整个流程的入口,运行前先确认 Neo4j 服务在线、图谱数据已导入。典型运行方式是:

python kbqa_test.py

脚本交互逻辑很简单:循环接收输入,调用search_answer.get_answer(),打印返回的答案。验证时我建议盯着三件事:第一,意图标签是否准确——如果输「头痛挂什么科」却走到disease_symptom分支,问题一定出在意图模型;第二,抽取的实体名是否被别名归一化——如果抽出来的是别名而没转标准名,查询必然落空;第三,答案有没有被截断——多结果拼接时排序和去重是否生效。

效果图有两张:效果图.png是问答界面的截图,知识图谱.png是图谱可视化渲染图。前者验证了意图识别 + 实体抽取 + 图谱查询的链路是通的,后者展示的是 Neo4j 浏览器或 Gephi 导出的实体关系图。如果你想自己重新渲染一张更漂亮的实体关系图,可以导出子图后换用网页端可视化工具生成,Neo4j 官方的浏览器里跑一句MATCH (n) RETURN n LIMIT 200就能出局部图,再用布局算法调出层次感,配合颜色区分 7 类实体,这就是现在常说的「实体关系图用 AI 工具画」的落地姿势——先用 Cypher 出数据,再做视觉美化。

验证到这一步,我想推荐一个比原脚本多走一步的小习惯:给kbqa_test.py加一个批量模式,把典型测试问题写进文本文件,逐行跑并把预测意图和答案落盘。我从第一次复现这个项目起就强制自己批量跑测试集,因为交互模式下的每轮验证都依赖我的记忆,手动敲到第 20 轮就记不清刚才某句话到底判对没有。批量落盘后对比历史输出,任何一次意图模型或词典调整引起的回归都能立刻暴露。从那以后,我每次拿到 KBQA 类项目都会先建一个回归问题集,再开始改代码——建议你也这么来一遍,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 11:06:08

清理大师的完整思路:深层垃圾定位与持久优化实战

说到“清理大师”,我第一反应是前阵子帮一个朋友处理他卡到怀疑人生的旧手机。那台机子用了快三年,打开微信要转三四秒圈圈,相册滑一滑就掉帧,64G的存储常年飘红。我花了大概一个晚上,没有刷机,没有恢复出厂…

作者头像 李华
网站建设 2026/10/9 11:04:26

WinForms超市管理系统源码实战:数据库设计、事务与收银全流程解析

简介:面向C#初、中级学习者的超市管理系统完整工程,基于Winform框架与.NET Framework 4.5开发,集成收银、用户管理、库存预警、商品、销售、日志及统计查询等零售业务模块,可直接作为课程设计、毕业设计或实际项目改造的参考蓝本。…

作者头像 李华
网站建设 2026/10/9 11:03:50

模型预测控制在微电网调度中的应用:Python实现储能优化与滚动修正

做微电网调度项目这几年,我最大的感受是:传统日前调度方案在“预测不准”的现实世界里,往往会暴露出各种问题——光伏预测一偏,储能该充的时候没充,该放的时候没放,弃光率和购电成本双双上涨。后来我把调度…

作者头像 李华
网站建设 2026/10/9 11:03:46

claude-mem实践:为Claude Code打造长期记忆系统

Claude Code跑得越久,越发现一个尴尬的问题:它什么都记得,又什么都不记得。当前会话里聊得清清楚楚的技术方案,开个新会话就忘得一干二净,每次都要重新交代项目背景、代码结构、你习惯的命名方式,碰到上了规…

作者头像 李华
网站建设 2026/10/9 11:02:57

地图比例尺:从缩放级别到瓦片金字塔的数据设计核心

做地图数据设计这么多年,回头盘点哪个概念最“基础”但其实最容易被低估,比例尺绝对排得上号。很多同学一听到“地图比例尺”五个字,第一反应就是“1比1万、1比5万、1比10万”,觉得这不就是小学地理课讲过的“图上距离比实地距离”…

作者头像 李华
网站建设 2026/10/9 11:02:53

虚拟绿幕实战:摆脱大屏反光偏色,录课直播一步到位

如果你录过课,大概经历过这样的场面:机器摆好、灯光打开,人往大屏前一站,屏幕上赫然映出你本人的半张脸和补光灯的残影;摄像头画面里,你的肤色被大屏的蓝光照得发青,PPT翻页的时候整张脸跟着忽明…

作者头像 李华