简介:基于中医药领域知识图谱的智能问答系统项目包,面向知识图谱、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, relation=None): """查询实体的邻居节点,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, bidirectional=True, batch_first=True) self.classifier = nn.Linear(512, num_labels) self.crf = CRF(num_labels, batch_first=True) def forward(self, input_ids, attention_mask, labels=None): outputs = self.bert(input_ids, attention_mask=attention_mask) lstm_out, _ = self.bilstm(outputs.last_hidden_state) logits = self.classifier(lstm_out) if labels is not None: return -self.crf(logits, labels, mask=attention_mask.bool()) return self.crf.decode(logits, mask=attention_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_k=10): """基于编辑距离和字符重叠率生成候选实体""" 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(key=lambda x: (-x[1], x[2])) return [c[0] for c in candidates[:top_k]]字符重叠率是个很粗糙但有效的指标。汉语医学实体通常 2~6 个字,「麻黄」和「麻黄根」的重叠率是 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_depth=4, max_paths=1000): """从起始实体出发,枚举所有不超过 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_depth=4意味着允许「方剂 → 中药 → 功效 → 症状」这样的四跳路径,而「中药 → 功效 → 病名 → 治疗方法 → 方剂」这种五跳就太绕了。对于毕业设计级别的系统,max_paths设在 500~1000 之间效果比较好,太少会丢答案,太多会导致排序模块计算卡顿。
另一个容易被忽视的参数是「关系排重」:路径中出现重复关系对语义贡献不大。比如「中药 → 功效 → 症状 → 功效 → 症状」这种路径,虽然理论上可达,但逻辑上绕了一圈又回到同类型节点。在path_filter里要检查关系序列是否单调,或者同一关系的出现次数是否超过 2 次,超过即丢弃。
5. 路径特征与答案排序:从 path_feature.py 到 ans_bot.py
5.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(key=lambda 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的返回格式,确认它在找不到任何路径的情况下返回「当前知识库无法回答该问题」而不是抛出异常。这个兜底逻辑在答辩演示时特别重要——只要有一次报错,整套系统的可信度就会被打折扣。
本文还有配套的精品资源,点击获取