简介面向希望入门知识图谱问答的开发者整套资源从零搭建了一个医疗领域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, encodingutf-8) print(df.shape) # 打印行数用于确认数据加载完整 print(df.columns[:10]) # 确认字段名后面按列名取实体这段代码的作用是加载原始语料。encodingutf-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, namename)注意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, encodingutf-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_size0.2, random_stateseed, stratifylabels # 保持类别比例避免小类流失 ) vec tfidf_model.transform(X_train) clf MultinomialNB(alpha0.01) clf.fit(vec, y_train) pred clf.predict(tfidf_model.transform(X_test)) f1 f1_score(y_test, pred, averageweighted) best max(best, f1) print(best f1:, best)参数alpha0.01是拉普拉斯平滑系数。我在复现时试过默认的alpha1.0F1 反而下降因为 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, encodingutf-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, nameentity_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里写了相对路径工作目录不对时找不到文件但异常被吞掉了。解决在所有打开文件的调用里显式写encodingutf-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 类项目都会先建一个回归问题集再开始改代码——建议你也这么来一遍希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?