中医药知识图谱问答系统实现:从NER到路径推理的完整技术方案
简介基于中医药领域知识图谱的智能问答系统项目包面向知识图谱、Python大作业及毕业设计人群系统性地展示了从中医药文本中抽取实体与关系、构建知识图谱并基于图谱完成智能问答的完整流程。资源共11个文件以9个Python脚本为核心分别负责命名实体识别、实体链接、路径抽取与过滤、问答交互等环节搭配1张问答流程示意图和1份说明文档压缩包仅123KB轻量易部署。已有147人学习/下载适合课程设计、毕业设计及知识图谱入门者参考。从中可快速搭建一个可运行的中医药领域问答系统理解从NER模型训练、实体链接到知识图谱路径检索的关键技术节点。另附问答流程图与说明文档能帮助读者梳理系统架构、定位调试思路也便于替换为其他领域数据做扩展实验。1. 中医药知识图谱问答系统的构建思路从文本到答案的完整链路拿到这个项目压缩包时我第一反应是看文件列表里有没有README.md因为毕业设计和课程大作业最容易出现的状况是代码写了一堆但没人知道先跑哪个文件。这个项目里KG.py负责图谱数据层train_ner.py和mention_extrator.py负责从用户问句中抽出实体entitylink.py把文本实体映射到图谱节点path_extrator.py到ans_bot.py则完成从路径枚举到答案生成的推理过程。整个链路对应知识图谱问答系统的标准三段式命名实体识别NER、实体链接Entity Linking、关系路径推理Path-based Reasoning。对于正在做知识图谱相关毕业设计或课设的人来说这套代码的最大价值不在于单个算法有多新而在于它把「图谱构建 → 语义解析 → 路径推理 → 答案生成」每个环节都拆成了独立模块你可以单独替换其中任意一个算法而不影响其他部分。2. 知识图谱存储与命名实体识别KG.py 和 train_ner.py 的分工2.1 图谱的数据结构为什么用图数据库而不是关系型数据库传统的关系型数据库存实体关系需要建两张表——entity表和relation表查「麻黄有什么功效」这类多跳问题时SQL 的 JOIN 次数会随着路径深度线性增长。KG.py在这里做的事本质上是把三元组头实体关系尾实体组织成邻接表结构在内存中直接用字典存储{实体A: {关系1: [实体B, 实体C], 关系2: [...]}}。这种结构的好处是支持 O(1) 复杂度的邻居查询为后面的路径枚举提供了遍历基础。class KG: def __init__(self): self.adjacency {} # 邻接表: {head: {relation: [tail, ...]}} self.entity2id {} # 实体到编号的映射 def add_triple(self, head, relation, tail): if head not in self.adjacency: self.adjacency[head] {} if relation not in self.adjacency[head]: self.adjacency[head][relation] [] self.adjacency[head][relation].append(tail) def get_neighbors(self, entity, relationNone): 查询实体的邻居节点relation 为 None 时返回全部关系下的邻居 if entity not in self.adjacency: return {} if relation is None: return self.adjacency[entity] return {relation: self.adjacency[entity].get(relation, [])}add_triple方法用于构建图谱get_neighbors是后续路径抽取模块频繁调用的接口设计成按关系筛选邻居是为了在路径搜索时过滤掉不相关的边比如问「功效」时只返回功效关系下的节点而不是把禁忌、产地等边也遍历一遍。如果你准备换成 Neo4j保留get_neighbors的接口签名内部把字典查询改成 Cypher 的MATCH (n)-[r]-(m) WHERE r.name $rel即可上层代码不需要改动。2.2 中文医学实体识别BIO 标注与模型选型train_ner.py做的是监督学习的序列标注任务。中医药领域的实体类型通常包括病名如感冒、证候如风寒表证、中药如麻黄、方剂如麻黄汤、症状如发热、治法如辛温解表。模型输入是一句话「麻黄具有发汗解表的功效」输出是对每个字打上 BIO 标签B-中药、I-中药、O、O、B-治法……标注数据格式一般是每行一个字加一个标签句子之间用空行隔开麻 B-中药黄 I-中药。训练时用transformers库加载预训练模型常见做法是bert-base-chinese作为编码层接一层BiLSTM后再接CRF解码。CRF 层的作用是约束标签转移关系比如I-中药前面必须是B-中药或I-中药不可能跟着O。from transformers import BertTokenizer, BertModel from torch import nn from torchcrf import CRF class BertBiLSTMCRF(nn.Module): def __init__(self, num_labels): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.bilstm nn.LSTM(768, 256, bidirectionalTrue, batch_firstTrue) self.classifier nn.Linear(512, 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) lstm_out, _ self.bilstm(outputs.last_hidden_state) logits self.classifier(lstm_out) if labels is not None: return -self.crf(logits, labels, maskattention_mask.bool()) return self.crf.decode(logits, maskattention_mask.bool())BERT 输出维度是 768双向 LSTM 的输出维度是 512最后过一个全连接层映射到标签数量。召回评估时要关注三类指标严格匹配实体边界和类型完全一致才算对、宽松匹配类型对但边界差一个字算对以及F1在验证集上低于 0.7 时的处理策略。一个容易被忽略的坑是医疗文本里「人」这个字经常出现在实体中间比如「人参」如果分词工具把「人参」切成「人/参」BIO 标签就彻底错位了。所以这个项目里mention_extrator.py不是直接在原始文本上做序列标注而是先做字级别的特征拼接或者用词典匹配生成候选实体片段。2.3 mention_extrator.py从句子中切出候选实体片段NER 模型输出的标签序列需要后处理才能变成实体片段。mention_extrator.py的核心逻辑就是根据 BIO 标签把连续片段拼起来规则很简单遇到B-开头收集遇到I-跟着拼遇到O或者切换到不同类型的B-就截断。但这个模块真正的难点在于处理嵌套实体和重叠实体。比如「风寒感冒」中既包含疾病实体「感冒」又包含证候实体「风寒感冒」BIO 标注只能标记一层对于嵌套情况常见做法是在mention_extrator里维护多套标签序列或者用词典做二次匹配。def extract_mentions(bio_tags, tokens): mentions [] cur_entity_type None cur_entity_start None for idx, (tag, token) in enumerate(zip(bio_tags, tokens)): if tag.startswith(B-): if cur_entity_type is not None: mentions.append((cur_entity_start, idx - 1, cur_entity_type)) cur_entity_type tag[2:] cur_entity_start idx elif tag.startswith(I-): if cur_entity_type is None: cur_entity_type tag[2:] cur_entity_start idx else: if cur_entity_type is not None: mentions.append((cur_entity_start, idx - 1, cur_entity_type)) cur_entity_type None cur_entity_start None if cur_entity_type is not None: mentions.append((cur_entity_start, len(tokens) - 1, cur_entity_type)) return mentions # [(start, end, entity_type), ...]提示如果实体边界经常差一个字不要急着改模型结构先在post_process里加一个词典最长匹配用预置的「中药名、方剂名」词表把模型漏掉的边界补回来。这种方法在课程设计里够用。3. 实体链接与图谱查询entitylink.py 与 kgclass.py 的协同3.1 为什么不能直接拿文本实体去查图NER 抽出的实体片段是「麻黄」而知识图谱里对应的节点可能叫「麻黄Ephedra sinica」或者「麻黄药材」文本形态和图谱实体在字面上有差异。entitylink.py就是负责消除这种差异的模块。常见的实体链接流程分两步先生成候选实体集合再对候选实体排序选出最匹配的那一个。候选生成阶段用字面相似度做粗筛排序阶段用上下文语义做精排。def generate_candidates(mention, kg, top_k10): 基于编辑距离和字符重叠率生成候选实体 candidates [] for entity in kg.get_all_entities(): overlap len(set(mention) set(entity)) / len(set(mention)) if overlap 0.5: ed levenshtein_distance(mention, entity) candidates.append((entity, overlap, ed)) candidates.sort(keylambda x: (-x[1], x[2])) return [c[0] for c in candidates[:top_k]]字符重叠率是个很粗糙但有效的指标。汉语医学实体通常 26 个字「麻黄」和「麻黄根」的重叠率是 2/2虽然编辑距离差 1但确实是候选。「麻杏石甘汤」和「麻黄」重叠率只有 1/2会漏掉所以还要配合前缀匹配做补充。精排时把问句的上下文向量和候选实体的图谱描述向量做余弦相似度计算向量可以来自 BERT 的pooler_output也可以直接用sentence-transformers的预训练模型做 embedding。3.2 kgclass.py图谱操作的统一封装kgclass.py在整个项目里起到数据访问层的作用。设计上它把图谱的物理存储JSON 文件、CSV 或 Neo4j和业务操作实体查询、关系查询、实体类型查询解耦。之所以单独封装一个类是因为路径抽取、路径过滤、特征提取三个模块都需要访问图谱数据如果每个模块都直接查KG.adjacency底层存储一换就要改所有文件。class KGClass: def __init__(self, kg_data_path): self.kg KG() self.entity2type {} self.load_from_json(kg_data_path) def get_entity_type(self, entity): 返回实体类型如 中药/方剂/证候/病名 return self.entity2type.get(entity, 未知) def get_relations(self, head, tail): 返回两个实体之间的所有关系 neighbors self.kg.get_neighbors(head) relations [] for rel, targets in neighbors.items(): if tail in targets: relations.append(rel) return relations实体类型信息在路径过滤阶段会用到比如过滤掉「证候 → 中药 → 病名」这种明显语义不通的路径。关系查询get_relations则是为了给路径上的每条边生成特征。4. 路径抽取与过滤path_extrator.py 到 path_filter.py 的链路设计4.1 路径枚举为什么不能直接做全图搜索如果问句是「麻黄能治疗什么病」经过实体链接后得到头实体麻黄和尾实体在答案未知的情况下是空值目标是在图谱中搜索从麻黄出发的合理路径。最直接的做法是限定深度做深度优先搜索或者广度优先搜索但要控制分支因子和最大深度否则路径数量会指数爆炸。一个中医药图谱如果有 5000 个实体、10 种关系深度为 3 的路径数量可能在几百万量级。def extract_paths(kg, start_entity, max_depth4, max_paths1000): 从起始实体出发枚举所有不超过 max_depth 的路径 paths [] def dfs(current, path, depth): if depth max_depth: return neighbors kg.get_neighbors(current) for relation, tail_entities in neighbors.items(): for tail in tail_entities: if tail in [p[0] for p in path]: continue # 防止循环 new_path path [(relation, tail)] paths.append(new_path) if len(paths) max_paths: return dfs(tail, new_path, depth 1) dfs(start_entity, [(None, start_entity)], 0) return paths这里要说的关键点是剪枝策略。上面的代码只做了「实体不重复」的简单剪枝实际使用中路径数量还是爆炸。常见的做法是在path_extrator里加关系方向约束比如从中药实体出发时功效关系的出边才保留主治疾病关系的入边才保留。另一个有效约束是实体类型约束路径上的实体类型必须符合先验知识比如合法的路径模式是中药 → 功效 → 症状或者是方剂 → 组成 → 中药 → 功效 → 症状。把这些模式写成一个规则表在 DFS 过程中动态剪枝把候选路径控制在几百条以内。path_filter.py做的是路径抽取之后的后置过滤。路径枚举阶段为了不丢答案会保留所有符合字面条件的路径但其中有大量噪音。过滤规则通常分两类长度过滤——超过 4 跳的路径语义可解释性差直接丢弃类型合法性过滤——图谱中每个实体都有类型路径上的类型序列必须出现在合法的类型转移矩阵中。路径类型序列是否合法说明中药 → 功效 → 症状合法典型的中药功能查询链方剂 → 组成 → 中药 → 功效 → 症状合法从一个方剂推适应症病名 → 禁忌 → 中药合法查询用药禁忌中药 → 功效 → 中药不合法功效关系不能连接两个中药实体4.2 规则与统计结合过滤参数怎么设路径过滤阶段设定的参数直接影响最终答案质量。max_depth4意味着允许「方剂 → 中药 → 功效 → 症状」这样的四跳路径而「中药 → 功效 → 病名 → 治疗方法 → 方剂」这种五跳就太绕了。对于毕业设计级别的系统max_paths设在 5001000 之间效果比较好太少会丢答案太多会导致排序模块计算卡顿。另一个容易被忽视的参数是「关系排重」路径中出现重复关系对语义贡献不大。比如「中药 → 功效 → 症状 → 功效 → 症状」这种路径虽然理论上可达但逻辑上绕了一圈又回到同类型节点。在path_filter里要检查关系序列是否单调或者同一关系的出现次数是否超过 2 次超过即丢弃。5. 路径特征与答案排序从 path_feature.py 到 ans_bot.py5.1 路径特征工程不只是看路径长度路径过滤完成后候选路径可能还有几十条甚至上百条需要给每条路径打分排序。path_feature.py定义了每条路径的特征向量这些特征是后续规则打分或机器学习排序模型的输入。特征设计是这类基于路径的问答系统的核心特征选得好哪怕用最简单的加权求和也能得到不错的答案。我常用的特征有六类特征名称描述计算方式路径长度边数越短通常越优len(path)关系类型分布不同关系类型的占比统计生成 one-hot 编码尾实体的出现频次尾实体在图谱中被引用次数频次越高越重要kg.get_entity_freq(tail)实体类型置信度实体链接阶段对每个实体的打分来自 entitylink 的输出路径与问句语义相似度路径上的关系名称和问句的余弦相似度关系名 embedding 与问句 embedding关系转移概率路径上相邻关系共同出现的概率统计图谱中关系共现频率最后的路径打分在ans_bot.py中完成。常见做法是对特征做加权线性组合score w1 * 1/len w2 * 语义相似度 w3 * 实体置信度权重系数可以通过在验证集上做网格搜索确定。如果要做得更认真一点可以在path_filter输出后标注好的样本上用XGBoost训练一个排序模型但考虑到课程设计的数据量通常不大手工设计权重反而更可控。def rank_paths(paths, question_embedding, weights): scored_paths [] for path in paths: features extract_features(path, question_embedding) score 0.0 score weights[length] * features[length_score] score weights[semantic] * features[semantic_sim] score weights[confidence] * features[entity_conf] scored_paths.append((path, score)) scored_paths.sort(keylambda x: -x[1]) return [p for p, s in scored_paths[:3]]rank_paths返回 Top-3 路径ans_bot.py再根据路径末端节点的类型生成最终答案。如果末端是症状实体答案就返回「麻黄可用于缓解发热、咳嗽等症状」如果末端是方剂实体则是给推荐方剂。5.2 完整问答流程的串联方式从用户输入到答案输出完整链路是这样的raw_text → mention_extrator → entitylink → path_extrator → path_filter → path_feature → ans_bot。每个模块的输入输出都是标准的数据结构这是这个项目设计上做得比较好的地方——mention_extrator输出带类型的实体列表entitylink输出标准化实体 ID 和置信度path_extrator输出路径列表path_filter输出过滤后的路径和丢弃原因。README.md里关于使用方式的部分如果写清楚了这段数据流转复现起来会非常顺。问答流程.png应该是把这套链路画成了流程图跑通代码之后可以对照检查自己卡在哪一步。6. 进阶调试技巧阈值设置、路径爆炸处理与结果验证我拆过不少知识图谱问答的课设和毕业设计代码这个项目最有参考价值的其实是path_filter和path_feature这两个常常被忽略的模块。很多新手把精力全花在 NER 的准确率上实际跑通后却发现答案质量很差问题往往出现在路径选择和排序上。关于 NER 阈值的设置有具体经验train_ner.py训练出的模型在预测阶段通常输出每个标签的概率分布代码里常用torch.argmax取最大值对应的标签但概率分布里如果最大概率只有 0.4 左右说明模型对当前字的标签很犹豫。常见的做法是加一个概率阈值把低于阈值的标签改为O。我用 0.5 作为基准值效果不错但你自己的数据分布不同可能会略有偏差。一个更实用的小技巧是让mention_extrator支持多套阈值对比用一个ner_threshold参数控制在验证集上测出最佳值——阈值太高会漏实体太低会引入噪音。关于路径爆炸当图谱数据量超过 1 万实体时纯内存的 DFS 遍历会明显变慢。这时候不要急着换 Neo4j有几个中间方案可以先用起来。在path_extrator中加一个「关系白名单」配置只允许遍历预先定义好的关系集合调整搜索策略从 DFS 改成带优先级的 BFS优先扩展与问句 embedding 相似度高的关系类型以起始实体为圆心、按跳数分层存储路径侯选集在path_filter阶段一旦路径数超过阈值就直接截断不再继续枚举。关于验证方法没有标注答案集时可以通过知识图谱自身的一致性来验证——检查答案实体是否与头实体之间存在可解释的关系路径。做一个eval_batch.py输入一组测试问句输出每条问句的 Top-3 路径和最终答案人工检查这些路径是否在逻辑上成立。如果「麻黄 → 发汗 → 感冒」这样的路径出现在结果里说明路径抽取和过滤整体是靠谱的如果出现「麻黄 → 解表 → 黄疸」这种跨界路径则要检查路径的实体类型序列是否在合法转移矩阵中被允许。另外path_feature中的语义相似度特征如果对结果贡献不稳定可以单独把特征值打出来看分布调整权重系数。最后检查一下ans_bot.py的返回格式确认它在找不到任何路径的情况下返回「当前知识库无法回答该问题」而不是抛出异常。这个兜底逻辑在答辩演示时特别重要——只要有一次报错整套系统的可信度就会被打折扣。本文还有配套的精品资源点击获取