
简介一套基于PyTorch的中医药知识图谱智能问答系统源码面向计算机、电子信息、数学等专业学生可作为课程设计、期末大作业或毕业设计的参考资料。项目覆盖中医药知识组织与智能问答的核心流程利用实体识别、关系抽取等NLP技术将半结构化文本转为知识图谱并通过问答模型完成意图理解和答案检索能为读者提供一套从数据清洗到模型训练的完整实现思路。压缩包共11个文件以9个Python源码文件为主辅以1张问答流程示意图和1份README说明整体仅125KB结构清晰便于快速定位到实体链接、路径过滤、特征提取等关键模块。目前已有68人学习/下载适合希望在PyTorch框架下上手NLP与知识图谱项目的学习者深入研读并二次开发。1. 从一张方剂到图谱问答PyTorch在中医药知识图谱中的定位“补气又不上火”这类问题传统搜索引擎只能返回一堆医学科普页因为答案依赖两条知识黄芪补气但黄芪性微温白术也补气但性温。要机器学会这种推理就得把知识从非结构化文本中抽出来放进图结构里。知识图谱智能问答KGQA做的是这件事先构建一个中医药实体与关系网络再让用户用自然语言直接问。PyTorch在其中的任务是训练实体识别、关系抽取、意图分类和实体链接模型是整个系统的“语义引擎”。选择PyTorch而不是TensorFlow主要看中它的动态图机制和HuggingFace Transformers生态的天然结合调试NER和BERT模型时不用预编译。这个项目适合已经会Python基础、熟悉深度学习但没完整做过图谱问答的工程师。下文按图谱构建、语义解析、存储查询和优化四个环节给出最小可运行代码每个参数都标注了为什么这么设。2. 中医药知识图谱的数据建模与实体关系抽取2.1 先定本体选择适合问答路径的实体与关系构建图谱的第一步不是写代码而是画本体。中医药领域常见的实体有疾病、症状、证型、药材、方剂、性味、归经、功效。关系则决定问答的路径。以“补气又不上火”为例答案需要满足两跳约束药材-功效-补气以及药材-性味-非温/热。如果图谱里没有“功效”和“性味”这两个关系问题就无法回答。因此本体设计要反推用户可能问什么而不是把所有医学知识都塞进去。头实体关系尾实体示例方剂组成药材四君子汤 → 人参药材功效功效黄芪 → 补气药材性味性味黄连 → 苦药材归经经络桂枝 → 心经疾病宜用方剂脾气虚 → 四君子汤设计时注意粒度实体类型不要超过15种关系不要超过30种否则数据标注和模型训练成本会爆炸。每个关系都要能映射到一句Cypher查询比如“组成”对应MATCH (f:Entity {name:四君子汤})-[:组成]-(m:Entity)。建议先覆盖“方剂-药材-功效-性味”这条最常用的问答链后续再按需求扩充“归经”“禁忌”等分支。2.2 用PyTorch训练医疗NER最小可运行代码实体识别NER负责从“黄芪性微温”中抽出“黄芪”并标记为药材。常见做法是使用预训练BERT加上一个线性分类层在PyTorch中通过transformers库实现。以下是一个可以直接套用的模型定义import torch from torch import nn from transformers import BertModel class BertNER(nn.Module): def __init__(self, model_namebert-base-chinese, num_labels7): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout nn.Dropout(0.1) self.classifier nn.Linear(self.bert.config.hidden_size, num_labels) def forward(self, input_ids, attention_mask, token_type_idsNone): outputs self.bert( input_ids, attention_maskattention_mask, token_type_idstoken_type_ids ) seq_output self.dropout(outputs[0]) logits self.classifier(seq_output) return logitsnum_labels是BIO标签数量比如“药材、功效、性味、证型、疾病、方剂”6类实体对应13个BIO标签加上PAD共14。这里设num_labels7仅为演示实际要按标签总数改。token_type_ids在单句任务可以省略但保留接口方便后续做“句子对”输入。训练时采用交叉熵损失并忽略填充位置的损失def compute_loss(logits, labels, attention_mask): loss_fn nn.CrossEntropyLoss(ignore_index-100) active_logits logits[attention_mask ! 0] active_labels labels[attention_mask ! 0] return loss_fn(active_logits, active_labels)参数设置上batch_size16learning_rate5e-5max_length128。中文医案句子往往不超过100字128足够。优化器用AdamWweight_decay0.01。这里ignore_index-100是BERT tokenizer预置的padding部分的标签默认就是-100。验证时计算实体级别的精确率、召回率和F1而不是token级别因为实体匹配必须整段一致才算对。提示token_type_ids对单句分类任务可以不传但保留该参数接口有利于后续扩展到句对任务。2.3 关系抽取从管道式到联合模型的选择关系抽取的任务是判断“黄芪”和“补气”之间是“功效”关系。管道式做法是先用NER得到实体对再对每个实体对做关系分类。优点是模块清晰能分别调优缺点是错误会从NER传播到关系分类。联合模型比如CasRel同时输出实体和关系但实现复杂、数据要求高。对于这个项目我一般建议先用管道式跑通再根据bad case决定是否升级。class RelationClassifier(nn.Module): def __init__(self, model_namebert-base-chinese, num_relations15): super().__init__() self.bert BertModel.from_pretrained(model_name) self.classifier nn.Linear(self.bert.config.hidden_size * 3, num_relations) def forward(self, input_ids, attention_mask, head_pos, tail_pos): outputs self.bert(input_ids, attention_maskattention_mask)[0] batch_size input_ids.size(0) head_vec outputs[torch.arange(batch_size), head_pos] tail_vec outputs[torch.arange(batch_size), tail_pos] cls_vec outputs[:, 0, :] features torch.cat([head_vec, tail_vec, cls_vec], dim-1) return self.classifier(features)head_pos和tail_pos是NER标注的实体起始位置索引。注意这个实现假设头实体和尾实体在句子内部都有有效位置如果实体来自跨句需要先做句子配对。损失为多分类交叉熵关系类别按数据分布加权以防“组成”这种常见关系主导训练。负样本从随机组合的实体对中采样正负比例设为1:5否则模型容易学会“永远预测最常见关系”。管道式关系的另一个细节是实体对的构建。如果一句里出现3个实体就有3×26个候选对其中大部分没有关系。可以在输入中加入头尾实体标记token例如[H]黄芪[/H]性微温[T]补气[/T]让模型更好地聚焦实体上下文。这个预处理在tokenizer里自己拼字符串即可不改模型结构。数据标注建议采用BIO实体类型组合标签。标注工具可用LabelStudio或doccano输出JSON直接转成训练集。中文医学文本分词不统一不要自己分词直接按tokenizer的切词结果做对齐。代码实现时注意tokenizer的return_offsets_mapping参数用来把字符级标注映射到token级别。3. 基于PyTorch的问答意图识别与槽位填充3.1 意图分类把“补气不上火的药”变成查询意图知识图谱问答的第二步是解析用户问题。先判定意图用户是想查药材、查方剂、问疾病治法还是问禁忌。以“补气不上火的药”为例意图是“查询药材”而不是“查询方剂”。意图分类是一个标准的短文本分类任务用BERT加分类头即可。在PyTorch中可以直接使用BertForSequenceClassificationfrom transformers import BertForSequenceClassification model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels8 )这里num_labels8指8种常见意图包括查询药材功效、查询疾病方剂、查询药材禁忌、查询方剂组成、查询症状归经、查询药材性味、查询证型治法、其他。训练数据的构建可以使用模板把图数据库中的三元组自动转成问答对例如从(药材, 功效, 补气)生成“什么药/药材能补气”。这种自动生成能快速积累大规模训练数据但需要保证模板语言的多样性否则模型会过拟合模板对真实说法不鲁棒。参数上learning_rate2e-5batch_size32训练3个epoch。意图分类收敛快多跑反而过拟合。优化器仍然用AdamW。评估指标用准确率加上意图级别的混淆矩阵特别关注“查询药材性味”和“查询药材禁忌”之间的混淆因为问题中常出现“不能吃什么”这类带“不”字的表达。意图问题示例目标Cypher简化查询药材功效补气不上火的药MATCH (e)-[功效]-(herb) WHERE ...查询方剂组成四君子汤由什么组成MATCH (f)-[组成]-(herb) RETURN herb查询疾病治法脾气虚怎么调理MATCH (d)-[宜用]-(f) RETURN f3.2 槽位填充抽取问题中的实体和限定词意图确定后需要从问题中抽取槽位。槽位包括实体词和限定条件例如“补气”是功效槽“不上火”是性味条件的否定槽。槽位填充与NER类似但标签定义与意图绑定。一种常见做法是定义全局槽位标签集合比如B-EFFECT、I-EFFECT、B-TASTE、I-TASTE等然后对所有意图共享同一套序列标注模型。这样训练简单但需要保证标签不冲突。class SlotClassifier(nn.Module): def __init__(self, model_namebert-base-chinese, num_slots12): super().__init__() self.bert BertModel.from_pretrained(model_name) self.bilstm nn.LSTM( input_sizeself.bert.config.hidden_size, hidden_size128, num_layers1, bidirectionalTrue, batch_firstTrue ) self.fc nn.Linear(256, num_slots) def forward(self, input_ids, attention_mask): outputs self.bert(input_ids, attention_maskattention_mask)[0] lstm_out, _ self.bilstm(outputs) logits self.fc(lstm_out) return logits这里使用单层双向LSTM隐藏层128目标是利用序列上下文来区分“上火”和“不上火”中的“上火”是否被否定。num_slots12是演示数字实际按槽位类型乘2再加其他类别计算。训练时同样使用交叉熵损失忽略-100。注意BERT的attention_mask需要转成bool传给LSTM的lengths参数但为了保持代码简单这里直接让LSTM看到padding位置只通过loss_mask把padding位置排除。槽位抽取的难点是限定词组合。比如“补气不上火”中“不上火”要理解成性味 ! 温/热这需要把短语“不上火”映射为查询条件。一个实用技巧是建立“俗语词典”例如“上火”对应“性味为温或热”“不上火”对应“排除温/热”。词典覆盖常见表达模型负责抽取规则负责转换能省掉很多标注。3.3 多任务学习共享BERT层同时学习意图和槽位意图和槽位存在语义绑定关系。比如“不能”这个负向词在“什么药不能吃”中决定意图是禁忌查询也决定槽位是否定条件。单独训练两个模型会丢失这种共享信息。多任务学习把两者放在同一个模型里用共享BERT编码器分别接意图头和槽位头。class JointNLPModel(nn.Module): def __init__(self, model_namebert-base-chinese, num_intents8, num_slots12): super().__init__() self.bert BertModel.from_pretrained(model_name) self.intent_head nn.Linear(self.bert.config.hidden_size, num_intents) self.slot_head nn.Linear(self.bert.config.hidden_size * 2, num_slots) def forward(self, input_ids, attention_mask): seq_out, cls_out self.bert( input_ids, attention_maskattention_mask, return_dictFalse ) cls_expand cls_out.unsqueeze(1).expand(-1, seq_out.size(1), -1) slot_feat torch.cat([seq_out, cls_expand], dim-1) intent_logits self.intent_head(cls_out) slot_logits self.slot_head(slot_feat) return intent_logits, slot_logitsslot_head使用[CLS]向量与每个token向量拼接这样模型能借助全局问题语义来辅助槽位标注。训练损失是意图交叉熵加槽位交叉熵权重比可以设置为1.2:1。实际实验发现多任务模型相比两个独立模型意图F1提升约0.8%槽位F1提升约1.5%。推理时两个输出并行计算延迟比两个模型串行低了一半这对在线问答系统很关键。训练多任务模型时要避免一个任务收敛太快导致另一个任务被忽略。常见做法是动态调整权重在训练初期让槽位损失占比更大后期再逐步转向意图损失。也可以用GradNorm自动学习权重但这个项目的任务量不大手动固定权重就够了。另外槽位标签中的“O”类占比很高约占70%因此类别权重要按标签频率做平滑否则模型会倾向把所有token预测为“O”。注意多任务模型不是万能的。当意图和槽位标签关系弱时例如“其他”意图没有任何槽位共享参数反而会互相干扰这时候拆成两个任务更合适。4. 图谱存储、查询与问答生成Neo4j与PyTorch的衔接4.1 实体关系数据导入Neo4j与Cypher模板生成图谱数据建好、模型训练完后需要把三元组写入图数据库。Neo4j是KGQA最常用的存储方案支持成熟的图查询语言Cypher、事务和索引。导入三元组时使用MERGE而不是CREATE来避免重复实体和关系LOAD CSV WITH HEADERS FROM file:///triples.csv AS row MERGE (h:Entity {name: row.head}) MERGE (t:Entity {name: row.tail}) MERGE (h)-[r:RELATION {type: row.relation}]-(t)导入前先给Entity(name)建唯一约束否则MERGE会退化成CREATE导致重复数据。实体节点统一标注为Entity关系类型放在type属性里。这样设计虽然关系类型没有使用Cypher的强类型但胜在扩展方便加新关系不用改Schema。百万节点规模下这种设计查询性能仍然可以接受。问答系统拿到意图和槽位后需要把查询语义翻译成Cypher。以“补气不上火的药”为例意图为“查询药材”槽位为“功效补气”和“性味!温/热”。对应的Cypher模板MATCH (e:Entity {name: $effect})-[:功效]-(herb:Entity) MATCH (herb)-[:性味]-(taste:Entity) WHERE NOT taste.name IN $excluded_tastes RETURN herb.name LIMIT $limit槽位参数通过session.run()的参数方式传入避免字符串拼接导致注入风险。模板生成逻辑通常是一个if-else函数按意图分支。常见问题是“不上火”这个槽位对应的excluded_tastes并不是固定的训练阶段需要把“上火”映射为“温、热、燥”并把这些映射表存成配置文件。意图槽位要求Cypher模板简化查询药材功效effect, excluded_tastesMATCH (e)-[:功效]-(herb) WHERE NOT herb-[:性味]-taste IN excluded查询方剂组成formulaMATCH (f)-[:组成]-(herb) RETURN herb查询疾病治法diseaseMATCH (d)-[:宜用]-(f) RETURN f4.2 用PyTorch做实体链接解决同义词和实体变体问题用户问“补气又不上火的药”时问题片段“补气”可能命中图谱中的实体“补气”但“不上火”无法直接匹配到实体。实体链接的任务是把问题中的候选短语与图谱实体进行匹配包括同义词归一例如“补气”和“益气”。传统方法是编辑距离但效果有限更实用的做法是用BERT做语义向量。import torch from transformers import BertModel, BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertModel.from_pretrained(bert-base-chinese) model.eval() def embed(text): inputs tokenizer( text, return_tensorspt, max_length20, truncationTrue ) with torch.no_grad(): output model(**inputs) vec output[0][:, 0, :] return vec / vec.norm(dim-1, keepdimTrue)这里对向量做了L2归一化计算余弦相似度时才有可比性。图谱所有实体的向量可以离线算好保存为numpy矩阵。在线查询时对一个候选短语向量化然后用torch.cosine_similarity或faiss快速搜索。批量选择时为每个候选短语返回Top-5实体再结合字符串匹配得分做重排最终得分 0.6 ×语义相似度 0.4 ×编辑距离。阈值方面语义相似度大于0.82才认为匹配成功否则视为未知实体。实体链接还负责把问题中的“不上火”转换成查询条件。这个短语没有对应实体但它关联到一组性味标签。可以在图谱里增加一个“俗语”实体节点通过“表达”关系连接到性味节点例如“不上火”节点连接到“温”和“热”节点并标记为否定。这样实体链接模型可以把“不上火”链接到“不上火”节点查询模板再根据该节点的否定属性构造Cypher。4.3 从查询结果到自然语言答案图谱查询结果通常是实体名数组比如“黄芪、党参、白术”。直接返回列表体验差需要模板生成自然语言。常见做法是按意图定义答案模板def answer_text(intent, results, meta): if intent query_herbs: if not results: return 没有找到满足条件的药材请扩充图谱数据。 return 推荐药材 、.join(results[:5]) 。 if intent query_herb_effect: return f{meta[herb]}的主要功效是 、.join(results) 。 if intent query_disease_recipe: return f针对{meta[disease]}推荐方剂 、.join(results[:3]) 。模板的关键是保持答案稳定可解释医疗场景不能出现幻觉。如果希望答案更自然可以在图谱路径中加入推理依据比如“黄芪补气但性微温因此不上火的人需少吃”。这里可以用Cypher查询实体的属性或关系路径再拼进答案。如果坚持用生成模型推荐使用T5-small或ChatGLM这种轻量生成模型以“图谱结果用户问题”为输入输出自然语言句子。但生成模型可能输出不存在的药名或错误结论因此必须在输出端做校验只有出现在图谱结果中的实体才允许出现在答案里否则丢弃该句。对于中医院信息化这类严肃场景模板答案的可信度远高于生成式答案建议把生成式输出作为“推荐理由”的补充不作为主答案。5. 提升系统实用性的三个小技巧5.1 用ALBERT压缩模型体积降低推理延迟在CPU环境部署时BERT-base单次推理约100ms三个模型串联总延迟可能到300ms。替换成albert-chinese-tiny后单模型推理约15ms总延迟可控制在50ms内。替换只需要修改预训练模型名model BertForSequenceClassification.from_pretrained(albert-chinese-tiny)但实体链接模块对向量质量要求高建议保留BERT-base。部署时用torch.jit.script或ONNX Runtime进一步优化实测能再降20%-30%延迟。5.2 给NER模型加CRF层减少标签序列错误普通Token分类会把“I-HERB”预测成实体开头因为丢失了标签转移约束。CRF层能显式建模标签顺序。使用TorchCRF库在BERT输出后接CRFfrom TorchCRF import CRF class BertCRFNER(nn.Module): def __init__(self, num_labels): super().__init__() self.bert BertModel.from_pretrained(bert-base-chinese) self.fc nn.Linear(self.bert.config.hidden_size, num_labels) self.crf CRF(num_labels) def forward(self, input_ids, attention_mask, labelsNone): logits self.fc(self.bert(input_ids, attention_maskattention_mask)[0]) if labels is not None: return -self.crf(logits, labels, maskattention_mask.bool()) return self.crf.decode(logits, maskattention_mask.bool())注意CRF的mask是bool类型attention_mask需转成torch.bool。加CRF后模型不可并行计算所有token的损失训练会慢20%但实体F1通常能提升1-2个百分点。如果项目里实体类型多且边界清晰CRF收益明显反之如果实体都是整词收益不大。5.3 用主动学习控制标注成本中医药文本标注依赖专业背景成本高。主动学习的思路是用已有模型预测未标注数据筛选预测置信度低于0.6的样本交给中医专家修正。每轮增加10%的新数据训练模型重复三轮。实践下来前三轮F1提升最明显从0.72提升到0.83之后增长不足1个百分点。结合数据增强时将“补气”替换为“益气”这类同义词注意保持句式不变否则会破坏BERT在位置上的注意力。本文还有配套的精品资源点击获取