简介:这份资源面向希望入门或实践智能问答系统的开发者与个人学习者,提供了一套基于深度学习的FAQ问答系统完整实现方案,可用于客服、教育、技术支持等场景的问答检索与答案匹配。压缩包共45个文件,约49KB,以24个Python脚本为核心,辅以8个Markdown说明文档、若干备份与测试文件,覆盖模型加载、序列到序列框架、训练与预测、分词处理、相似度计算、数据预处理及匹配神经网络训练等模块,并配有配置文件与依赖清单,便于按模块理解系统结构。目前已有83人学习下载。读者可从中获得从数据预处理、模型训练、评估到部署的完整流程参考,理解BERT在问答任务中的改造方式,以及问题与候选答案相似度匹配的实现思路,适合作为个人学习深度学习NLP问答系统的实践素材。
1. 从一堆散装脚本到能跑的 FAQ 问答:这套方案到底值不值得拆
很多做 NLP 的朋友拿到一个 FAQ 问答需求时,第一反应是直接上大模型接口,但真到了要控成本、要私有化、要低延迟的场景,检索加匹配这套老架构反而更稳。我最近拆的这份「基于深度学习的智能FAQ问答系统」资源包,就是典型的检索式问答工程实现:它不靠生成,而是把用户问题先做意图分类,再走 BM25 和 HNSW+Faiss 两路召回,最后用 BERT 匹配网络精排,从已有问答库里挑出最贴切的那条答案。整个包里有 bert_model.py、seq2seq.py、bm25.py、hnsw_faiss.py、train_matchnn.py、similarity.py 这些脚本,还有 intention、generative、ranking、retrieval 四个子目录,基本覆盖了从数据预处理到模型训练再到预测的完整链路。它适合谁?适合正在做客服 FAQ、教育答疑、技术支持知识库的工程师,尤其是想搞懂检索式问答每一层怎么落地、而不是只会调 API 的人。下面我按自己复现的顺序,把这份资源拆开讲清楚。
2. 意图分类与双路召回:FAQ 问答的第一道漏斗怎么搭
FAQ 问答系统最怕的不是答不准,而是答得慢。用户问一句「怎么退换货」,你不可能拿这句话去跟知识库里几万条 FAQ 逐条算 BERT 相似度,那延迟直接爆炸。所以这套方案的第一道漏斗就是:先用轻量手段把候选范围从几万条压到几十条,再交给重模型精排。这一步的核心组件是 intention 目录下的意图分类、retrieval 目录下的 BM25 和 HNSW+Faiss 召回,以及 preprocessor.py、tokenizer.py、word2vec.py 这几个预处理脚本。
2.1 意图分类:把问题先归到对的业务桶里
intention 目录里放的是意图识别相关代码,常见做法是用 BERT 或 TextCNN 做一个多分类模型,把用户问题映射到「售后」「物流」「支付」「账号」这类业务意图上。为什么要先做意图?因为 FAQ 库往往按业务线分表,如果意图判错,后面召回再准也是白搭。我一般会先用 preprocessor.py 把原始问答对清洗成「问题\t意图\t答案」的三列格式,再用 tokenizer.py 做分词和截断。
# preprocessor.py 里典型的数据清洗逻辑(示意) import re import pandas as pd def clean_text(text): # 去掉多余空白和特殊符号,保留中文、英文、数字和常用标点 text = re.sub(r'\s+', ' ', text) text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9,。?!、;:]', '', text) return text.strip() def build_intent_dataset(raw_path, out_path): df = pd.read_csv(raw_path, sep='\t', header=None, names=['question', 'intent', 'answer']) df['question'] = df['question'].apply(clean_text) # 过滤掉问题或答案为空的行,避免训练时 loss 异常 df = df[(df['question'].str.len() > 0) & (df['answer'].str.len() > 0)] df.to_csv(out_path, sep='\t', index=False, header=False) return df if __name__ == '__main__': build_intent_dataset('data/raw_faq.tsv', 'data/intent_train.tsv')这段代码的关键参数是sep='\t',因为后面训练脚本大多按 tab 分隔读取;clean_text里保留中文标点是为了不破坏问句语义,如果你做的是英文 FAQ,正则要相应调整。清洗完的数据直接喂给 intention 下的训练入口,config.py 里控制学习率和 batch size,一般意图分类这种任务 2e-5 到 5e-5 的学习率就够,轮次 3 到 5 轮,再多容易过拟合。
2.2 BM25 召回:关键词匹配仍然是性价比最高的兜底
retrieval 目录下的 bm25.py 实现的是经典 BM25 检索。很多人觉得有了 BERT 就不需要 BM25 了,这是典型的翻车思路。BM25 对专有名词、型号、订单号这类词匹配极准,而且速度是毫秒级。我一般把 BM25 当作第一路召回,取 top 50 候选。
# bm25.py 核心调用示意 from rank_bm25 import BM25Okapi import jieba class BM25Retriever: def __init__(self, corpus): # corpus 是 FAQ 问题列表,先分词再建索引 self.tokenized = [list(jieba.cut(q)) for q in corpus] self.bm25 = BM25Okapi(self.tokenized) def search(self, query, topk=50): tokens = list(jieba.cut(query)) scores = self.bm25.get_scores(tokens) # 按分数降序取 topk 的索引 ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:topk] return ranked这里topk=50是个经验值,太小会漏召回,太大后面精排压力大。BM25Okapi 的默认参数 k1=1.5、b=0.75 对大多数中文 FAQ 够用,如果你的问题长度差异特别大,可以把 b 调到 0.5 左右,降低长文档的惩罚。分词用 jieba 是常见做法,但如果你有领域词典,记得加载自定义词典,否则「花呗」「白条」这类词会被切碎,召回直接掉一截。
2.3 HNSW+Faiss 向量召回:语义相近但字面不同的兜底
BM25 的短板是字面不匹配就召回不到,比如用户问「钱扣了东西没到」,知识库里写的是「支付成功未发货」,关键词对不上。hnsw_faiss.py 就是补这一路的,它用 word2vec.py 或 BERT 把问题编码成向量,再用 Faiss 的 HNSW 索引做近似最近邻搜索。
# hnsw_faiss.py 建索引与查询示意 import faiss import numpy as np def build_hnsw_index(embeddings, dim=768): # HNSW 参数:M 控制图连接数,efConstruction 控制建索引精度 index = faiss.IndexHNSWFlat(dim, 32) index.hnsw.efConstruction = 200 index.add(embeddings.astype('float32')) return index def search_index(index, query_vec, topk=50): index.hnsw.efSearch = 64 # 查询时的搜索范围,越大越准越慢 distances, indices = index.search(query_vec.astype('float32'), topk) return indices[0]M=32和efConstruction=200是我常用的起点,M 越大索引越准但内存涨得快,efSearch 在查询时调,线上一般设 64 到 128。向量维度取决于你用 word2vec 还是 BERT,word2vec 通常是 200 或 300 维,BERT 是 768 维。注意 Faiss 要求 float32,别传 float64 进去,否则直接报错。两路召回的结果合并去重后,就进入下一章的匹配精排。
3. 匹配网络与精排:BERT 怎么把候选答案排对顺序
召回给你 50 到 100 条候选,真正决定用户体验的是精排这一步。ranking 目录下的 ranker.py、train_matchnn.py、matchnn.py、matchnn_utils.py 就是干这个的。它的思路是:把用户问题和每个候选 FAQ 问题拼成一对,送进 BERT 做二分类或回归,输出一个匹配分,按分排序取 top 1 或 top 3。
3.1 匹配网络的结构选型:交互式还是双塔
匹配网络常见两种结构:双塔(bi-encoder)和交互式(cross-encoder)。双塔把两个句子分别编码再算余弦,速度快但精度略低;交互式把两句拼一起送 BERT,精度高但每条候选都要过一次模型,50 条候选就是 50 次前向,延迟高。这套资源里的 matchnn.py 走的是交互式路线,因为 FAQ 场景候选已经被召回压到几十条,交互式扛得住。
# matchnn.py 匹配模型前向逻辑示意 import torch import torch.nn as nn from transformers import BertModel class MatchNN(nn.Module): def __init__(self, bert_path, dropout=0.1): super().__init__() self.bert = BertModel.from_pretrained(bert_path) self.dropout = nn.Dropout(dropout) # 二分类:匹配 / 不匹配 self.classifier = nn.Linear(self.bert.config.hidden_size, 2) def forward(self, input_ids, attention_mask, token_type_ids): outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids) # 取 [CLS] 位置的向量做分类 pooled = outputs.pooler_output logits = self.classifier(self.dropout(pooled)) return logitsdropout=0.1是 BERT 微调的常规值,如果你的训练集小于一万对,可以加到 0.2 到 0.3 防过拟合。token_type_ids用来区分问题和候选答案两段,这是 BERT 配对任务的标配。训练时正样本是真实匹配的问答对,负样本从召回结果里采,常见做法是每条正样本配 4 到 8 条负样本,比例太低模型学不到区分度,太高又容易训练不稳定。
3.2 训练脚本的关键参数:学习率、warmup 与梯度累积
train_matchnn.py 是训练入口,config_distil.py 和 config.py 里定义了超参。BERT 微调最忌讳学习率设大,我一般用 2e-5,warmup 比例 0.1,训练 3 到 4 个 epoch。如果显存不够,用梯度累积把等效 batch size 撑上去。
# 训练启动命令示意 python train_matchnn.py \ --bert_path ./lib/bert \ --train_file data/match_train.tsv \ --dev_file data/match_dev.tsv \ --learning_rate 2e-5 \ --batch_size 16 \ --gradient_accumulation_steps 2 \ --num_train_epochs 3 \ --warmup_ratio 0.1 \ --max_seq_length 128 \ --output_dir ./result/match_modelbatch_size=16配合gradient_accumulation_steps=2,等效 batch 是 32,这是单卡 16G 显存下比较稳的配置。max_seq_length=128对 FAQ 够用,问题和答案一般不会太长,设 256 会白白增加显存和耗时。warmup_ratio=0.1让学习率在前 10% 步数里线性上升,避免一开始就把预训练权重带偏。训练完在 dev 集上看准确率和 AUC,如果 AUC 卡在 0.7 以下,优先查负样本采样是不是太简单,而不是急着换模型。
3.3 推理与相似度融合:similarity.py 怎么用
similarity.py 负责计算问题和候选答案的相似度,它既可以单独用余弦相似度做粗排,也可以和匹配模型的分数做加权融合。我一般把 BM25 分数、向量余弦分数、BERT 匹配分归一化后按 0.2:0.3:0.5 加权,具体权重在验证集上网格搜一下。
# similarity.py 分数融合示意 def fuse_scores(bm25_score, vec_score, match_score, w=(0.2, 0.3, 0.5)): # 三个分数先各自归一化到 0-1,再加权求和 return w[0]*bm25_score + w[1]*vec_score + w[2]*match_score权重不是拍脑袋定的,如果你的业务里专有名词多,BM25 权重可以提到 0.3;如果用户问法特别口语化,向量和匹配分权重要更高。predict.py 是最终对外推理入口,它串起意图分类、双路召回、精排和分数融合,返回 top 1 答案和置信度。置信度低于阈值时,常见做法是转人工或返回「没找到相关答案」,这个阈值要在线上根据 badcase 调。
4. 避坑与排查:复现这套 FAQ 问答时最容易翻车的五个点
这套资源包结构不算复杂,但真跑起来坑不少。我把自己踩过的和社群里高频出现的五个问题列出来,每条按现象、原因、解决来说。
4.1 现象:训练 loss 不降,准确率一直在随机水平
原因通常是数据格式不对。preprocessor.py 输出的 tsv 如果列顺序和 train.py 读取时不一致,模型拿到的标签就是错的。另一个常见原因是 tokenizer.py 里 max_length 设得太小,问题被截断后关键信息丢了。
解决:先打印三条训练样本,确认 input_ids 解码回来是完整问句;再检查 label 是不是 0/1 而不是 1/2。max_length 建议 128 起步,FAQ 问句很少超过这个长度。
4.2 现象:Faiss 建索引时报维度不匹配
原因是你用 word2vec 生成的向量是 300 维,但 build_hnsw_index 里 dim 写的是 768。或者 embeddings 是 list 不是 numpy array,Faiss 不认。
解决:建索引前先embeddings = np.array(embeddings).astype('float32'),然后dim = embeddings.shape[1],别硬编码。如果混用了 word2vec 和 BERT 向量,统一成一种再建索引。
4.3 现象:BM25 召回结果里明明有正确答案却排到 50 名开外
原因是分词没加载领域词典,或者 BM25 的 b 参数对短问句惩罚过大。FAQ 问句通常很短,b=0.75 会让短文档得分被压低。
解决:加载自定义词典后重新分词建索引;把 b 调到 0.3 到 0.5 之间试;另外确认查询时用的分词器和建索引时一致,别一个用 jieba 一个用空格切。
4.4 现象:精排后 top1 答案还不如 BM25 直接给的准
原因是匹配模型过拟合了训练集里的问法,泛化差。或者负样本采样时把语义相近但实际匹配的样本当成了负样本,模型学反了。
解决:检查负样本构造逻辑,语义相似度高于 0.9 的候选不要当负样本;增加 dev 集早停;如果训练集小于 5000 对,考虑先用 BM25 分数做特征训一个 LightGBM 排序模型,比直接微调 BERT 更稳。
4.5 现象:线上推理延迟超过 500ms
原因通常是精排阶段候选太多,或者 BERT 没做 batch 推理,一条条过模型。
解决:把精排候选从 50 降到 20,召回阶段多花点功夫提准确率;推理时把候选拼成 batch 一次前向;如果还慢,用 config_distil.py 里的蒸馏配置换小模型,或者把 BERT 换成双塔结构先粗筛再交互精排。
提示:这套资源里有个「备份文件.zip」和若干 .zbak 文件,解压时注意别覆盖了正在改的脚本,我一般先把备份挪到单独目录再动手。
5. 从能跑到好用:阈值调优与 badcase 回流的一个具体习惯
把系统跑通只是起点,真正决定 FAQ 问答好不好用的是阈值和回流机制。predict.py 返回的置信度分数,你得在验证集上画一条 precision-recall 曲线,找到业务能接受的平衡点。比如客服场景宁可转人工也别答错,阈值就设高一点,0.85 以上才自动回复;教育答疑可以宽松些,0.6 就返回 top1 并附上「猜你想问」的 top3。
我自己的习惯是每次上线新模型前,强制跑一遍 badcase 回流:把线上置信度低于阈值的问题日志捞出来,人工标注正确答案,补进训练集重新训一轮。这套资源里 log 目录和 result 目录就是干这个用的,别让它们空着。另外 config.py 里的参数不要一次改太多,我一般固定随机种子,一次只动一个超参,记录 dev 集指标变化,否则出了问题根本不知道是哪个参数导致的。
还有一个容易被忽略的点:FAQ 库是会变的,新政策、新产品上线都要更新问答对。我一般每周重建一次 Faiss 索引和 BM25 索引,重建脚本就放在 tools.py 里,用 cron 定时跑。索引重建期间用旧索引顶着,重建完原子切换,避免线上抖动。这套流程跑顺之后,你会发现检索式问答的维护成本比生成式低得多,答案可控、可解释、可追溯,这也是它在企业 FAQ 场景里一直没被完全替代的原因。
从那以后我每次接手 FAQ 项目,都强制先跑通「意图分类 → 双路召回 → 精排 → 阈值调优」这条链路的最小闭环,再谈优化。希望帮到你。
本文还有配套的精品资源,点击获取