简介:本资源为基于Python的知识库问答Seq2Seq模型代码实现包,面向具备一定深度学习基础、希望动手搭建智能问答系统的开发者与学习者。内容围绕编码器-解码器架构展开,涵盖数据预处理、词汇表构建、模型搭建、训练优化、评估部署等完整流程,并涉及注意力机制与知识库信息整合思路。压缩包共21个文件,约3.39MB,以py脚本为主,包含模型定义、训练与预测入口,另配有train、test数据文件、json格式的WebQuestions样例、vocab词汇表及sta统计文件,便于直接复现实验。已有2093人学习下载,读者可借此理解Seq2Seq在问答任务中的落地方式,掌握从语料处理到答案生成的代码组织逻辑,并在此基础上尝试Transformer或预训练模型等改进方向。
1. 从零理解基于 Python 的知识库问答 seq2seq 模型:它到底解决什么问题
很多团队做知识库问答,第一反应是上 RAG:向量检索 + 大模型拼上下文。但真到落地时会发现两个尴尬:一是检索回来的段落经常答非所问,二是大模型在垂直领域张口就编。这时候一条被低估的路线就浮出来了——用 seq2seq 模型把「用户问题」直接映射成「知识库里的标准答案」,本质上是把问答做成一个可控的生成任务,而不是让模型自由发挥。
这篇讲的就是基于 Python 的知识库问答 seq2seq 模型代码实现:从数据构造、词表、编码器解码器、训练循环,到推理时怎么把知识库里的候选答案喂进去。它适合两类人:一类是想搞懂 seq2seq 在问答场景里到底怎么用、不想只停留在调库的工程师;另一类是手里有一批「问题-答案」对、想先跑一个不依赖外部大模型的最小可用问答系统的从业者。全程 Python + PyTorch,代码可抄,参数可调,坑我替你踩过一遍。
2. 数据与词表:知识库问答的输入到底长什么样
2.1 为什么问答任务不能直接套翻译的 seq2seq
翻译任务里,源语言和目标语言是两套独立词表,输入输出长度接近。问答不一样:用户问题通常很短(十几个字),知识库答案可能是一整段(几十到上百字),而且答案里的实体、术语大量来自知识库本身。如果你直接拿通用中文词表去编码,专业术语会被切成碎片,解码时模型根本拼不回来。
常见做法是:源端(问题)用字级或词级分词都行,目标端(答案)尽量保留知识库里的原始 token,尤其是实体名、编号、专有名词。我一般会先统计知识库里出现频次高但通用分词器切得稀碎的词,把它们加进自定义词表。这一步不做,后面训练 loss 降得再低,推理出来的答案也是缺胳膊少腿。
另一个关键点是:纯 seq2seq 生成答案容易跑偏,所以工业上更稳的做法是「检索 + 生成」两段式——先用检索从知识库捞出 Top-K 候选答案,再让 seq2seq 在这些候选里做选择或改写。本文的代码实现会先按纯生成跑通,再在推理章讲怎么接检索候选。
2.2 构造 (问题, 答案) 训练对的三个来源
知识库问答的训练数据不会凭空出现,实操里主要靠三种方式攒:
| 来源 | 做法 | 适用场景 |
|---|---|---|
| 人工标注 | 运营/客服整理真实用户问题与标准答案 | 冷启动,量少但质量高 |
| FAQ 挖掘 | 从历史工单、聊天记录里抽问答对 | 有存量数据的团队 |
| 模板生成 | 用实体+模板批量造问句,答案取知识库字段 | 知识库结构化程度高 |
模板生成是最容易被低估的一招。比如知识库里有「产品名-参数-值」这种结构化字段,你可以写几十个问句模板:「XX 的参数是多少」「XX 支持什么」,答案直接取字段值。这样一天能造几千条,虽然问句多样性差,但足够让模型学会「问题→答案字段」的映射关系。
数据清洗上,我一般会做三件事:去掉答案长度超过 128 字的样本(太长训练慢且容易截断)、去掉问题和答案完全重复的、对答案做统一标点处理。别小看标点,中文问答里全角和半角混用会让词表凭空多出一堆低频 token。
2.3 用 Python 搭一套可复用的词表与 Dataset
下面这段是词表构建 + Dataset 的最小实现,直接可跑。核心思路:源端和目标端共用一套词表(问答场景下问题和答案用词重叠度高,共用能减少参数量),特殊符号单独占位。
import re from collections import Counter from torch.utils.data import Dataset # 特殊符号:PAD补齐、SOS解码起始、EOS解码结束、UNK未登录词 PAD, SOS, EOS, UNK = "<pad>", "<sos>", "<eos>", "<unk>" def tokenize(text): # 中文按字切,英文数字按词切,够用且不依赖第三方分词器 text = re.sub(r"\s+", "", text.strip()) return list(text) def build_vocab(pairs, min_freq=2, max_size=20000): counter = Counter() for q, a in pairs: counter.update(tokenize(q)) counter.update(tokenize(a)) # 特殊符号永远保留,其余按频次过滤 vocab = {PAD: 0, SOS: 1, EOS: 2, UNK: 3} for token, freq in counter.most_common(max_size): if freq >= min_freq and token not in vocab: vocab[token] = len(vocab) return vocab class QADataset(Dataset): def __init__(self, pairs, vocab, max_q_len=32, max_a_len=128): self.vocab = vocab self.max_q_len = max_q_len self.max_a_len = max_a_len self.samples = [] for q, a in pairs: q_ids = self._encode(q, max_q_len, add_sos=False) # 答案需要 SOS 开头、EOS 结尾,解码器才知道从哪开始、到哪停 a_ids = self._encode(a, max_a_len, add_sos=True) self.samples.append((q_ids, a_ids)) def _encode(self, text, max_len, add_sos): ids = [self.vocab.get(t, self.vocab[UNK]) for t in tokenize(text)] if add_sos: ids = [self.vocab[SOS]] + ids ids = ids[:max_len] ids = ids + [self.vocab[EOS]] if not add_sos else ids + [self.vocab[EOS]] return ids def __len__(self): return len(self.samples) def __getitem__(self, idx): return self.samples[idx]逻辑说明:build_vocab用Counter统计所有问答对的 token 频次,min_freq=2过滤掉只出现一次的低频词,避免词表被噪声撑大。QADataset._encode里有个容易翻车的细节——问题端不加 SOS,答案端必须加 SOS 和 EOS,因为解码器训练时要用「SOS + 答案前缀」预测「答案 + EOS」,这是 teacher forcing 的前提。
参数说明:max_q_len=32对绝大多数中文问句够用,超过就截断;max_a_len=128是答案上限,如果你的知识库答案普遍更长,可以调到 256,但显存和训练时间会明显上升。max_size=20000是词表上限,垂直领域知识库一般 1 万以内就够,设太大反而让 embedding 层浪费参数。
提示:如果你的知识库里有大量英文术语或编号,
tokenize里的按字切会把它们拆散,建议改成先用正则把连续英文数字抽成一个 token,再对中文按字切。
3. 编码器-解码器结构:把 seq2seq 问答模型搭起来
3.1 为什么问答场景优先选 GRU 而不是 Transformer
一提 seq2seq,很多人条件反射上 Transformer。但在知识库问答这种「数据量不大、答案偏短、部署资源有限」的场景里,GRU 版编码器-解码器往往是更务实的选择。原因有三:一是 GRU 参数量小,几千到几万条问答对就能训到收敛,Transformer 没个几十万条容易过拟合;二是推理延迟低,CPU 上也能跑;三是结构简单,出问题好排查,不像注意力那样一堆玄学。
当然,如果你数据量足够、答案很长、还想要更好的生成质量,加注意力机制的 GRU 或者直接上 Transformer 都行。本文先用带注意力的 GRU 打底,这是性价比最高的起点。注意力机制的作用是让解码器每一步都能「回头看」问题编码的哪些位置,避免长问题信息被压成一个固定向量后丢失。
3.2 编码器、注意力、解码器的代码实现
import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, vocab_size, embed_dim=256, hidden_dim=512, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.gru = nn.GRU(embed_dim, hidden_dim, batch_first=True, bidirectional=True) # 双向 GRU 输出 2*hidden,投影回 hidden 给解码器用 self.fc = nn.Linear(hidden_dim * 2, hidden_dim) self.dropout = nn.Dropout(dropout) def forward(self, src): # src: [batch, q_len] embedded = self.dropout(self.embedding(src)) outputs, hidden = self.gru(embedded) # hidden: [2, batch, hidden] -> 拼接后投影 hidden = torch.tanh(self.fc(torch.cat([hidden[0], hidden[1]], dim=-1))) return outputs, hidden.unsqueeze(0) class Attention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.attn = nn.Linear(hidden_dim * 2, hidden_dim) self.v = nn.Linear(hidden_dim, 1, bias=False) def forward(self, decoder_hidden, encoder_outputs, mask=None): # decoder_hidden: [batch, hidden] -> 扩展成 [batch, q_len, hidden] q_len = encoder_outputs.size(1) dec = decoder_hidden.unsqueeze(1).repeat(1, q_len, 1) energy = torch.tanh(self.attn(torch.cat([dec, encoder_outputs], dim=-1))) scores = self.v(energy).squeeze(-1) # [batch, q_len] if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) weights = F.softmax(scores, dim=-1) context = torch.bmm(weights.unsqueeze(1), encoder_outputs).squeeze(1) return context, weights class Decoder(nn.Module): def __init__(self, vocab_size, embed_dim=256, hidden_dim=512, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) self.attention = Attention(hidden_dim) # 输入是 上一词embedding + context向量 self.gru = nn.GRU(embed_dim + hidden_dim, hidden_dim, batch_first=True) self.fc = nn.Linear(hidden_dim * 2 + embed_dim, vocab_size) self.dropout = nn.Dropout(dropout) def forward(self, input_token, hidden, encoder_outputs, mask=None): # input_token: [batch] 单个时间步 embedded = self.dropout(self.embedding(input_token.unsqueeze(1))) # [batch,1,embed] context, weights = self.attention(hidden.squeeze(0), encoder_outputs, mask) rnn_input = torch.cat([embedded, context.unsqueeze(1)], dim=-1) output, hidden = self.gru(rnn_input, hidden) output = output.squeeze(1) # 拼接 output、context、当前词embedding,增强表达 pred = self.fc(torch.cat([output, context, embedded.squeeze(1)], dim=-1)) return pred, hidden, weights逻辑说明:编码器用双向 GRU,前向和后向的最终隐状态拼接后经fc投影,作为解码器初始隐状态。注意力模块把解码器当前隐状态和编码器每个位置的输出做对齐打分,softmax 得到权重后加权求和成 context 向量。解码器每一步的输入是「上一时刻词 embedding + context」,输出再和 context、embedding 拼接后过全连接,映射到词表维度。
参数说明:embed_dim=256、hidden_dim=512是中小规模问答任务的稳妥配置,数据量上万条可以适当加大。dropout=0.3是防过拟合的关键,问答数据少的时候可以提到 0.4~0.5。padding_idx=0必须和词表里 PAD 的 id 一致,否则补齐位会参与梯度更新,训练会莫名其妙发散。
注意:注意力里的
mask用来屏蔽补齐位,如果你的 batch 里问题长度差异大,不加 mask 会让模型把注意力分到 PAD 上,这是新手最常见的翻车点之一。
3.3 训练循环与 teacher forcing 的开关时机
def train_epoch(model, loader, optimizer, criterion, vocab, teacher_forcing=0.5): model.train() total_loss = 0 for q_ids, a_ids in loader: optimizer.zero_grad() enc_out, hidden = model.encoder(q_ids) # 解码器第一步输入统一是 SOS input_token = a_ids[:, 0] loss = 0 for t in range(1, a_ids.size(1)): pred, hidden, _ = model.decoder(input_token, hidden, enc_out) loss += criterion(pred, a_ids[:, t]) # teacher forcing:按概率用真实上一词,否则用模型自己的预测 use_teacher = torch.rand(1).item() < teacher_forcing input_token = a_ids[:, t] if use_teacher else pred.argmax(-1) loss.backward() # 梯度裁剪,防止 RNN 梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss += loss.item() / a_ids.size(1) return total_loss / len(loader)逻辑说明:每个 batch 先编码问题,拿到编码输出和初始隐状态。解码从 SOS 开始,逐步预测答案的每个 token,累加交叉熵损失。teacher_forcing控制用真实上一词还是模型预测,训练初期设高一点(0.7~0.9)让模型快速学会语言结构,后期降到 0.3 以下逼模型适应自己的输出。
参数说明:clip_grad_norm_(..., 1.0)是 RNN 训练的保命操作,梯度爆炸时 loss 会突然变 NaN,加了这个基本能压住。teacher_forcing=0.5是折中值,可以按 epoch 线性衰减。损失除以答案长度是为了让不同长度样本的 loss 可比。
4. 推理与知识库对接:让模型答出知识库里的内容
4.1 贪心解码、束搜索怎么选
推理时最朴素的是贪心解码:每步取概率最大的词。优点是快,缺点是容易陷入重复或局部最优,比如一直输出「的的的」。束搜索(beam search)保留 Top-K 候选路径,最后选整体概率最高的,生成质量明显更好,代价是计算量翻 K 倍。
我的经验是:答案短(20 字以内)用贪心就够,答案长或要求多样性时上束搜索,beam size 取 3~5。再大收益递减,延迟还上去了。下面给一个带长度惩罚的束搜索实现。
def beam_search(model, src, vocab, beam_size=3, max_len=64, device="cpu"): model.eval() inv_vocab = {v: k for k, v in vocab.items()} with torch.no_grad(): src = src.to(device) enc_out, hidden = model.encoder(src) # 每条候选:(序列, 累计log概率, 隐状态) beams = [([vocab["<sos>"]], 0.0, hidden)] for _ in range(max_len): candidates = [] for seq, score, h in beams: if seq[-1] == vocab["<eos>"]: candidates.append((seq, score, h)) continue input_token = torch.tensor([seq[-1]], device=device) pred, new_h, _ = model.decoder(input_token, h, enc_out) log_probs = F.log_softmax(pred, dim=-1).squeeze(0) topk = torch.topk(log_probs, beam_size) for k in range(beam_size): candidates.append((seq + [topk.indices[k].item()], score + topk.values[k].item(), new_h)) # 长度惩罚:避免束搜索偏向短句 candidates.sort(key=lambda x: x[1] / (len(x[0]) ** 0.7), reverse=True) beams = candidates[:beam_size] if all(b[0][-1] == vocab["<eos>"] for b in beams): break best_seq = beams[0][0] return "".join(inv_vocab.get(i, "") for i in best_seq[1:-1])逻辑说明:维护 beam_size 条候选序列,每步对每条序列扩展出 Top-K 词,汇总后按「累计 log 概率 / 长度惩罚」排序,保留前 K 条。长度惩罚指数 0.7 是常用值,能缓解束搜索偏爱短答案的问题。
参数说明:beam_size=3是质量和速度的平衡点,max_len=64防止死循环,遇到 EOS 就停。inv_vocab反查时跳过 SOS 和 EOS,只输出正文。
4.2 把检索候选接进解码:知识库问答的落地形态
纯生成模型有个硬伤:它可能编出知识库里根本没有的答案。生产级做法是「检索 + 重排/生成」:先用 BM25 或向量检索从知识库捞出 Top-10 候选答案,再让 seq2seq 做两件事之一——要么在候选里选一个(分类式),要么以候选为参考改写(生成式)。
最简单可靠的接法是分类式:把「问题 + 候选答案」拼成输入,模型输出一个分数,选最高分的候选。这样模型永远不会编造知识库外的内容。如果你坚持生成式,就把检索到的候选答案拼在问题后面作为额外输入,让模型学会「抄」候选里的关键信息。
def retrieve_and_answer(question, kb, model, vocab, top_k=5): # kb: [(question, answer), ...] 知识库 # 这里用最简单的字符重叠做检索,生产环境换成 BM25 或向量检索 def overlap(q1, q2): return len(set(q1) & set(q2)) / (len(set(q1)) + 1e-6) scored = [(overlap(question, q), a) for q, a in kb] scored.sort(key=lambda x: x[0], reverse=True) candidates = [a for _, a in scored[:top_k]] # 生成式:把候选拼进输入,模型参考候选作答 src_text = question + " " + " ".join(candidates) src_ids = [vocab.get(t, vocab["<unk>"]) for t in tokenize(src_text)][:64] src = torch.tensor([src_ids]) return beam_search(model, src, vocab)逻辑说明:先用字符重叠做粗检索(生产环境替换成 BM25 或向量检索),拿到候选答案后拼进模型输入,让解码器在生成时能「看到」知识库内容。这样即使模型语言能力一般,也能靠候选里的原文拼出正确答案。
参数说明:top_k=5是候选数量,太多会超出输入长度限制,太少可能漏掉正确项。src_ids截断到 64 是因为问题加候选拼接后容易超长,需要根据你的max_q_len调整。
5. 避坑与排查:seq2seq 问答训练里最常见的 5 个翻车现场
5.1 现象:loss 一直不降,输出全是高频词
原因通常是词表构建有问题,或者学习率太大导致模型卡在局部最优。先检查词表里 PAD 的 id 是不是 0、padding_idx有没有对上。如果词表没问题,把学习率从 1e-3 降到 3e-4 试试,GRU 对学习率比 Transformer 敏感。还有一种可能是答案端没加 SOS/EOS,解码器不知道从哪开始,只能瞎猜高频词。
5.2 现象:推理时输出重复句子,比如「谢谢谢谢谢谢」
这是贪心解码的经典病。原因有两个:一是训练时 teacher forcing 比例太高,模型没见过自己的错误输出,推理时一旦偏了就回不来;二是没有对重复做惩罚。解决办法:训练后期把 teacher forcing 降到 0.2 以下,推理时加重复惩罚(对已生成的词降低其 logits),或者直接换束搜索。
5.3 现象:答案里出现知识库没有的实体,开始编造
纯生成模型的通病。根因是模型在训练时学到了「语言模式」而不是「知识」,遇到没见过的问题就按语言习惯编。解决方向是接检索,用第 4 章的「检索 + 生成」或分类式重排,把生成空间限制在知识库候选内。如果暂时不想接检索,至少在推理时对输出做一次知识库实体校验,命中不了就返回兜底话术。
5.4 现象:batch 里长短样本混在一起,loss 波动剧烈
原因是补齐到统一长度后,短样本的 PAD 位参与了 loss 计算。必须在criterion里设ignore_index=vocab["<pad>"],让 PAD 位不贡献损失。另外注意力里的 mask 也要同步加上,否则注意力会跑到 PAD 上。这两处漏一个,训练曲线就会像心电图。
5.5 现象:显存够但训练极慢,一个 epoch 要几小时
先看是不是在 CPU 上跑,GRU 在 CPU 上确实慢,换 GPU 能快十几倍。如果已经在 GPU 上还慢,检查 DataLoader 的num_workers是不是 0,设成 4 或 8 能明显加速数据加载。还有一个隐蔽原因是答案max_a_len设太大,比如设了 512,但实际答案平均只有 40 字,那大部分计算都浪费在 PAD 上。统计一下答案长度分布,把上限卡在 95 分位。
6. 进阶技巧:用复制机制和长度分桶把问答质量再抬一档
跑通基础版之后,如果你想让模型在知识库问答上更稳,有两个技巧投入产出比最高。
第一个是复制机制(copy mechanism)。知识库答案里大量实体是低频词,词表里可能压根没有,模型只能输出 UNK。复制机制让解码器每一步除了从词表选词,还能「复制」输入问题里的词。实现上是在解码器输出后加一个门控:p_gen决定这一步是生成还是复制,复制概率分布直接取注意力权重。这样即使实体没进词表,模型也能从问题里原样抄过来。Pointer-Generator 网络就是这个思路,代码量不大,但对含大量专有名词的知识库问答提升明显。
第二个是长度分桶(bucket by length)。问答数据里问题长度和答案长度差异很大,统一补齐浪费严重。做法是把样本按答案长度排序后分桶,每个 batch 内长度接近,PAD 比例大幅下降。配合DataLoader的batch_sampler使用,训练速度通常能提升 30% 以上,而且因为 PAD 少了,loss 也更干净。
| 技巧 | 解决的核心问题 | 实现成本 | 预期收益 |
|---|---|---|---|
| 复制机制 | 低频实体输出 UNK | 中,改解码器 | 实体准确率明显提升 |
| 长度分桶 | PAD 浪费、训练慢 | 低,改采样器 | 训练提速 30%+ |
| 束搜索 + 长度惩罚 | 生成重复、偏短 | 低,改推理 | 答案可读性提升 |
| 检索候选拼接 | 模型编造知识 | 中,加检索模块 | 答案可控性大幅提升 |
验证这些改动有没有效,别只看 loss。我一般会固定一批测试问题,人工看三个指标:答案是否命中知识库原文、有没有编造实体、长度是否合理。loss 降了但答案变差的模型我见过太多次,尤其是加了复制机制后如果门控没调好,模型会过度复制问题里的词,答非所问。
最后一个习惯:每次改结构或参数,只改一个变量,跑完对比测试集输出再改下一个。seq2seq 这玩意儿玄学不少,一次改三处,出了问题你根本不知道是哪儿的锅。这套代码我前后调了两周,最大的教训就是——先把数据质量和词表搞干净,再谈模型结构,否则再花哨的注意力机制也救不回来。希望帮到你。
本文还有配套的精品资源,点击获取