简介:这份资源面向具备一定Python与深度学习基础的开发者,提供一套基于知识库的问答Seq2Seq模型完整代码实现,帮助读者理解从数据预处理到模型训练、评估与部署的全流程。压缩包共21个文件,约3.39MB,以7个py脚本为核心,涵盖编码器解码器构建、训练与预测入口;另含4个test与4个train数据文件、2个json问答样本、2个vocab词表及2个sta状态文件,覆盖数据、模型与推理各环节。项目围绕编码器-解码器架构展开,涉及LSTM/GRU、注意力机制、词嵌入、序列填充、交叉熵损失与Adam优化等关键知识点,并演示如何将知识库信息融入问答生成与后处理。目前已有2094人学习下载,适合希望动手复现Seq2Seq问答系统、提升自然语言处理实战能力的读者参考借鉴。
1. 从一份 FAQ 表到能对话的知识库:seq2seq 问答到底解决什么问题
手里有一份几百条的客服问答对,或者一份产品 FAQ 表格,想做一个能"问一句、答一句"的智能问答,很多人第一反应是上大模型。但真到落地时会发现:显存吃紧、推理延迟高、答案还容易跑偏。这时候一个基于 Python 的知识库问答 seq2seq 模型代码实现,反而是性价比最高的起点——它足够小,能在单卡甚至 CPU 上跑通;它足够透明,你能看清每一层在干什么;它也足够可扩展,后面接检索、接大模型都有位置。
这篇讲的就是这条路线:把知识库里的"问题-答案"对整理成训练语料,用 Python 搭一个带注意力机制的 seq2seq 模型,训练、推理、评估一条龙跑通。适合两类人:一类是刚学完 Python 入门、想找个完整项目练手的;另一类是想给企业做私有化知识库问答、又不想一上来就堆 GPU 的工程师。读完你能拿到一套可复现的代码骨架,知道参数怎么调、坑在哪、什么时候该换方案。
2. 语料怎么来:知识库问答对的结构化与清洗
2.1 知识库问答语料的三种常见来源
做 seq2seq 问答,第一件事不是写模型,是把知识库变成"一问一答"的平行语料。知识库这个词听起来很重,实际落地时无非三种来源。
第一种是结构化 FAQ 表,Excel 或 CSV,两列:问题、答案。这是最省事的,直接读进来就能用。第二种是文档型知识库,比如 wiki 知识库、obsidian 知识库搭建出来的 Markdown 文件,需要先切分成段落,再人工或半自动生成问答对。第三种是历史工单和聊天记录,噪声最大,但最贴近真实问法。
我一般会先做一件事:把知识库里的条目按主题聚类,同一主题下至少准备 5 到 10 种不同问法。原因很直接——seq2seq 是监督学习,模型见过的问法越多样,推理时对同义改写的鲁棒性越好。只给一种标准问法,用户换个说法模型就懵了。
清洗规则不用太复杂,但几条必须做:去掉 HTML 标签、统一全半角标点、把连续空白压成一个空格、过滤掉长度超过 50 字的问题(太长的多半是段落不是问句)。答案侧保留原始表述,但要把换行符替换成空格,避免训练时被当成序列结束。
2.2 用 Python 把 FAQ 表转成训练对
下面这段代码把一份 CSV 格式的知识库问答表读进来,做基础清洗,输出成"问题\t答案"的平行语料文件。这是整个流程的入口,格式定好了后面才顺。
import csv import re import random def clean_text(text): # 去掉 HTML 标签 text = re.sub(r"<[^>]+>", "", text) # 全角转半角(只处理常见标点) text = text.replace(",", ",").replace("?", "?").replace("!", "!") # 压缩空白 text = re.sub(r"\s+", " ", text).strip() return text def build_corpus(csv_path, out_path, max_q_len=50): pairs = [] with open(csv_path, "r", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: q = clean_text(row["question"]) a = clean_text(row["answer"]) # 过滤异常样本 if not q or not a: continue if len(q) > max_q_len: continue pairs.append((q, a)) # 打乱顺序,避免同主题扎堆 random.seed(42) random.shuffle(pairs) with open(out_path, "w", encoding="utf-8") as f: for q, a in pairs: f.write(f"{q}\t{a}\n") print(f"有效问答对: {len(pairs)}") return pairs if __name__ == "__main__": build_corpus("faq.csv", "corpus.tsv")逻辑说明:clean_text负责统一格式,build_corpus负责过滤和打乱。参数max_q_len=50是问题长度上限,按字符算,中文场景下 50 字基本覆盖了正常问句;如果你的知识库问题偏长,可以放宽到 80,但要注意后面模型的最大序列长度也要跟着调。random.seed(42)是为了让每次切分结果可复现,做实验对比时这点很重要。
输出格式用\t分隔而不是逗号,是因为答案里经常出现逗号,用制表符能省掉一堆转义麻烦。这一步做完,你应该得到一个几千到几万行的corpus.tsv,行数就是你的有效训练样本量。
提示:如果有效问答对少于 500 条,先别急着训模型,优先补语料。seq2seq 在小数据上很容易退化成"复读机",输出高频答案。
2.3 词表构建与序列长度分布检查
语料有了,下一步是看长度分布,决定max_len设多少。这一步很多人跳过,结果训练时大量样本被截断,答案缺头少尾。
def length_stats(path): q_lens, a_lens = [], [] with open(path, "r", encoding="utf-8") as f: for line in f: q, a = line.strip().split("\t") q_lens.append(len(q)) a_lens.append(len(a)) def pct(lst, p): lst = sorted(lst) idx = int(len(lst) * p) return lst[min(idx, len(lst) - 1)] print(f"问题长度 P50={pct(q_lens,0.5)} P90={pct(q_lens,0.9)} P99={pct(q_lens,0.99)}") print(f"答案长度 P50={pct(a_lens,0.5)} P90={pct(a_lens,0.9)} P99={pct(a_lens,0.99)}") length_stats("corpus.tsv")看 P90 和 P99 这两个数。max_len一般取 P99 再往上留一点余量,比如 P99 是 42,就设 48。取 P50 会导致一半样本被截断,取最大值又会让 padding 浪费大量算力。这是血泪经验:序列长度设错,模型不是训不动,是训出来答非所问。
词表构建按字符级还是词级,取决于你的语料规模。中文问答对在万条以下,我建议字符级,词表小、没有 OOV 问题;万条以上可以上 jieba 分词做词级,效果更紧凑。字符级词表通常 3000 到 5000 个字符就够,加上<pad>、<sos>、<eos>、<unk>四个特殊符号。
3. 模型怎么搭:带注意力的 seq2seq 结构与参数选择
3.1 为什么知识库问答要用带注意力的 seq2seq
基础 seq2seq 是编码器把整个问题压成一个固定长度的向量,解码器再从这个向量里生成答案。问题在于:问题一长,这个固定向量就成了信息瓶颈,前面的词被后面的词挤掉,答案自然丢信息。
注意力机制解决的正是这件事。解码器每生成一个词,都回头看一眼编码器的所有隐藏状态,按相关性加权求和。这样"问什么答什么"的对应关系就建立起来了。在知识库问答场景里,用户问"退货要几天",答案里的"7 天"必须和问题里的"几天"对上,注意力就是干这个的。
选型上,编码器和解码器都用单层或双层 GRU/LSTM 就够了。别一上来堆 Transformer,几千条语料喂不饱它,反而过拟合。GRU 比 LSTM 参数少、训练快,在这个规模下效果差不多,我一般首选 GRU。词向量维度 128 到 256,隐藏层维度 256 到 512,这是小数据问答的甜点区。
3.2 编码器、注意力、解码器的代码骨架
下面用 PyTorch 搭一个完整的带注意力 seq2seq。代码分三块:编码器、注意力、解码器。这是核心,值得逐行看。
import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, vocab_size, emb_dim, hid_dim): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=0) self.gru = nn.GRU(emb_dim, hid_dim, batch_first=True, bidirectional=True) # 双向输出拼接后降维,供解码器初始化 self.fc = nn.Linear(hid_dim * 2, hid_dim) def forward(self, src): # src: [batch, src_len] emb = self.embedding(src) # [B, L, E] outputs, hidden = self.gru(emb) # outputs: [B, L, 2H] # 取双向最后一步拼接作为解码器初始隐状态 hidden = torch.tanh(self.fc( torch.cat([hidden[-2], hidden[-1]], dim=1) )) # [B, H] return outputs, hidden class Attention(nn.Module): def __init__(self, hid_dim): super().__init__() self.attn = nn.Linear(hid_dim * 3, hid_dim) self.v = nn.Linear(hid_dim, 1, bias=False) def forward(self, hidden, encoder_outputs, mask): # hidden: [B, H] encoder_outputs: [B, L, 2H] src_len = encoder_outputs.shape[1] hidden = hidden.unsqueeze(1).repeat(1, src_len, 1) # [B, L, H] energy = torch.tanh(self.attn( torch.cat([hidden, encoder_outputs], dim=2) )) # [B, L, H] attention = self.v(energy).squeeze(2) # [B, L] # 把 padding 位置 mask 掉,避免注意力和浪费在无意义位置 attention = attention.masked_fill(mask == 0, -1e10) return F.softmax(attention, dim=1) class Decoder(nn.Module): def __init__(self, vocab_size, emb_dim, hid_dim): super().__init__() self.embedding = nn.Embedding(vocab_size, emb_dim, padding_idx=0) self.attention = Attention(hid_dim) self.gru = nn.GRU(emb_dim + hid_dim * 2, hid_dim, batch_first=True) self.fc = nn.Linear(hid_dim * 3 + emb_dim, vocab_size) def forward(self, input_step, hidden, encoder_outputs, mask): # input_step: [B, 1] emb = self.embedding(input_step) # [B, 1, E] attn_weights = self.attention(hidden, encoder_outputs, mask) attn_weights = attn_weights.unsqueeze(1) # [B, 1, L] context = torch.bmm(attn_weights, encoder_outputs) # [B, 1, 2H] rnn_input = torch.cat([emb, context], dim=2) # [B, 1, E+2H] output, hidden = self.gru(rnn_input, hidden.unsqueeze(0)) output = output.squeeze(1) # [B, H] context = context.squeeze(1) # [B, 2H] emb = emb.squeeze(1) # [B, E] pred = self.fc(torch.cat([output, context, emb], dim=1)) return pred, hidden.squeeze(0), attn_weights.squeeze(1)逻辑说明:编码器用双向 GRU,把正反两个方向的最后隐状态拼起来过一层tanh,得到解码器的初始状态。注意力用 Bahdanau 式的加性注意力,masked_fill那行是关键——把 padding 位置的分数压到极小,softmax 后权重接近 0,否则模型会把注意力浪费在填充符上。解码器每一步把词向量、注意力上下文、GRU 输出三者拼接后过全连接层出词表分布。
参数说明:emb_dim建议 128 起,hid_dim建议 256 起。padding_idx=0必须和词表里<pad>的索引一致,否则 mask 会错位。-1e10这个数不用纠结,只要足够小让 softmax 后趋近 0 即可。
3.3 训练循环与 teacher forcing 的比例控制
训练时用 teacher forcing:解码器每一步的输入用真实答案的上一个词,而不是模型自己上一步的预测。这样收敛快,但有个副作用——推理时模型没见过自己的错误,容易一步错步步错。
def train_step(model, src, src_mask, tgt, optimizer, criterion, tf_ratio=0.5): encoder, decoder = model optimizer.zero_grad() encoder_outputs, hidden = encoder(src) batch_size, tgt_len = tgt.shape input_step = tgt[:, 0].unsqueeze(1) # <sos> loss = 0 for t in range(1, tgt_len): pred, hidden, _ = decoder(input_step, hidden, encoder_outputs, src_mask) loss += criterion(pred, tgt[:, t]) # teacher forcing:按比例决定用真实标签还是模型预测 use_gt = random.random() < tf_ratio input_step = tgt[:, t].unsqueeze(1) if use_gt else pred.argmax(1).unsqueeze(1) loss = loss / (tgt_len - 1) loss.backward() torch.nn.utils.clip_grad_norm_(list(encoder.parameters()) + list(decoder.parameters()), 1.0) optimizer.step() return loss.item()逻辑说明:tf_ratio控制 teacher forcing 的概率。训练初期设 0.8 到 1.0 让模型快速学会语言结构,后期逐步降到 0.3 到 0.5,让模型适应自己的预测。这个退火过程是提升推理质量的关键,很多人固定 1.0 训到底,结果推理时输出崩坏。
参数说明:clip_grad_norm_的 1.0 是梯度裁剪阈值,RNN 类模型梯度爆炸很常见,这行是后悔药。优化器用 Adam,学习率 1e-3 起步,训练 20 到 50 轮看验证集损失。batch size 32 或 64,取决于显存。
注意:
criterion用nn.CrossEntropyLoss(ignore_index=0),把 padding 位置的损失忽略掉,否则模型会花大量精力学"预测填充符"。
4. 推理与评估:让模型真的答出知识库里的内容
4.1 贪心解码与 beam search 的取舍
训练完,推理阶段第一个选择是解码策略。贪心解码每步取概率最大的词,快但容易陷入局部最优,输出重复。beam search 保留 top-k 条候选路径,质量更好但慢 k 倍。
def greedy_decode(model, src, src_mask, vocab, max_len=50, device="cpu"): encoder, decoder = model encoder.eval(); decoder.eval() with torch.no_grad(): encoder_outputs, hidden = encoder(src) input_step = torch.tensor([[vocab["<sos>"]]], device=device) result = [] for _ in range(max_len): pred, hidden, _ = decoder(input_step, hidden, encoder_outputs, src_mask) top1 = pred.argmax(1).item() if top1 == vocab["<eos>"]: break result.append(top1) input_step = torch.tensor([[top1]], device=device) return result逻辑说明:从<sos>开始,每步取最大概率词,遇到<eos>停止。max_len是硬上限,防止模型不输出结束符时死循环。知识库问答场景里,答案通常不长,max_len=50足够。
beam search 的实现要维护 k 条路径的累积对数概率,代码量翻倍。我的建议是:先用贪心跑通,看输出质量。如果答案重复、漏词严重,再上 beam search,beam_width取 3 到 5 就够,再大收益递减。
4.2 用 BLEU 和人工抽查双轨评估
自动指标用 BLEU,但别只看它。BLEU 高不代表答得对,尤其在知识库问答里,答案往往有固定表述,BLEU 会虚高。
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction def evaluate_bleu(model, test_pairs, vocab, idx2word, device="cpu"): smooth = SmoothingFunction().method1 scores = [] for q, a in test_pairs: src_ids = [vocab.get(c, vocab["<unk>"]) for c in q] src = torch.tensor([src_ids], device=device) src_mask = (src != 0).unsqueeze(1) pred_ids = greedy_decode(model, src, src_mask, vocab, device=device) pred = "".join(idx2word[i] for i in pred_ids) ref = list(a) scores.append(sentence_bleu([ref], list(pred), smoothing_function=smooth)) return sum(scores) / len(scores)逻辑说明:逐条解码,和参考答案算 BLEU。smoothing_function处理短句 n-gram 为零的情况,否则短答案 BLEU 全是 0。
但真正靠谱的是人工抽查。我一般随机抽 50 条,分三类标记:完全正确、部分正确、完全错误。部分正确的看是漏了关键信息还是表述不同。如果完全错误超过 30%,别调参了,回去补语料或换方案。这是踩坑踩出来的判断标准。
4.3 把模型接回知识库:检索兜底的混合方案
纯生成式 seq2seq 有个硬伤:它可能编造知识库里没有的答案。在客服、医疗这类场景,编造是不可接受的。
常见做法是加一层检索兜底:先用问题去知识库里做相似度检索,如果检索到高置信度的问答对,直接返回;检索不到或置信度低,再交给 seq2seq 生成。检索可以用简单的 TF-IDF 或 BM25,也可以用句向量模型。这样既保留了生成模型的泛化能力,又用检索守住了准确性底线。
from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity class HybridQA: def __init__(self, questions, answers, threshold=0.6): self.answers = answers self.vectorizer = TfidfVectorizer(analyzer="char", ngram_range=(1, 2)) self.matrix = self.vectorizer.fit_transform(questions) self.threshold = threshold def retrieve(self, query): vec = self.vectorizer.transform([query]) sims = cosine_similarity(vec, self.matrix)[0] idx = sims.argmax() if sims[idx] >= self.threshold: return self.answers[idx], sims[idx] return None, sims[idx]逻辑说明:analyzer="char"用字符级 n-gram,对中文友好,不用分词。threshold是检索置信度阈值,高于它直接返回知识库答案,低于它交给生成模型。这个阈值要在验证集上调,0.5 到 0.7 之间试。
参数说明:ngram_range=(1, 2)兼顾单字和双字组合,知识库问题短的话够用。如果问题较长,可以加到(1, 3)。
5. 避坑与排查:seq2seq 问答训不动的五个真实原因
5.1 输出全是高频答案或重复词
现象:不管问什么,模型都输出同一句高频答案,或者反复输出同一个字。
原因:语料里某类答案占比过高,模型学会了"押注高频"这个偷懒策略;或者解码时没有正确 mask,注意力集中在 padding 上。
解决:先统计答案的分布,如果某答案占比超过 20%,做下采样或给其他答案加权。检查masked_fill那行是否生效,打印注意力权重看看是不是均匀分布。另外确认ignore_index=0设对了,否则模型在学预测填充符。
5.2 训练损失降了但验证损失反弹
现象:训练 loss 一路降到很低,验证 loss 先降后升,推理输出开始胡言乱语。
原因:过拟合。小语料上 seq2seq 参数相对过多,几十轮就开始记住训练集。
解决:加 dropout,编码器和解码器的 embedding 后、GRU 输出后都加 0.2 到 0.3 的 dropout。加早停,验证 loss 连续 3 轮不降就停。如果还不行,减小hid_dim,从 512 降到 256 甚至 128。
5.3 推理时第一个词就错,后面全崩
现象:贪心解码第一步就输出了不该出现的词,整句跑偏。
原因:exposure bias。训练时用 teacher forcing,模型从没处理过自己的错误输入,推理时一旦第一步错就没有纠正能力。
解决:把tf_ratio做退火,从 1.0 逐步降到 0.3。或者用 scheduled sampling,按训练轮次动态调整。再不行就上 beam search,用多条路径对冲单步错误。
5.4 中文分词导致答案缺字
现象:输出答案里有些字丢了,或者出现<unk>。
原因:用了词级分词,但词表里没有覆盖测试时的词,全部映射成<unk>。
解决:小语料直接换字符级,词表覆盖所有出现过的字符,基本没有 OOV。如果坚持词级,把词表最小词频降到 1,并确保分词器和训练时一致。这是翻车高发区,训练和推理用了不同分词配置,结果对不上。
5.5 显存不够或训练极慢
现象:batch size 调到 8 还是 OOM,或者一个 epoch 跑几小时。
原因:序列长度设太长,padding 浪费严重;或者解码器逐时间步循环,没有并行。
解决:按 P99 设max_len,并在每个 batch 内做动态 padding,按 batch 内最长序列补齐而不是全局最长。训练时把数据按长度分桶,同桶内长度接近,padding 浪费最小。解码器的逐步循环是 seq2seq 的固有开销,只能靠减小hid_dim和 batch size 缓解。
6. 从能跑到好用:几个让问答质量再上一档的技巧
模型跑通只是起点。真正让知识库问答好用,靠的是几个细节。
第一个是数据增强。同一句问题,用同义词替换、语序调整生成多个变体,能显著提升鲁棒性。比如"怎么退货"和"退货流程是什么"、"我想退货怎么办",本质是一个意图。我一般用规则加少量人工,把每个意图的问法扩到 5 条以上,语料量翻几倍,效果立竿见影。
第二个是答案后处理。seq2seq 输出偶尔会带重复词或多余标点,加一层规则清洗:连续重复的词压成一个,去掉开头结尾的孤立标点,长度异常短的答案直接丢弃转检索兜底。这层规则不优雅,但实用。
第三个是置信度过滤。解码时记录每步的最大概率,取平均作为答案置信度。低于阈值的答案不直接返回,而是提示"没找到确切答案,请换个问法"。这比硬答一个错答案体验好得多。
| 技巧 | 作用 | 代价 |
|---|---|---|
| 数据增强 | 提升同义问法鲁棒性 | 需要人工或规则生成 |
| 答案后处理 | 清理重复和噪声 | 规则维护成本 |
| 置信度过滤 | 避免硬答错答案 | 需要调阈值 |
| 检索兜底 | 保证准确性下限 | 多一套检索模块 |
第四个是定期用真实日志回流。上线后把用户实际问的问题收集起来,人工标注正确答案,每月补一批进训练集。模型不是训一次就完事,知识库在变,问法在变,语料也得跟着更新。
最后说个我自己的习惯:每次改完模型或语料,固定抽 50 条做回归测试,记录正确率。别凭感觉说"好像变好了",数字不会骗人。这套 seq2seq 方案在几千条语料的规模下,正确率做到 70% 到 85% 是现实的,再往上要么补数据,要么就得上检索加强或换更强的模型了。想清楚你的场景能接受多少错误率,再决定投多少精力。希望帮到你。
本文还有配套的精品资源,点击获取