简介:这是一份面向自然语言处理与知识图谱初学者的医疗领域KBQA问答系统完整项目包,覆盖从数据准备、知识图谱构建到意图识别与答案检索的全流程。系统规模包括7类实体、约3.7万实体和21万实体关系,可帮助读者快速理解KBQA在垂直领域的落地思路。压缩包共15个文件,约3.73MB,包含Python脚本、txt词表与停用词、csv疾病数据、训练好的意图模型(.m)以及效果图等;txt与csv提供医疗实体和关系数据,py负责图谱构建与问答实现,模型文件可直接复用。项目明确介绍了采用朴素贝叶斯作为意图分类器的实验依据:基于210条手工标注数据,最佳F1值达96.68%,并对比了SVM效果。目前已有787人学习下载,适合希望通过完整小项目实践知识图谱问答链路、并参考模型选型思路的开发者。
1. 医疗KBQA从0到1:3.7万实体规模意味着什么
“医疗领域知识图谱 + KBQA问答系统”,在大模型时代听上去有点复古,但在医院信息科、药企知识库、临床辅助决策场景里,用户要的从来不是一个会聊天的黑匣子,而是“为什么这么答、依据来自哪条关系”的可解释答案。这篇文章想拆透一个具体规模的方案:7类实体、约3.7万实体节点、21万条实体关系的知识图谱,以及跑在它上面的KBQA问答系统。这个规模属于中小型科室级知识库,一台普通服务器就够用,最适合辅助问诊、合理用药核查、科研检索。适合正在医疗信息化方向做落地的工程师和数据团队,也适合想从零搭一套知识图谱问答、但又不想一上来就上大模型的团队。
2. 7类实体与21万关系:本体设计决定问答的天花板
很多人一上来就把实体类型画得很细,结果后面做KBQA时发现意图模板根本写不全。我的习惯是先反推:把要支持的问答场景列一遍,让实体类型和关系类型从场景里长出来,而不是从教科书里抄出来。
2.1 七类实体怎么选:先想好“用户会问什么”,再定义实体类型
先列医疗场景里的高频问答:某病有什么症状、某症状可能是什么病、某药能治什么病、某病不能用什么药、某药和某药能不能一起吃、某个检查或手术是查什么/做什么、这个病该挂哪个科。把这些场景倒过来,7类实体基本就是固定的:疾病Disease、症状Symptom、药品Drug、药物成分Ingredient、检查Test、手术Operation、科室Department。
| 实体类型 | 典型属性 | 典型来源 |
|---|---|---|
| 疾病 Disease | code, name, alias_list | ICD-10编码库、临床指南 |
| 症状 Symptom | code, name, alias_list | 医学教材、结构化病历 |
| 药品 Drug | code, name, alias_list | 药品说明书、HIS药品字典 |
| 成分 Ingredient | code, name, alias_list | 药品说明书、药典 |
| 检查 Test | code, name, alias_list | 检验检查目录 |
| 手术 Operation | code, name, alias_list | 手术编码库 |
| 科室 Department | code, name, alias_list | 医院科室表 |
这里最容易被忽略的是“药品”和“成分”必须分开。问“阿司匹林肠溶片”和问“阿司匹林”是两条完全不同的查询路径:前者指向具体某个药,后者指向成分,一个成分会对应多个药品。如果混在一个实体类型里,实体链接阶段就会频繁选错候选,答案排序也会乱。
3.7万实体的分布并不均匀。药品加成分往往占一万五左右,疾病加症状占一万五左右,检查加手术占六千上下,科室最少。这个分布很重要:它是典型的幂律分布,头部实体(高血压、糖尿病、阿司匹林)命中了一半以上的问答请求,长尾实体的链接全靠别名词典和模糊匹配。我在设计词典时,会先保证头部实体每条问法都有别名覆盖,再去铺长尾。
2.2 关系建模:边必须有方向和证据,否则KBQA无法回答“为什么”
关系类型不能多,我一般控制在12种以内。原因是KBQA的意图模板本质上是“关系类型 × 查询意图”的组合,关系类型每多一个,模板数量就涨一截。中小规模图谱要先覆盖高频语义,而不是追求完整的医学本体模型。
核心关系就这几种:TREATS(药品/成分→疾病)、RELIEVES(药品→症状)、HAS_SYMPTOM(疾病→症状)、CONTRAINDICATES(药品→疾病)、INTERACTS_WITH(药品/成分→药品/成分)、BELONGS_TO(疾病→科室)、DIAGNOSED_BY(疾病→检查)。每条边都要带两个属性:证据来源和证据等级。
(:Drug)-[:TREATS {evidence_source: "药品说明书", evidence_level: "高"}]->(:Disease) (:Drug)-[:CONTRAINDICATES {evidence_source: "说明书禁忌项", evidence_level: "高"}]->(:Disease) (:Drug)-[:INTERACTS_WITH {evidence_source: "相互作用研究", evidence_level: "中"}]->(:Drug) (:Disease)-[:HAS_SYMPTOM {evidence_source: "临床指南", evidence_level: "中"}]->(:Symptom)这段结构决定了问答系统能不能回答“为什么”。临床用户问“这个结论哪来的”,如果你只说“阿司匹林和布洛芬有相互作用”,却给不出依据,系统就上不了线。所以关系属性里证据字段是刚需,不是加分项。
21万条关系在这个本体规模下是什么概念?3.7万实体、每类关系平均出度5~6,和真实医疗图谱的稠密度差不多。但关系分布高度偏斜:高血压、糖尿病这类常见病,关系数量上千;长尾实体可能只有两三条,甚至孤立。这会直接影响答案排序策略,第四章会展开讲。
提示:不要把“未知”建模成“无关系”。某药和某药的相互作用未知,不代表它们之间有一条“已知无相互作用”的边。建了反而会给出错误结论,这是医疗知识图谱最容易埋雷的地方。
3. 图谱入库三步:实体对齐、Cypher导入、质量校验
本体定了,下一步是把数据倒进图数据库。这一步做得好不好,直接决定KBQA上线的排错成本。我这里的落地路径是:先做实体对齐清洗,再用CSV加Cypher批量导入,最后跑一轮质量校验脚本。
3.1 实体对齐:为什么“阿司匹林”和“乙酰水杨酸”是两个节点就让问答系统翻车
实体对齐是决定问答下限的步骤。同一个实体如果在图里出现两个节点,实体链接命中一半概率是失败的:问“乙酰水杨酸”时匹配到另一个节点,模板查询返回空。比如“阿司匹林”和“乙酰水杨酸”、“布洛芬”和“异丁苯丙酸”,这些都属于一类实体。
我一般用三层策略:先做文本标准化,再查同义词词典,最后用编辑距离做模糊兜底。文本标准化要处理全角半角、括号里的剂型规格、英文大小写和空格。
import unicodedata import re from difflib import SequenceMatcher # 实际使用时从 aliases.csv 加载,格式:alias -> 标准实体 code ALIAS_DICT = { "阿司匹林": "DRUG_ASPIRIN", "乙酰水杨酸": "DRUG_ASPIRIN", "布洛芬": "DRUG_IBUPROFEN", "异丁苯丙酸": "DRUG_IBUPROFEN", } # 常见剂型后缀,匹配前先切掉 DOSAGE_SUFFIX = ["肠溶片", "缓释片", "胶囊", "注射液", "片", "分散片"] def normalize_name(name: str) -> str: name = unicodedata.normalize("NFKC", name) name = re.sub(r"[((].*?[))]", "", name) # 去掉括号里的规格和剂型说明 name = re.sub(r"[\s_\-]+", "", name).lower() for suffix in DOSAGE_SUFFIX: if name.endswith(suffix): name = name[: -len(suffix)] break return name def match_entity(raw_name: str): norm = normalize_name(raw_name) if norm in ALIAS_DICT: return ALIAS_DICT[norm], "exact" # 模糊匹配兜底,阈值是经验值 best_code, best_score = None, 0.0 for alias, code in ALIAS_DICT.items(): score = SequenceMatcher(None, norm, alias).ratio() if score > best_score: best_code, best_score = code, score if best_score >= 0.92: return best_code, f"fuzzy:{best_score:.2f}" return None, None这段逻辑分三段:标准化、精确匹配、模糊匹配。前两段负责跑量,第三段只在长尾问法出现时才触发,因为模糊匹配会把“丙戊酸钠”和“丙戊酸镁”这类相似但不同的实体误对齐,所以阈值不能放太低。
0.92这个阈值是玄学,我一般不建议拍脑袋定。先把线上问句里没命中的实体捞出来,在500条真实样本上做人工标注,再拿标注结果回推阈值。这里的关键参数不是单个阈值,而是“切剂型后缀”这件事一定要做对:不切的话,“阿司匹林肠溶片”和“阿司匹林”永远对不齐。
3.2 CSV + Cypher 批量导入:约束和索引建错,3.7万实体会让你等到下班
对齐完成后的实体表导出成两个CSV:entities.csv 放节点,relations.csv 放边。节点表字段是 code、name、entity_type、alias_list、extra;关系表字段是 start_code、end_code、rel_type、evidence_source、evidence_level。
// entities.csv 按类型分多次导入,先用约束保证 code 全局唯一 CREATE CONSTRAINT disease_code IF NOT EXISTS FOR (n:Disease) REQUIRE n.code IS UNIQUE; CREATE CONSTRAINT drug_code IF NOT EXISTS FOR (n:Drug) REQUIRE n.code IS UNIQUE; CREATE CONSTRAINT ingredient_code IF NOT EXISTS FOR (n:Ingredient) REQUIRE n.code IS UNIQUE; CREATE CONSTRAINT symptom_code IF NOT EXISTS FOR (n:Symptom) REQUIRE n.code IS UNIQUE; LOAD CSV WITH HEADERS FROM 'file:///entities.csv' AS row WITH row WHERE row.entity_type = 'Disease' MERGE (d:Disease {code: row.code}) ON CREATE SET d.name = row.name, d.alias_list = row.alias_list;这段代码的关键是:先建约束,再MERGE。约束保证实体code不重复,MERGE依赖约束做唯一性匹配,没有约束的话,重复导入一次就多一个节点,线上问答立刻翻车。
关系导入同理,只是要注意关系类型是固定的,没法用变量动态指定。没有装APOC插件的话,就按关系类型写多行MERGE,每行负责一个rel_type:
LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row WITH row WHERE row.rel_type = 'TREATS' MATCH (a {code: row.start_code}) MATCH (b {code: row.end_code}) MERGE (a)-[r:TREATS {evidence_source: row.evidence_source, evidence_level: row.evidence_level}]->(b) ON CREATE SET r.created_at = datetime();这段逻辑里,MERGE的边属性带上了证据来源和证据等级,这是故意的:同一来源、同一等级的边重复导入时不会生成重边;如果两个来源都论证了同一条关系,图谱里保留两条边,这也是为了溯源时的证据完整性。
| CSV字段 | 说明 |
|---|---|
| start_code / end_code | 实体主键,来自实体对齐后的标准code |
| rel_type | TREATS、CONTRAINDICATES 等,按类型分开导入 |
| evidence_source | 说明书/指南/文献,用于答案溯源 |
| evidence_level | 高/中/低,用于答案排序 |
LOAD CSV默认每1000行一个事务,3.7万节点和21万关系在小批量下跑得动。真正拖慢速度的不是批次,而是没建索引的查询:每导入一条关系,Cypher都要全局扫节点。所以实体必须用约束保证唯一,关系查询里的节点code匹配也必须走索引。
3.3 关系质量校验:孤立节点和重复关系,比想象中多
导入完不要直接接端到端问答,先跑一轮质量校验。我一般会把下面四条查询存成一个校验脚本,每次导入后都执行:
// 孤立节点:没有任何关系的节点,占总数比例过高说明关系构建漏了 MATCH (n) WHERE NOT EXISTS { (n)--() } RETURN count(n); // 各类型节点度分布,重点看头部节点的出边数量是否合理 MATCH (n:Drug)-[r]-() RETURN n.code, count(r) AS degree ORDER BY degree DESC LIMIT 20; // 重复关系:同一对节点、同一类型,检查是否建了多条边 MATCH (a)-[r]->(b) WITH a, b, type(r) AS t, count(*) AS c WHERE c > 1 RETURN a.code, b.code, t, c LIMIT 50; // 缺证据的关系:边建了但没来源,问答时没法溯源 MATCH ()-[r]-() WHERE r.evidence_level IS NULL RETURN count(r);孤立节点比例我一般控制在2%以下,高于这个数就要回头检查关系抽取是不是漏了某个类型;重复关系应该为0,大于0说明导入脚本的MERGE属性粒度不对;证据字段缺失比例,医疗场景里应该无限接近0。
质量校验建议放在自动化流水线里每天跑。临床数据更新频繁,增量导入后跑一轮,能及时发现增量数据把孤立节点和重边带进来。这个习惯比优化任何一条查询语句都值钱。
4. KBQA管线:把自然语言问句翻译成具体Cypher的三层漏斗
图谱建好后,问答系统本质上是一个“自然语言到结构化查询”的翻译器。我采用的是三层漏斗:意图分类、实体链接、查询生成。每一层都用规则做底,模型做兜底,这样3.7万实体的小图谱也能在可解释的前提下跑出可用效果。
4.1 意图分类:先用规则把框架跑通,模型留给BadCase去训
三种常见的KBQA路线:模板匹配、语义解析、检索排序。医疗场景下我首选模板加规则分类,不是因为它先进,而是因为3.7万实体的小图谱撑不起训练数据量,而且医疗问法的“否定词”和“禁忌词”对语义解析的准确率要求极高,黑匣子式模型出了问题很难排查。
先定义意图集合,每个意图对应一组关键词:
INTENT_KEYWORDS = { "query_treatment": ["能治", "治疗", "吃什么药", "用什么药", "疗法"], "query_symptom": ["症状", "表现", "临床特征"], "query_contraindication": ["禁用", "不能吃", "禁忌", "忌用", "慎用"], "query_interaction": ["相互作用", "一起吃", "同时服用", "冲突"], "query_exam": ["检查", "化验", "诊断方法"], "query_department": ["挂哪个科", "就诊科室", "看哪个科"], } def classify_intent(question: str) -> str: hits = [] for intent, keywords in INTENT_KEYWORDS.items(): if any(k in question for k in keywords): hits.append(intent) if len(hits) <= 1: return hits[0] if hits else "query_general" # 冲突时的优先级:禁忌 > 相互作用 > 治疗 > 症状 > 检查 > 科室 priority = [ "query_contraindication", "query_interaction", "query_treatment", "query_symptom", "query_exam", "query_department", ] for intent in priority: if intent in hits: return intent return "query_general"这段逻辑要解决的核心问题是:“高血压患者不能吃什么药”这句话,同时命中了“吃什么药”和“不能吃”。如果不做优先级处理,系统会把它当成正向治疗查询,返回一堆高血压治疗药物,这在临床场景里是严重错误。所以优先级里禁忌意图排最前。
规则配置我会放到配置文件里,不写死在代码中。原因是医疗问法的口语化程度远超预期,“能不吃”“有啥忌口”“跟啥冲突”这些说法,只有上了线、从日志里捞出来才能补全。每次补规则都要走配置更新,不要动代码。
当规则命中的BadCase积累到200条以上,再考虑训一个BERT意图分类器,但它的结果只作为规则的补充,不替代规则。后面第六章会讲BadCase回流的具体做法。
4.2 实体链接:AC自动机最大匹配 + 邻域消歧
实体链接是把问句里的实体指称映射到图谱节点。医疗场景有三个特有问题:嵌套实体(“2型糖尿病”里嵌着“糖尿病”)、同义词(“阿司匹林”和“乙酰水杨酸”)、缩写(“Hp”和“幽门螺杆菌”)。嵌套问题我最常用Aho-Corasick自动机加最大匹配:
from pyahocorasick import Automaton # alias_items 是 (别名, 标准code) 列表,来自实体表的 alias_list automaton = Automaton() for alias, code in alias_items: automaton.add_word(alias, (alias, code)) automaton.make_automaton() def entity_link(question: str): norm_q = normalize_name(question) hits = [] for end, (alias, code) in automaton.iter(norm_q): start = end - len(alias) + 1 hits.append((start, end, alias, code)) # 先按位置升序,同位置按长度降序;再贪心取不重叠匹配 hits.sort(key=lambda h: (h[0], -(h[1] - h[0]))) picked, covered = [], set() for start, end, alias, code in hits: if any(i in covered for i in range(start, end + 1)): continue picked.append({"code": code, "alias": alias, "start": start, "end": end}) covered.update(range(start, end + 1)) return picked这段逻辑的重点是“长词优先”:当问句里同时出现“2型糖尿病”和“糖尿病”时,自动机返回的匹配结果是重叠的,贪心策略按长度降序选取,保证“2型糖尿病”被整体命中,而不是被切碎成“2型”和“糖尿病”。
医疗实体链接还有一个消歧问题:问“高血压患者能吃布洛芬吗”会链接出“高血压”和“布洛芬”两个实体,但查询主轴应该是药品“布洛芬”,疾病是约束条件。我一般做法是结合意图和实体邻域打分:如果意图是query_treatment,候选实体类型是Drug优先;如果意图是query_symptom,候选实体类型是Disease优先。加上类型候选池,准确率能从75%提到90%以上。
这里还有一个容易被忽略的参数:AC自动机匹配的是字面字符串,所以词典里一定要有“阿司匹林肠溶片”这类带剂型的完整写法,不然用户按说明书上的药名来问,链接就会失败。词典的构建不是一次性的,要跟着BadCase回流持续更新。
4.3 查询生成与空结果兜底:模板参数化,别让答案把系统打崩
实体链接和意图分类都出来了,查询生成就只是查表。每一类意图对应一组Cypher模板,实体code作为参数传入:
| 意图 | 示例问句 | Cypher主轴 |
|---|---|---|
| query_treatment | 高血压能吃什么药 | MATCH (d:Disease {code:$code})<-[:TREATS]-(drug:Drug) |
| query_symptom | 糖尿病有什么症状 | MATCH (d:Disease {code:$code})-[:HAS_SYMPTOM]->(s:Symptom) |
| query_contraindication | 高血压不能用什么药 | MATCH (d:Disease {code:$code})<-[:CONTRAINDICATES]-(drug:Drug) |
| query_interaction | 阿司匹林和布洛芬能一起吃吗 | MATCH (a:Drug {code:$code1})-[:INTERACTS_WITH]-(b:Drug {code:$code2}) |
QUERY_TEMPLATES = { "query_treatment": ( "MATCH (d:Disease {code:$code})<-[:TREATS]-(drug:Drug) " "RETURN drug.name AS answer LIMIT 20" ), "query_symptom": ( "MATCH (d:Disease {code:$code})-[:HAS_SYMPTOM]->(s:Symptom) " "RETURN s.name AS answer LIMIT 20" ), "query_contraindication": ( "MATCH (d:Disease {code:$code})<-[:CONTRAINDICATES]-(drug:Drug) " "RETURN drug.name AS answer LIMIT 20" ), "query_interaction": ( "MATCH (a:Drug {code:$code1})-[r:INTERACTS_WITH]-(b:Drug {code:$code2}) " "RETURN r.evidence_source AS answer LIMIT 5" ), } def generate_query(intent: str, entities: list): if not entities: return None, None params = {} if intent == "query_interaction" and len(entities) >= 2: params = {"code1": entities[0]["code"], "code2": entities[1]["code"]} else: params = {"code": entities[0]["code"]} cypher = QUERY_TEMPLATES.get(intent) return cypher, params模板参数化是必须养成的习惯。用户问句里的实体指称永远不许通过字符串拼接进Cypher,否则一条“xx') RETURN 1//”就能注入你的图谱。参数化之后,Cypher层会处理转义,这是底线。
空结果兜底同样重要。医疗图谱长尾实体多,答案为空是常态,不是异常。我的兜底逻辑分三档:第一档,图谱里确实没有这个关系,返回“图谱中暂未收录相关信息,请咨询临床医生”;第二档,实体链接成功但意图模板查不到,说明关系没建,要进BadCase池;第三档,实体链接失败,说明词典缺别名,也要进BadCase池。这两类BadCase的流向不一样,日志里必须区分。
注意:每条查询都要设超时,我一般把Neo4j的事务超时压到2秒。21万关系的图谱里,一个高热度实体如果LIMIT忘了写,Cypher可能把上千条结果全捞出来,查询时间直接翻倍,用户端表现为卡死。
5. 医疗KBQA落地避坑:5个让我长期返工的真实问题
5.1 坑一:同义词做成了别名属性,但模板只在name上匹配
现象是问“阿斯匹林”搜不到,问“阿司匹林肠溶片”能命中药品节点,但知识图谱里这个药的关系却为空。原因是建图时把别名放进了实体的alias_list属性,但KBQA的Cypher模板只写了WHERE d.name = $entity,压根没走别名索引。
解决方法是把实体链接当作唯一的入口:任何问句先经过实体对齐,转成标准code,模板只按code查节点。这样同义词、错别字、剂型差异都在对齐层消化,Cypher层不需要考虑别名,逻辑清晰也不容易漏。
5.2 坑二:关系方向建模不统一,问“能不能一起吃”时返回空
现象是A药和B药有相互作用,问“A和B能不能一起吃”能答,镜像问法“B和A能不能一起吃”就返回空。原因是导入时INTERACTS_WITH方向建的是A指向B,B指向A的边不存在,而查询模板里写了固定方向。
解决方法是相互作用这类天然无向的关系,在查询时统一写成无向匹配,也就是第四章模板里那种不带箭头的写法。导入时保留原始方向,查询时忽略方向,两条边其实只需要建一条,但为了镜像问句的查询性能,我一般还是会把两个方向的边都建上,图空间换查询时间。
5.3 坑三:否定问句被优先级规则带偏
现象是问“高血压患者不能用什么药”,返回了所有治疗高血压的药。原因是“用什么药”命中了正向治疗关键词,优先级虽然把禁忌排在最前,但问句里同时出现了“不能”和“用什么药”,规则层没做否定词识别。
解决方法是意图分类前先做一轮否定词检测,命中“禁用、禁忌、不能吃、不能喝、忌用、慎用”其中之一,直接把意图改写成query_contraindication或query_interaction,不再进入常规优先级判断。这个规则看着简单,但漏了会让整个系统在临床上给出反方向建议,属于上线前必须压测的边界条件。
5.4 坑四:图谱增量更新后,查询结果还是旧的
现象是修正了一条关系,线上问答还是返回旧答案。原因是查询层加了Redis缓存,缓存key只包含问句哈希,没有包含图谱版本号,数据变更后缓存没有失效。
解决办法是给缓存key拼一个graph_version参数,每次增量导入后自增版本号,缓存层按版本号整体失效。这个坑最容易在从“离线一次性构建”切到“增量更新”时爆发,因为一次性构建时你根本不会想到缓存失效的问题。
5.5 坑五:MERGE导入关系时生成了大量重复边
现象是relation.csv全量重导,跑完发现21万条边变成了30万条。原因是MERGE关系时没有把证据属性放进匹配条件,每次重导都当成新关系插入。
解决办法是把evidence_source和evidence_level作为MERGE的匹配属性,同一来源、同一等级的关系重复导入时会自动命中,不会新建边。同一关系如果来自两个不同证据源,允许保留两条边,查询时按证据等级排序取高等级,这样既避免重复边,又保留溯源能力。
6. 进阶:评测集怎么标,BadCase怎么回流
端到端评测别只盯着准确率。我一般标注200条真实问句,不自己编,而是从线上日志里抽,分三类标注:完全正确、部分正确、错误。部分正确指的是实体链接对了但答案不完整,比如问“高血压能吃什么药”,只返回了一种药,漏了其他几种。
def evaluate(gold_data, predictions): # gold_data: [{"question": "...", "answer_list": ["药A", "药B"]}] # predictions: [["药A", "药B", "药C"]] exact_match = 0 recall_at_1 = 0 for gold, pred in zip(gold_data, predictions): gold_set = set(gold["answer_list"]) if gold_set and pred and pred[0] in gold_set: recall_at_1 += 1 if gold_set == set(pred): exact_match += 1 n = len(gold_data) return {"EM": exact_match / n, "Recall@1": recall_at_1 / n}BadCase回流比调模型参数更重要。每周从日志里捞未命中和低分问句,分三类处理:缺别名,补进实体词典;缺关键词,补进意图规则;缺关系,补进图谱。我踩过一次教训:花了两周微调BERT分类器,准确率只涨了1.2%,后来把“能不吃”“有啥忌口”这类口语问法加进否定规则,整体EM直接涨了3个百分点。规则派的快速胜利,永远比模型派的缓慢爬坡更早见效,希望帮到你。
本文还有配套的精品资源,点击获取