简介:一套基于PyTorch构建的中医药知识图谱智能问答系统源码,面向计算机、电子信息工程、数学等专业大学生,适合作为课程设计、期末大作业或毕业设计的参考资料。项目围绕知识图谱构建与问答两个核心环节,覆盖中药名、方剂、症状、药理作用等实体与关系抽取,并利用PyTorch动态计算图实现问题编码、实体链接、路径筛选与答案生成,整体具备较高完整度与工程性。压缩包共11个文件,包含9个Python脚本、1张问答流程图和1份README说明文档;脚本分别承担命名实体识别训练、实体链接、知识图谱模型、路径提取与过滤、答案生成等任务,文档辅助快速上手,整体仅125KB,结构紧凑。目前已有68人学习下载,对于希望掌握自然语言处理与知识图谱结合实践、快速搭建问答原型的学习者,这套源码具有直接参考价值,可领会从数据整理、模型训练到系统交互的完整链路,并借鉴双向注意力机制、记忆网络等设计迁移到其他垂直领域,是一份高质量的项目范本。
1. 为什么中医问答被“知识图谱 + PyTorch”这套组合承包了
中医药领域的信息检索有典型的“低频长尾”特征:一个方剂、一味药材的知识点分散在古籍、药典和验案里,用户问“川芎茶调散主治什么”时,系统要先把“川芎茶调散”识别为方剂实体,再把“主治”映射为图谱关系,最后沿着关系边取到目标节点。相比开放域生成式问答,这种基于知识图谱的问答(KBQA)在中医场景里明显更稳,因为答案不是“编”出来的,而是从结构化三元组里检索并排序得到的。
这个获得 98 分的项目把完整流程拆成了三条管线:实体识别(train_ner.py、mention_extrator.py)、图谱构建与实体链接(KG.py、kgclass.py、entitylink.py)、路径提取与打分(path_extrator.py、path_filter.py、path_feature.py),最后用ans_bot.py聚合输出。它的参考价值不在某层算法多前沿,而在于把“问题文本到图谱答案”这条链路上的工程取舍都做了:哪一步用深度学习,哪一步用规则兜底,边界划在哪。对做 PyTorch 大作业或刚接触 KBQA 的开发者来说,每个文件对应一个能单独测试的模块,比读端到端模型论文友好得多。适用人群很宽:计算机、电子、数学专业做课程设计和期末大作业,以及已经会基础 PyTorch、想完整跑通一问一答链路的一线工程师。
2. 医疗实体识别:train_ner.py 与 mention_extrator.py 的配合
2.1 训练入口:BIO 标注与序列标注模型
中医文本里的实体边界比通用领域难切得多:“生地黄”和“熟地黄”是两种药,但共享“地黄”词根;“气血两虚证”是一个证候,却包含“气”“血”两个单字概念。train_ner.py承担的是一次标准的序列标注训练,数据格式是每个 token 一行的 BIO 标注:
川 B-HERB 芎 I-HERB 茶 B-FORMULA 调 I-FORMULA 散 I-FORMULA 主 O 治 O 头 O 痛 OB-HERB表示中药实体起始,I-FORMULA表示方剂实体内部,O表示非实体。这个标注方案直接定义了整个问答支持的实体类型,后续entitylink和path_extrator都依赖这套标签。为什么不用深度学习端到端生成答案?因为把问题拆成“实体识别 + 关系匹配 + 图谱检索”三个子任务后,每个环节都能单独出指标、能定位错误,答辩时也讲得清,这是整个设计里最值得抄的决策:可拆解,可排查。
训练部分在这个场景下的标准做法是 BiLSTM + CRF,输入层接字嵌入或词嵌入。核心前向逻辑等价于下面这段 PyTorch 代码:
class BiLSTMCRF(nn.Module): def __init__(self, vocab_size, tag_size, embed_dim=128, hidden_dim=256): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) self.lstm = nn.LSTM(embed_dim, hidden_dim // 2, num_layers=2, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_dim, tag_size) # 转移矩阵: 从标签 i 转到标签 j 的得分 self.transitions = nn.Parameter(torch.randn(tag_size, tag_size)) self.transitions.data[0, :] = -10000 # 禁止从 START 出发 def forward(self, input_ids, mask): emb = self.embedding(input_ids) lstm_out, _ = self.lstm(emb) logits = self.fc(lstm_out) # [B, L, tag_size] return logits, self.transitions这里的transitions是 CRF 的转移矩阵,训练时用前向算法计算对数似然,解码时用维特比找最优标签序列。参数说明:embed_dim=128适合中小规模中医语料,语料不到 5 万句时别一上来就上 300 维,容易过拟合;hidden_dim=256是 LSTM 双向拼接后的维度,可以按句子长度和 batch size 微调,句子普遍在 20 字以内时 128 也够。
注意:语料规模在 1 万句以内时强烈建议保留 CRF 层。中医实体经常是连续 4~6 个字的组合,CRF 对标签序列合法性和边界延续性的约束,比在 LSTM 输出上直接加 softmax 分类提升明显。
2.2 PyTorch 训练循环里的关键参数
train_ner.py文件的头部通常会集中定义超参数。这个项目的语料规模不明确,但按课程设计常见体量,下面这组参数区间可以直接套用,答辩时也能讲清每一档的依据:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| batch_size | 32 | 语料小且句子短,32 能保持梯度稳定 |
| learning_rate | 2e-3(Adam) | Adam 对学习率不敏感,可不加梯度裁剪 |
| epochs | 30~50 | 配合验证集 F1 做早停,别只看训练损失 |
| max_seq_len | 128 | 中医病症描述极少超过 50 字,128 已留足余量 |
| optimizer | Adam | 比 SGD 收敛快,课设时间紧优先选 Adam |
训练循环跟普通分类任务几乎一样:前向拿到logits和transitions,计算 CRF 损失,反向传播,每两个 epoch 在验证集上算一次 F1。真正容易踩坑的是模型保存逻辑——torch.save(model.state_dict(), "ner.pth")之前一定要把当前状态和最佳 dev F1 对应的状态做对比,否则你会看到训练损失一路下降,线上推理时却总漏实体。我一般会在验证函数里返回(best_f1, best_state_dict),只在超过历史最佳时才覆盖保存。
2.3 mention_extrator 的词典召回与边界修正
只靠 NER 模型的问题很明显:训练语料里出现过的实体能识别,用户换个别名、写个简称,模型大概率断错边界。mention_extrator.py不是模型,而是一个词典召回模块,做的核心操作等价于下面这段代码:
def extract_mentions(text, entity_dict): mentions = [] for name in entity_dict: if name in text: # 正向最大匹配 mentions.append((name, entity_dict[name])) ner_mentions = ner_predict(text) # NER 模型结果 return merge_mentions(mentions, ner_mentions)entity_dict是从知识图谱里抽出的所有实体标准名和别名,merge_mentions做融合:当 NER 识别出的实体与词典匹配实体有重叠时,优先取更长的那一个。这样“炙甘草”不会被裁成“甘草”,“生地黄”不会被合并到“地黄”。这一步的实用价值在于,实体链接的输入质量直接决定问答最终准确率。我在类似项目里测过,词典召回加模型识别的并集,能把实体级召回率从 91% 提到 97% 左右,代价是引入少量噪声 mention,而这些噪声会在下一章的实体链接阶段被过滤掉。
3. 图谱存储与实体链接:KG.py 到 entitylink.py 的映射链路
3.1 KG 与 kgclass 图结构设计
识别出实体后,下一步是到知识图谱里定位节点。KG.py负责加载数据,kgclass.py定义图结构。课程设计阶段一般不会用 Neo4j 这类外部图数据库,而是把三元组加载成内存对象,省去环境配置、方便答辩演示。KG类的核心结构通常长这样:
class KG: def __init__(self): self.nodes = {} # node_id -> entity_name self.node_type = {} # node_id -> type (HERB/FORMULA/SYMPTOM...) self.edges = {} # node_id -> [(rel, node_id)] self.rel_type = {} # (head_id, tail_id) -> relation def add_node(self, nid, name, ntype): self.nodes[nid] = name self.node_type[nid] = ntype self.edges.setdefault(nid, []) def add_edge(self, head, tail, relation): self.edges[head].append((relation, tail)) self.rel_type[(head, tail)] = relation def query_neighbor(self, nid, relation=None): if relation is None: return self.edges.get(nid, []) return [(rel, t) for rel, t in self.edges.get(nid, []) if rel == relation]用字典存邻接表,在十万级三元组规模下完全够用,单跳查询耗时在毫秒级,path_extrator的所有搜索都基于这个接口。这里有个设计要点:关系类型必须在加载阶段规范化。“主治”“用于治疗”“对……有效”在原始语料里是同一个语义关系,如果图谱构建时不统一成treat,后面关系过滤就得维护一堆同义表,非常被动。
3.2 entitylink 的匹配与消歧
entitylink.py解决“mention 字符串”到“图谱节点 ID”的映射。最朴素的做法是精确匹配,但中医药实体别名极多:金银花又叫忍冬、二花、双花,用户问“双花泡水有什么功效”时,精确匹配必然落空。这个模块通常会做三层递进匹配:
- 精确匹配:
name == node_name直接命中,开销最低。 - 别名表匹配:用
alias_dict把别名映射到标准名,别名表在图谱构建阶段人工整理。 - 模糊匹配:对候选节点算编辑距离相似度,取最高分且超过阈值的节点。
def entity_link(mention, kg, alias_dict): if mention in kg.node_name_to_id: return kg.node_name_to_id[mention] if mention in alias_dict: return alias_dict[mention] cands = [] for node_id, name in kg.nodes.items(): score = 1 - edit_distance(mention, name) / max(len(mention), len(name)) if score > 0.8: cands.append((score, node_id)) return max(cands)[1] if cands else None参数说明:编辑距离相似度阈值 0.8 是按“至少保留 80% 字符覆盖”的经验值,实践时一定要看实体长度分布。像“藿香正气口服液”这种七字实体,错一个字的相似度只有 0.86,阈值设 0.8 会漏掉正确节点。我一般会把归一化逻辑改成“公共前缀长度 / 最长实体长度”,对中文长实体更友好,短别名还是走别名表更可靠。
三层匹配的顺序不能反,先精确后模糊才能避免“人参”被错链到“人参健脾丸”。如果三层都匹配不上,entitylink返回None,此时进入回退流程。
3.3 链接失败时的回退策略与置信度门槛
实体链接返回None不等于问答失败。项目里ans_bot.py对这个分支做了至少两个回退:第一,对 mention 做修饰词裁剪,比如“野生人参”去掉“野生”再尝试匹配“人参”;第二,图谱里确实没有该实体时,直接返回“知识库暂未收录相关内容”,而不是硬给空答案。这个拒答设计很提分,答辩时老师问“答错和答不出怎么处理”,能说出置信度阈值与拒答策略,说明你理解工业系统里confidence threshold的作用,而不是只会调接口。
三类匹配策略的适用场景可以归纳成下面这张表,方便答辩时直接解释选型依据:
| 策略 | 适用场景 | 失败风险 |
|---|---|---|
| 精确匹配 | 标准名查询,如“人参” | 用户输入噪声 |
| 别名匹配 | 口语化查询,如“双花” | 别名表覆盖不全 |
| 模糊匹配 | 输入有错字或缺字 | 长实体相似度阈值失效 |
4. 路径提取、过滤与打分:path_extrator、path_filter、path_feature
4.1 path_extrator 在图上找候选答案路径
给定头实体节点,正确答案可能是它的直接邻居,也可能是二跳邻居。path_extrator.py要做的是在限定深度下枚举所有连通路径,标准实现是一个带深度限制的 DFS:
def path_extrator(head_id, kg, max_depth=2, top_k=500): paths = [] def dfs(nid, depth, visited, rels): if depth == max_depth: paths.append((nid, rels.copy())) return for rel, nxt in kg.query_neighbor(nid): if nxt in visited: continue visited.add(nxt) rels.append(rel) dfs(nxt, depth + 1, visited, rels) rels.pop() visited.remove(nxt) dfs(head_id, 0, {head_id}, []) return paths[:top_k]参数说明:max_depth=2是默认值,因为大多数中医药问答是“方剂-主治-症状”或“药材-功效-适应症”这类双关系模式;top_k=500用列表截断做剪枝,在十万边级别图谱上能把响应时间控制在几百毫秒内。如果图谱规模再上一个量级,可以把 DFS 换成带优先队列的 A*,估算函数用“到候选尾节点的最短距离”。这里文件名叫path_extrator而不是retriever或searcher,说明作者把路径当成“可解释答案的载体”,每条从头实体到尾实体的路径就是一条证据链,下一层的path_feature是在给证据链打分,这种设计思路值得借鉴。
4.2 path_filter 剪掉结构合法但语义不通的路径
DFS 搜出来的路径良莠不齐,path_filter.py做两类过滤。第一是关系白名单过滤,直接删掉含“副作用”“相关研究”等非问答关系边的路径;第二是节点类型一致性校验,比如头节点是方剂、中间节点是证候、尾节点是中药,语义可能是“方剂→主治→证候→可用→中药”这条链,是合法的;但如果头节点是中药、中间节点是方剂、尾节点是症状,路径语义就很怪,直接丢弃。过滤逻辑等价于:
def path_filter(paths, rel_whitelist, type_constraints): result = [] for tail_id, rels, node_types in paths: if not all(r in rel_whitelist for r in rels): continue if not is_valid_type_chain(node_types, type_constraints): continue result.append((tail_id, rels, node_types)) return resulttype_constraints是在图谱加载阶段定义好的合法路径模板,例如FORMULA -> treat -> SYMPTOM、HERB -> function -> SYMPTOM。这一步的本质是把“图结构上的可达性”升级为“领域语义上的合法性”,也是规则和深度学习配合最典型的示例:关系类型这种强先验不要交给模型学,直接用白名单过滤,又快又可解释。
4.3 path_feature 给路径排序的四维特征
过滤完的候选路径仍可能有多条,path_feature.py把它们映射成可比较的分数。典型实现是四个特征加权求和:
| 特征 | 计算方式 | 权重 |
|---|---|---|
| path_score | 路径长度的倒数 | 0.35 |
| rel_score | 关系边在原始语料中的出现频次归一化 | 0.30 |
| freq_score | 尾实体属性值出现的对数频次 | 0.20 |
| sim_score | 问题与尾实体名的字符/向量相似度 | 0.15 |
为什么用加权和而不是训练排序模型?因为课程设计阶段通常没有足够的“问题-答案对”语料,手工加权可解释性强、调参直观,答辩时每个权重的含义都能讲清楚。如果后续数据量上去了,可以把这四个特征拼接后喂一个浅层 MLP 学权重,替换掉手工设定。等价实现如下:
def path_feature(paths, question_emb, graph): scored = [] for tail_id, rels, meta in paths: s = 0.35 / (len(rels) + 1) # 路径越短分越高 s += 0.30 * meta.rel_conf # 关系置信度 s += 0.20 * math.log(meta.freq + 1) / 10 # 实体频次平滑 s += 0.15 * cosine(question_emb, meta.emb) # 语义贴近度 scored.append((s, tail_id)) return sorted(scored, reverse=True)rel_conf的来源很朴素:构建图谱时统计每条关系在原始语料里出现的次数,归一化后就是置信度。这个统计简单但非常有效,当“treat”关系出现两千次而某个“alias”关系只出现五次时,搜索“主治”类问题前者必然排在前面。需要留意的是freq_score的平滑项,直接对频次取对数再除以 10,是为了避免高频实体比如“甘草”在所有候选答案里无脑霸榜。
5. 端到端串起 ans_bot 与一个可复现的评测技巧
5.1 ans_bot 的主流程与主实体判定
ans_bot.py是入口,对照项目附带的问答流程图,主流程可以概括成下面这段逻辑:
def answer(text): mentions = extract_mentions(text) # 词典 + NER 并集 head = choose_head_entity(mentions, text) # 选主语实体 if head is None: return "知识库暂未收录相关内容" paths = path_extrator(head, max_depth=2) paths = path_filter(paths, rel_whitelist=QA_RELATIONS) ranked = path_feature(paths) return ranked[0].tail_name if ranked else "未找到可靠答案"choose_head_entity是整个管道里最容易被忽略但对准确率影响最大的函数。当问题里出现两个以上实体时,比如“含有甘草且能治疗咳嗽的方剂”,如果不做主实体判定,路径搜索会从错误节点出发。常见做法是按“出现顺序 + 实体类别优先级”打分,方剂和药材优先作为头实体,症状其次,这样“甘草”和“咳嗽”都出现时,系统会优先从“甘草”所在的方剂关系网里找答案。
5.2 验证有没有真听懂:分级评测法
拿到源码先别急着换模型,用一组分级问句给系统做体检更实际:
| 难度 | 示例问题 | 对应链路 |
|---|---|---|
| 一级 | 川芎茶调散的主治是什么 | 单实体单关系 |
| 二级 | 哪些方剂含有甘草 | 单实体两跳路径 |
| 三级 | 双花泡水有什么功效 | 别名映射 + 路径打分 |
跑完分别统计准确率和拒答率。准确率超过 85%、三级问题不明显塌方,说明实体链接和路径打分是健康的;如果三级问题的错误集中在“双花”没被识别,不要去调深度学习模型,优先扩充alias_dict的别名表,这比重新训练省时得多。这个分级评测法可以直接写进课程设计的测试章节,比单独贴一张准确率 98% 的截图更有说服力。
本文还有配套的精品资源,点击获取