医药知识图谱问答:实体识别、链接与意图识别工程实践
简介这套医药知识图谱自动问答系统源码面向自然语言处理与知识图谱方向的本科生、研究生可作为课程设计、毕业设计及入门实战项目。系统实现三大核心模块基于词典与BERT_CRF的实体识别、基于Sentence-BERT的实体链接、基于提问词与领域词词典的意图识别覆盖问答链路中关键算法环节。资源包共364个文件约72.34MB包含Python训练与推理脚本.py、前端页面.html/.css/.js、图表与静态资源.png/.svg、模型配置与数据文件.json/.tsv/.pkl、说明文档.md/.txt等目录结构完整可直接运行。已有66人学习浏览适合想深入理解知识图谱问答技术栈的读者解压即可获得完整源码与运行配置既可用于快速复现演示也能支撑二次开发。在现有基础上可扩展新意图、新实体类型或迁移到其他领域知识图谱灵活适配课程设计、毕业设计或论文实验。1. 一套医药知识图谱问答为什么需要“识别链接意图”三段式医药领域的自动问答系统难点从来不在“能不能回答”而在“回答错一个数就是事故”。实体识别基于词典BERT_CRF、实体链接Sentence-BERT做匹配、意图识别基于提问词领域词词典这三层恰好是医药知识图谱问答里最经得起验证的组合先让意图决定查询方向再让实体识别圈出关键词最后用实体链接把别名和标准名对齐。这套新版实现的妙处在于它没有把三个任务塞进一个端到端模型而是保留了解耦的模块边界线下可以分别评测线上可以各自扩容。知识图谱构建阶段维护的术语表和别名表在这里直接决定词典召回的上限。适合正在做人名、药名、疾病名混杂场景问答或负责工单解析、图谱查询入口的工程师。2. 实体识别——词典召回打底BERT_CRF兜底泛化2.1 医药术语为什么不能只靠模型医药实体的名字有极强的离散性同一成分的标准名是“乙酰水杨酸”商品名叫“拜阿司匹灵”临床缩写是“ASA”用户口语还可能说“阿司匹林肠溶片”甚至“阿司匹林”三个字。纯粹用BERT_CRF去序列标注问题在于低频和未登录术语模型在训练集里见过多少种写法直接决定它的上限。公开预训练模型在通用语料上表现稳定但在中药名、生化药名、罕见病名上经常整段漏掉。反过来词典匹配的问题在于覆盖不了“用户自创的简写”。所以常见的做法是双通道并行词典走AC自动机做最长匹配专门吃高频、强规则术语BERT_CRF只处理词典漏掉或前后文模糊的片段。线上跑下来词典通道能覆盖70%以上的标准名命中而且零延迟、零训练剩下30%里BERT_CRF兜住大部分非规范写法。这套设计把“确定的事”和“模糊的事”分开处理比单独堆任何一个模型都稳。2.2 用AC自动机做词典召回代码与参数词典匹配用pyahocorasick构建自动机一个add_word就能把整张术语表变成多模式匹配结构。构建一次之后每次查询只扫一遍文本时间复杂度近似 O(文本长度 命中数)import ahocorasick def build_automaton(term_dict): # term_dict: {阿司匹林: (D001, drug), 胃溃疡: (DIS002, disease)} automaton ahocorasick.Automaton() for name, (node_id, etype) in term_dict.items(): # 同一标准名可能对应多个图谱节点这里先记录链接阶段再消歧 automaton.add_word(name, (node_id, etype, name)) automaton.make_automaton() return automaton automaton build_automaton(term_dict) def dict_scan(text, automaton): hits [] for end_idx, (node_id, etype, name) in automaton.iter(text): start_idx end_idx - len(name) 1 curr (start_idx, end_idx 1, name, node_id, etype) if hits and curr[0] hits[-1][1] and curr[1] hits[-1][1]: # 与上一条命中重叠时保留更长的 hits[-1] curr if len(curr[2]) len(hits[-1][2]) else hits[-1] else: hits.append(curr) return hitsahocorasick.iter返回的是命中词的结束位置必须用len(name)回推起始位置。注意它返回的是最长匹配的结束位置同一位置只给一个最长的词所以start_idx和end_idx之间是不重叠的。词典规模在几十万词条量级时构建一次耗时几秒到几十秒线上只做查询压力很小这里常见的一个坑是词条里混入了标点或括号全半角不一致会导致匹配不上构建词典前最好统一做一次规范化。2.3 BERT_CRF 作为兜底模型结构与推理要点BERT_CRF 的结构一句话讲清BERT 出每个 token 的向量接一个线性层映射到标签数CRF 层负责约束标签序列的合法性。相比单纯用 BERT softmaxCRF 的最大收益是“I 标签不能跨实体类型”“实体必须以 B 开头”这类约束被模型显式学到了import torch.nn as nn from transformers import BertModel from torchcrf import CRF class BertCRF(nn.Module): def __init__(self, pretrained_path, num_labels): super().__init__() self.bert BertModel.from_pretrained(pretrained_path) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) self.crf CRF(num_labels, batch_firstTrue) def forward(self, input_ids, attention_mask, labelsNone): outputs self.bert(input_ids, attention_maskattention_mask) logits self.classifier(self.dropout(outputs.last_hidden_state)) if labels is not None: # CRF 返回的是负对数似然取负号作为 loss loss -self.crf(logits, labels, maskattention_mask.bool(), reductionmean) return loss return self.crf.decode(logits, maskattention_mask.bool())num_labels是 B、I、O 乘以实体类型数的总和比如药品、疾病、症状三类就是 7 个标签。训练时 labels 里被 padding 的位置填 -100配合attention_mask让 CRF 跳过这些位置。推理时decode直接返回最优标签序列不再需要自己写维特比。实际落地时要注意两点一是最大序列长度不要拉满到 512医药常识问答里一个用户问句通常不超过 40 个字超过 128 的句子直接切句避免无意义的算力浪费二是标签体系里“B-药品”和“I-疾病”这类组合对 CRF 来说天然非法它会自动改判这就是 CRF 对比纯 softmax 最直观的收益。2.4 双通道合并重叠 span 的冲突处理两套识别结果的合并是实体识别模块最容易出错的地方。我常用的策略是按下表优先级处理通道情况处理方式理由词典命中BERT_CRF 未命中保留词典结果人工审核过的术语表置信度高于模型预测BERT_CRF 命中词典未命中保留模型结果这是模型真正的泛化贡献属于新写法两边命中同一 span类型一致直接合并双通道互证置信度最高两边命中同一 span类型冲突词典优先常用名到类型是人工映射出错概率低span 部分重叠查表决定长 span 优先通常词典词条更长更规范但也有例外需看实体类型合并时记下每个 span 的来源通道和置信度这个置信度要传给实体链接阶段当先验特征。比如“阿司匹林”同时被词典和模型命中链接分数的置信度可以适当抬升只被模型命中时则全看 Sentence-BERT 的匹配分数。3. 实体链接——Sentence-BERT 把别名和标准名拉进同一向量空间3.1 字符串相似度解决不了的别名问题实体识别完成后识别出的文本片段还是一个字符串它必须映射到知识图谱里确定的某个节点。医药场景里“阿司匹林”“乙酰水杨酸”“ASA”“拜阿司匹灵”指向的是同一个药品节点但两两之间的编辑距离没有任何优势。反过来“匹维溴铵”和“溴吡斯的明”结构相似、读音接近却是两种完全不同的药。这种场景下 Levenshtein、Jaccard 这类字符匹配全部失效只能靠语义相似度判断。实体链接整条链路的常用做法分两步先召回候选再精排。候选阶段用自己的别名表和图谱索引把潜在节点压到 K 个以内精排阶段用 Sentence-BERT 对 mention 和候选节点的文本生成向量算余弦相似度。知识图谱本体设计在这时候的收益就体现出来了如果构建图谱时为每个节点维护了别名、描述、适应症等冗余文本字段向量匹配的质量会明显好于只对实体名做编码。3.2 候选召回先缩小范围再算相似度候选召回不要上来就全库向量检索医药图谱节点少则几万多则上千万全量向量检索在低延迟要求下没必要。我一般先走两步按知识图谱的实体类型过滤。意图识别产出“药品”类型后候选只在该类型节点里找这一步通常能砍掉 90% 的数据。用别名表和 N-gram 倒排索引做粗召回。命中别名表直接拿节点 ID未命中的用 BM25 在实体名、别名、描述字段上粗扫取前 20 个候选。def recall_candidates(mention, entity_type, alias_index, es_client, top_k20): # 第一步别名表精确命中 if mention in alias_index: hits alias_index[mention] return [eid for eid, etype in hits if etype entity_type][:top_k] # 第二步ES/OpenSearch 做前缀与 match 查询 query { query: {bool: {filter: [{term: {type: entity_type}}], should: [ {match: {name: mention}}, {match: {alias: mention}} ]}}, size: top_k } res es_client.search(indexmed_entity, bodyquery) return [hit[_id] for hit in res[hits][hits]]注意 alias_index 的 key 应该同时包含标准名、商品名、化学名和已收集的口语别名它解决的是“用户问法完全等于图谱别名”的情况。ES 的match查询解决的是“问法多一个字或少一个字”的情况比如“阿司匹林片”对“阿司匹林”。这一步最怕的是类型过滤写错比如把“糖尿病”划到症状类型结果候选为空链接直接失败实体类型的边界定义应该在知识图谱构建阶段就定死这个表通常放在配置中心里业务侧不许随便改。3.3 Sentence-BERT 编码与相似度排序候选集合到 20 个以内后精排用 Sentence-BERT。核心思路是把 mention 和候选节点的文本分别编码成向量再算余弦相似度from sentence_transformers import SentenceTransformer, util # 跑通流程可以用通用多语言模型生产环境建议换生物医学语料微调过的版本 model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) def link_entity(mention_text, context_text, candidates, entity_repo): # mention_text: 实体识别出的字符串 # context_text: 用户原问句给模型更多上下文 mention_vec model.encode( f{context_text[:32]} {mention_text}, normalize_embeddingsTrue ) cand_texts [] for cid in candidates: node entity_repo[cid] # 组合字段能显著提升向量区分度 cand_texts.append(f{node.get(name,)} {node.get(alias,)} {node.get(desc,)}[:64]) cand_vecs model.encode(cand_texts, normalize_embeddingsTrue) scores util.cos_sim(mention_vec, cand_vecs)[0] ranked sorted(zip(candidates, scores.tolist()), keylambda x: x[1], reverseTrue) return ranked这里normalize_embeddingsTrue是必要的不归一化的话余弦相似度跟向量模长耦合排序会不稳定。编码 mention 时把用户原问句一起拼进去是我实测有明显收益的做法单独编码“阿司匹林”和“阿司匹林肠溶片”向量距离可能很大但如果上下文指明“阿司匹林肠溶片空腹吃”模型能借上下文把向量拉向正确位置。desc字段截断到 64 个字符以内就够医药实体描述里前几十个字符通常已经包含关键区分信息。链接完还要做一个空结果判断如果最高分低于阈值说明这个 mention 很可能不在图谱覆盖范围内不要让下游硬查返回“未收录”比返回错误结果安全得多。阈值标定方法放到最后一章讲。3.4 阈值取值和它的代价模型阈值设高了实体链接精确率高但召回低用户问“阿司匹林”可能因为分数 0.73 而被拒答设低了可能把“阿司匹林”错链到“阿司匹林维C片”节点上导致生成答案细节全错。我见过的生产环境里阈值跨度很大取决于图谱节点文本的丰富度。判断标准只有一个宁可拒答、不要错答。医药场景下“我不确定”的成本远低于“给错”。所以阈值扫描要同时记录拒答率和链接准确率画出两条曲线的交叉点再决定阈值。4. 意图识别——提问词权重 领域词词典不训练也能上线4.1 意图类别先对齐查询模板医药自动问答的意图类别不需要追求通用对话那种十几个分类跟图谱查询模板匹配上才有意义。一套够用的分类如下意图图谱查询模式典型问句副作用查询药品节点 - 相关症状/不良反应“阿司匹林吃了胃疼正常吗”禁忌与相互作用药品节点与另一实体之间的关系“阿司匹林能喝酒吗”用法用量药品节点的用法用量属性“布洛芬一天吃几次”适应症查询药品 - 对应疾病“阿司匹林治什么病”科室推荐疾病 - 科室“糖尿病挂什么科”拒答/闲聊无查询模板“你好”“今天天气”这个表在项目一开始就要定死因为对话产品经理会不断往里加意图类别每加一个图谱的关系类型和查询模板就得配套更新。4.2 提问词权重 领域词词典的双层打分意图识别不靠模型靠两层词典打分。第一层是提问词权重比如“副作用”“不良反应”这类词对副作用意图有强指示第二层是领域词加分问句里出现药品实体就被推回药品相关意图。两层分数叠加取最高分作为结果INTENT_QUESTION_WORDS { adverse_reaction: {副作用: 3, 不良反应: 3, 胃疼: 2, 反胃: 2}, contraindication: {禁忌: 3, 不能: 2, 相互作用: 2, 喝酒: 2}, usage: {怎么吃: 3, 用法: 2, 用量: 2, 一天几次: 3}, indication: {治什么: 3, 适应症: 3, 管什么: 2}, department: {挂什么科: 3, 哪个科: 2, 科室: 2} } ENTITY_TYPE_WEIGHTS {drug: 2, disease: 1, symptom: 1} def intent_judge(question, entity_types): scores {name: 0 for name in INTENT_QUESTION_WORDS} for intent, word_map in INTENT_QUESTION_WORDS.items(): for word, score in word_map.items(): if word in question: scores[intent] score for etype in entity_types: for e in ENTITY_TYPE_WEIGHTS: if e etype: scores[adverse_reaction] 1 # 医药问答里疾病/症状实体出现常伴随副作用查询 dominant max(scores, keyscores.get) return dominant, scores权重表是这套系统里唯一需要手调的核心资产。新问法上线会优先反映在提问词覆盖率上所以这个表每次迭代都应该按线上日志更新而不是一次性铺完。常见的一个问题是“阿司匹林和布洛芬能一起吃吗”这类问句同时含“一起吃”和“布洛芬”两张词典都在命中最后的意图偏向取决于权重设计。我给每类意图设 3 档权重强指示词 3 分、弱指示 2 分、语境修饰 1 分基本上能稳定区分。4.3 否定词与多意图处理否定词在医药问答里重要性不输任何提问词。“能喝酒吗”和“不能喝酒吗”是两种意图前者查相互作用后者可能是在确认禁忌。常用做法给否定词单独设规则并在最后做一次修正NEG_WORDS [不能, 不宜, 禁止, 不可, 别] def neg_correct(intent, question): if intent usage and any(w in question for w in NEG_WORDS): # “一天不能吃几次”实际上患者是想确认上限剂量 return usage_upper_limit if intent contraindication and any(w in question for w in NEG_WORDS): return contraindication return intent多意图场景比如“阿司匹林吃了胃疼怎么办”胃疼触发副作用意图“怎么办”触发解决类意图。此时我按实体类型修剪如果问句里同时出现药品实体和症状实体优先副作用或相互作用意图如果只出现单个药品实体才归到用法用量。这里的策略是意图识别服务只输出一个主意图其余候选意图附带在响应里供后端在模棱两可时做二次选择。4.4 输出结构意图识别给下游什么意图识别模块的输出不只是一个字符串而是一个 JSON 结构实体识别结果和链接结果后续会回填到这个结构里整体传给查询模板生成器{ question: 阿司匹林能和布洛芬一起吃吗, intent: contraindication, intent_score: 4, negated: false, entity_types: [drug, drug], candidates: [ {intent: adverse_reaction, score: 3} ] }这个结构同时被日志系统消费可以直接落进 ES 做离线分析。判定逻辑失败时比如所有意图得分都低于 2 分就走拒答分支返回“这个问题我暂时答不了”。5. 把三个模块串成完整问答链路从问句到查询模板5.1 串行还是并行意图先行识别并行三个模块放到线上执行顺序有讲究。我采用的顺序是意图识别先跑因为它是纯规则匹配延迟在毫秒级实体识别和实体链接在意图识别进行的同时并行触发。等两个结果都回来再组装查询模板。这样做的原因有两个第一实体识别如果不做类型过滤就直接链接候选集大延迟会翻倍第二意图识别的结果能反哺实体识别比如发现是药品相互作用查询就把实体识别的词典切换成药品疾病优先模式减少无关类型干扰。技术实现上在线服务通常是 Python 异步并发或直接上多进程消费同一个请求体。并行时的关键控制点是设置超时实体链接单次最多给 300 毫秒超过就只返回词典精确命中的候选做兜底。不能因为某个模型推理卡顿让整个问答接口超时。5.2 按意图生成图查询语句意图识别和实体链接的结果都齐了就进入查询模板生成。模板不是写死在代码里的字符串拼接最好做成配置化的 Cypher/GQL 模板每个意图对应一段模板实体 ID 和关系类型从结果里动态填入TEMPLATES { adverse_reaction: ( MATCH (d:Drug {{id: {drug_id}}})-[:HAS_ADR]-(s:Symptom) RETURN s.name AS name LIMIT 20 ), contraindication: ( MATCH (d1:Drug {{id: {drug_id}}})-[:INTERACTS_WITH]-(d2:Drug) WHERE d2.id {target_id} RETURN d1.name, d2.name ), usage: ( MATCH (d:Drug {{id: {drug_id}}}) RETURN d.dosage AS dosage, d.frequency AS frequency ) } def build_query(intent, linked_entities): if intent contraindication: drug [e for e in linked_entities if e[type] drug] return TEMPLATES[intent].format(drug_iddrug[0][node_id], target_iddrug[1][node_id]) drug [e for e in linked_entities if e[type] drug][0] return TEMPLATES[intent].format(drug_iddrug[node_id])模板里的{id}一定要走参数化绑定不要直接拼接用户输入这条在医药问答里是硬要求。图谱查询的返回结果还要做一次后处理如果返回的是空集合说明实体链接成功但关系缺失这时候的回复话术应该引导用户换一种问法不要直接说“没有”。5.3 链路日志与可观测性知道问题丢在哪一步三个模块的链路天然适合做分步骤埋点。每条问句产生一行 JSON 日志记录各步骤的输入输出、耗时和置信度这对后续迭代至关重要{ question: 阿司匹林能和布洛芬一起吃吗, intent: {value: contraindication, score: 4}, ner: [ {text: 阿司匹林, type: drug, score: 0.98, source: both}, {text: 布洛芬, type: drug, score: 0.97, source: dict} ], linking: [ {mention: 阿司匹林, node_id: D001, score: 0.91}, {mention: 布洛芬, node_id: D045, score: 0.88} ], query: MATCH ..., reply_count: 3, latency_ms: 240 }上线后我会给这个日志配两套看板一套按天统计各步骤失败率另一套按错误类型归因。最常见的情况是意图识别把“一起吃”漏了导致意图走错这类错误在日志里表现为intent_score偏低或者两个候选意图分数很接近可以据此反推该补哪些提问词。另外注意一点知识图谱可视化前端通常会做标签数量截断比如“只显示 25 个标签”这类限制是渲染层的策略和问答链路的失败无关排查时别在可视化组件上浪费时间。5.4 查询结果的答案组织查询结果回来之后不能直接拼字符串返回。医药问答的答案要附带来源信息实体节点 ID、图谱来源、查询模板名称。这么做有两个直接收益一是前端可以展示答案依据增强可信度二是离线评估时能自动判断答案是否从正确的图谱子图里取出。如果同一实体命中多个候选节点链接模块返回的分数顺序就是答案排序的依据这一点在答案文案里不需要暴露给用户但必须写进日志。6. 上线前必做的验证用真实问句校准阈值6.1 离线评测集怎么构造别用标注团队拍脑袋写的问句做评测。我会从线上日志里抽最近 30 天的真实用户问句按意图分层抽 200 条确保每个意图类别至少 30 条然后人工标注三个参数正确意图、实体 span、应链接到的图谱节点 ID。这份评测集的标注成本大约是一个人两天但它能暴露所有模块叠加后的真实水平。抽样的关键点是保留重复问法否则评测集里一家独大的高频问句会把指标拉高。6.2 阈值扫描用脚本找最优切点实体链接的余弦相似度阈值、意图识别的分数阈值、实体识别的置信度阈值三个参数同时可调。我先把后两个固定单独扫描链接阈值观察链接准确率和拒答率的变化import numpy as np def scan_threshold(dataset, model, candidates_func, thresholds): best {threshold: 0.8, f1: 0} for t in np.arange(0.60, 0.95, 0.05): correct 0 rejected 0 for item in dataset: mention item[mention] gold_node item[gold_node] cands candidates_func(mention, item[entity_type]) ranked model.link_entity(mention, item[context], cands) if not ranked or ranked[0][1] t: rejected 1 continue if ranked[0][0] gold_node: correct 1 precision correct / max(len(dataset) - rejected, 1) recall correct / len(dataset) f1 2 * precision * recall / max(precision recall, 1e-9) print(fth{t:.2f} precision{precision:.3f} recall{recall:.3f} f1{f1:.3f} reject{rejected}) if f1 best[f1]: best {threshold: t, f1: f1} return best扫描结果里最值得关注的是拒答数量。阈值从 0.70 升到 0.75如果 F1 没有明显下降但拒答率上升了 3%说明样本集中在模糊区间这时候阈值保持 0.70 更合适。同理意图识别也做一个分数阈值扫描把所有低于阈值的问句统一送入拒答分支这比硬性返回一个错误意图要好得多。6.3 错误分析表定位哪一步在丢分最常见的错误是被链接模块拉低的。把评测集里的失败样本展平按错误来源归类问句预期意图实际意图链接分数问题定位阿司匹林能和布洛芬一起吃吗contraindicationadverse_reaction0.82提问词漏了“一起吃”硝苯地平缓释片说明书usageadverse_reaction0.65图谱无“说明书”属性模板福松是治什么病的indicationdepartment0.77意图词典缺“治什么病”这张表每周更新一次每次迭代只解决占比最高的第一行。通常三轮迭代后意图识别的错误占比会低于 10%实体链接的阈值和候选召回会成为新的瓶颈再把重心转到扩充别名表和微调 Sentence-BERT 上。把三个模块的评测脚本写进 CI每次改词典、改阈值、换模型都跑一遍阈值扫描结果直接提交到配置中心这个闭环跑顺之后系统迭代的安全性就有了保障。本文还有配套的精品资源点击获取