ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于深度学习的FAQ问答系统实战:从毕设源码到上线避坑

基于深度学习的FAQ问答系统实战:从毕设源码到上线避坑 简介这是一份面向计算机相关专业在校学生、教师及企业开发者的FAQ式问答系统完整项目包以深度学习技术为核心适合作为毕业设计、课程设计或项目立项演示使用。项目围绕FAQ检索与匹配展开涵盖意图识别、生成式问答、排序与检索等多个模块并配套数据集与训练脚本便于理解问答系统从数据预处理到模型训练、预测的完整链路。压缩包共37个文件以24个Python源码文件为主辅以8个Markdown说明文档、4个gittest测试文件及1个txt依赖清单整体约41KB结构清晰、模块划分明确。目前已有756人学习下载具备一定参考热度。读者可据此掌握词向量、BM25、Faiss向量检索、深度匹配与Seq2Seq生成等关键实现思路并可在现有代码基础上修改扩展完成功能定制或二次开发。1. 从一份毕设压缩包说起FAQ 问答系统到底能不能直接跑起来很多人拿到「基于深度学习的FAQ式问答系统源码数据集(毕设项目).zip」这类压缩包第一反应是解压、找requirements.txt、pip install、python app.py然后发现跑不起来或者跑起来答非所问。这不是你环境的问题而是 FAQ 问答系统本身有一个容易被忽略的分层它既不是纯检索也不是纯生成而是「召回 匹配 排序」三段式。压缩包里通常包含三样东西——一份 FAQ 问答对数据集常见格式是 question-answer 的 CSV 或 JSON、一套基于深度学习的语义匹配模型代码BERT 或 TextCNN 居多、一个 Web 演示入口Flask 或 Django。它解决的核心问题是用户问一句话系统从已有问答库里找出语义最接近的那条标准问题返回对应答案。适合谁适合做毕设的学生、想快速搭一个客服 FAQ 原型的工程师、以及想拿一个完整小项目练手深度学习落地的人。但前提是你得先搞清楚它的数据长什么样、模型怎么训、阈值怎么定否则就是黑匣子。2. FAQ 问答系统的技术选型为什么不是纯生成也不是纯关键词2.1 检索式、生成式、匹配式三条路线的取舍FAQ 场景有一个硬约束答案必须是可控的、可追溯的。你问「退货流程是什么」系统不能自己编一段话必须返回运营写好的标准答案。这就直接排除了纯生成式方案如 GPT 类模型直接生成回答因为生成式在 FAQ 场景下容易产生幻觉且答案不可控。纯关键词检索TF-IDF、BM25的问题是语义泛化差——用户问「怎么退钱」和标准问题「退款流程」字面重叠低关键词方案召回不到。所以主流做法是「语义向量召回 精排」先用一个轻量模型把用户问题和库内所有标准问题编码成向量算余弦相似度取 Top-K再用一个更重的交叉编码模型对 Top-K 精排。压缩包里的深度学习模型大概率就是做这一步语义匹配的。常见选型是 BERT 做句对分类输入「用户问题 标准问题」输出相似/不相似或者双塔模型两边分别编码算向量内积。毕设项目为了代码简洁多用 BERT 句对分类因为可以直接复用 HuggingFace 的BertForSequenceClassification。2.2 数据集长什么样先看清字段再动手在跑任何代码之前先把数据集打开看字段。FAQ 数据集通常是一个 CSV至少两列question和answer。但用于训练语义匹配模型时需要的是「正样本对」和「负样本对」。正样本对就是同一个问题的不同问法比如「怎么退款」和「退款流程」负样本对是随机拼的不同问题。如果压缩包里的数据集只有 question-answer 两列没有问法变体那你需要自己构造正负样本。常见做法是把每条标准问题作为一个类别同一类别下的不同问法作为正样本不同类别之间随机组合作为负样本。下面这段代码就是做这个转换的。import pandas as pd import random from itertools import combinations # 假设原始数据格式faq.csv 包含 question 和 answer 两列 df pd.read_csv(faq.csv, encodingutf-8) # 按 answer 分组同一个 answer 下的不同 question 视为同义问法 grouped df.groupby(answer)[question].apply(list).reset_index() pairs [] labels [] # 构造正样本同一组内的 question 两两组合 for _, row in grouped.iterrows(): qs row[question] if len(qs) 2: continue for q1, q2 in combinations(qs, 2): pairs.append((q1, q2)) labels.append(1) # 构造负样本不同组的 question 随机组合数量与正样本持平 all_questions df[question].tolist() num_neg len(pairs) for _ in range(num_neg): q1, q2 random.sample(all_questions, 2) # 确保不是同一组 if df[df[question] q1][answer].values[0] ! df[df[question] q2][answer].values[0]: pairs.append((q1, q2)) labels.append(0) # 输出训练数据 train_df pd.DataFrame(pairs, columns[text_a, text_b]) train_df[label] labels train_df.to_csv(train_pairs.csv, indexFalse, encodingutf-8) print(f正样本数{sum(labels)}负样本数{len(labels) - sum(labels)})这段代码的逻辑说明groupby(answer)是关键它假设同一个答案对应的多个问题互为同义问法。如果你的数据集里每条 question 只对应一条 answer没有重复 answer那这个方法构造不出正样本需要换思路——可以用同义词替换、回译back translation来生成变体。参数说明num_neg控制负样本数量一般与正样本 1:1 或 1:2负样本太多会导致类别不均衡。random.sample每次抽两条要检查是否属于同一 answer避免把同义问法误标为负样本这是血泪经验——一旦标错模型学到的就是错的。2.3 模型训练的最小命令与关键参数数据准备好之后训练脚本通常基于 HuggingFace Transformers。下面是一个最小可运行的训练代码骨架适配 BERT 句对分类。from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments from datasets import Dataset import pandas as pd # 加载数据 df pd.read_csv(train_pairs.csv) dataset Dataset.from_pandas(df) # 分词 tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def tokenize(examples): return tokenizer(examples[text_a], examples[text_b], truncationTrue, paddingmax_length, max_length128) dataset dataset.map(tokenize, batchedTrue) dataset dataset.rename_column(label, labels) dataset.set_format(typetorch, columns[input_ids, token_type_ids, attention_mask, labels]) # 划分训练集和验证集 dataset dataset.train_test_split(test_size0.1) # 加载模型 model BertForSequenceClassification.from_pretrained(bert-base-chinese, num_labels2) # 训练参数 training_args TrainingArguments( output_dir./faq_model, num_train_epochs3, per_device_train_batch_size16, per_device_eval_batch_size32, learning_rate2e-5, warmup_ratio0.1, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy, logging_dir./logs, ) trainer Trainer( modelmodel, argstraining_args, train_datasetdataset[train], eval_datasetdataset[test], ) trainer.train() trainer.save_model(./faq_model_final)逻辑说明tokenizer把两段文本编码成 BERT 需要的input_ids和token_type_idsmax_length128对 FAQ 场景足够因为问题和答案通常不会太长。num_labels2是二分类相似/不相似。参数说明learning_rate2e-5是 BERT 微调的标准起点太大容易震荡太小收敛慢per_device_train_batch_size16在 8GB 显存下可以跑如果显存不够降到 8num_train_epochs3对 FAQ 这种小数据集通常够多了会过拟合。warmup_ratio0.1让前 10% 的步数学习率线性上升避免一开始就大步长破坏预训练权重。训练完成后推理时取logits的 softmax如果相似类概率超过阈值比如 0.7就返回对应答案否则返回「抱歉我没有找到相关答案」。这个阈值是玄学需要根据验证集上的 Precision-Recall 曲线来定后面避坑章节会细说。3. 从训练到上线推理服务与阈值调优的落地细节3.1 用 Flask 包一个最小可用的问答接口模型训练完只是第一步要让它能被调用得包一个 HTTP 接口。毕设项目里常见的是 Flask因为代码量少。下面是一个最小推理服务。from flask import Flask, request, jsonify import torch from transformers import BertTokenizer, BertForSequenceClassification import pandas as pd app Flask(__name__) # 加载模型和分词器 model BertForSequenceClassification.from_pretrained(./faq_model_final) tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model.eval() # 加载 FAQ 库 faq_df pd.read_csv(faq.csv, encodingutf-8) questions faq_df[question].tolist() answers faq_df[answer].tolist() def get_similarity(q1, q2): inputs tokenizer(q1, q2, return_tensorspt, truncationTrue, paddingmax_length, max_length128) with torch.no_grad(): logits model(**inputs).logits probs torch.softmax(logits, dim1) return probs[0][1].item() # 相似类概率 app.route(/ask, methods[POST]) def ask(): user_q request.json.get(question, ) if not user_q: return jsonify({error: empty question}), 400 best_score 0 best_idx -1 for i, std_q in enumerate(questions): score get_similarity(user_q, std_q) if score best_score: best_score score best_idx i threshold 0.7 if best_score threshold: return jsonify({answer: answers[best_idx], score: best_score}) else: return jsonify({answer: 抱歉我没有找到相关答案, score: best_score}) if __name__ __main__: app.run(host0.0.0.0, port5000)逻辑说明每次请求遍历整个 FAQ 库逐条算相似度取最高分。参数说明threshold0.7是经验值但不同数据集上最优阈值不同需要用验证集调。max_length128要和训练时保持一致否则分词结果分布不同推理会偏。这个实现有个明显问题如果 FAQ 库有几千条逐条算 BERT 会非常慢。生产环境应该用双塔模型预编码所有标准问题推理时只编码用户问题然后算向量内积。但毕设项目为了代码简单通常就用这种暴力遍历几百条以内还能接受。3.2 阈值怎么定用验证集画 P-R 曲线阈值不是拍脑袋定的。正确做法是在验证集上对每个样本取最高相似度分数和真实标签然后遍历 0.1 到 0.9 的阈值算精确率和召回率。下面这段代码就是做这件事。import numpy as np from sklearn.metrics import precision_recall_curve # 假设 val_scores 是验证集上每个样本的最高相似度val_labels 是真实标签1 表示应该回答0 表示应该拒答 val_scores np.array([...]) # 实际运行时替换为真实数据 val_labels np.array([...]) precision, recall, thresholds precision_recall_curve(val_labels, val_scores) # 找到 F1 最高的阈值 f1_scores 2 * precision * recall / (precision recall 1e-8) best_idx np.argmax(f1_scores) best_threshold thresholds[best_idx] print(f最佳阈值{best_threshold:.3f}对应 F1{f1_scores[best_idx]:.3f})逻辑说明precision_recall_curve返回不同阈值下的精确率和召回率。参数说明val_scores是模型对每个验证样本给出的最高相似度val_labels是人工标注的「是否应该回答」。注意这里的标签不是「两个问题是否相似」而是「系统是否应该给出答案」因为阈值的作用是决定拒答还是回答。如果验证集里没有拒答样本需要人工构造一些不相关的问题作为负样本。这个步骤很多毕设项目直接跳过导致上线后要么什么都答阈值太低要么什么都不答阈值太高翻车现场很常见。3.3 用双塔模型把推理速度提上来如果 FAQ 库超过 500 条逐条 BERT 推理会慢到不可用。常见优化是换成双塔结构一个 BERT 编码用户问题另一个 BERT 编码标准问题各自输出一个向量算余弦相似度。标准问题的向量可以离线预计算好存成矩阵推理时只算用户问题的向量然后做一次矩阵乘法。下面是对应的模型定义和推理代码。import torch import torch.nn as nn from transformers import BertModel, BertTokenizer class DualEncoder(nn.Module): def __init__(self, model_namebert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(model_name) self.dropout nn.Dropout(0.1) def forward(self, input_ids, attention_mask, token_type_ids): outputs self.bert(input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids) # 取 [CLS] 位置的向量作为句向量 cls_embedding outputs.last_hidden_state[:, 0, :] return self.dropout(cls_embedding) # 离线编码所有标准问题 def encode_questions(questions, model, tokenizer, batch_size32): model.eval() all_embeddings [] for i in range(0, len(questions), batch_size): batch questions[i:ibatch_size] inputs tokenizer(batch, return_tensorspt, truncationTrue, paddingTrue, max_length64) with torch.no_grad(): embeddings model(**inputs) all_embeddings.append(embeddings) return torch.cat(all_embeddings, dim0) # 推理时只编码用户问题然后算内积 def retrieve(user_q, std_embeddings, model, tokenizer, top_k5): inputs tokenizer(user_q, return_tensorspt, truncationTrue, paddingTrue, max_length64) with torch.no_grad(): user_embedding model(**inputs) # 余弦相似度 user_embedding nn.functional.normalize(user_embedding, dim1) std_embeddings nn.functional.normalize(std_embeddings, dim1) scores torch.matmul(user_embedding, std_embeddings.T).squeeze(0) top_scores, top_indices torch.topk(scores, top_k) return top_scores, top_indices逻辑说明双塔的关键是两边共享同一个 BERT 或者各自独立训练时用对比学习损失如 InfoNCE让正样本对的向量靠近负样本对远离。参数说明max_length64比句对分类的 128 小因为单句编码不需要拼接。top_k5是召回阶段取前 5 个后面可以再接一个精排模型。这个方案把推理复杂度从 O(N) 次 BERT 前向降到 1 次 BERT 前向 1 次矩阵乘法N 是 FAQ 库大小。毕设项目如果只追求跑通可以不做双塔但如果你想让系统「能用」这是值得改的一步。4. 避坑与排查FAQ 问答系统上线前必须过的五道坎4.1 现象模型在验证集上准确率 99%上线后答非所问原因验证集和训练集同分布且负样本构造太简单。很多毕设项目构造负样本时直接从不同类别随机抽两条但这两条可能字面差异极大模型学到的只是「字面不同就是不相似」而不是真正的语义匹配。解决负样本要构造「难负样本」比如字面相似但语义不同的问法「退款流程」vs「退货流程」或者用 BM25 召回但标注为不相似的样本。另外验证集要单独构造不能从训练集里随机切否则同分布导致虚高。4.2 现象用户问「怎么退钱」系统返回「如何修改密码」原因阈值太低或者模型没有学到同义泛化。如果阈值设成 0.5很多不相关的问题也会超过阈值。解决先按 3.2 的方法把阈值调到 F1 最高点通常在 0.7 到 0.85 之间。如果调完还是错说明模型语义能力不够需要换更大的预训练模型如bert-base-chinese换成roberta-base或chinese-roberta-wwm-ext或者增加同义问法的训练数据。4.3 现象推理接口响应时间超过 3 秒原因逐条遍历 FAQ 库每条都跑一次 BERT。解决按 3.3 换成双塔模型离线编码标准问题。如果不想改模型至少把 FAQ 库限制在 200 条以内或者用 Faiss 做向量检索加速。另一个常见原因是max_length设得太大比如 512但 FAQ 问题通常不超过 50 个字改成 64 能快很多。4.4 现象训练 loss 不下降或者降到 0.1 后验证集准确率反而降原因学习率太大导致震荡或者过拟合。解决学习率从 2e-5 降到 1e-5 试试增加warmup_ratio到 0.2。如果 loss 降到很低但验证集差说明过拟合减少num_train_epochs到 2或者增加 dropout把BertForSequenceClassification的hidden_dropout_prob调到 0.3。另外检查数据里有没有标签错误——血泪经验标注错误的数据比模型本身更能毁掉效果。4.5 现象Flask 服务并发一高就崩原因Flask 默认单线程且模型推理占用 GPU 显存多个请求同时进来会 OOM。解决用gunicorn起多个 worker但每个 worker 都会加载一份模型显存翻倍。更实际的做法是加一个请求队列或者用torch.no_grad()和model.eval()确保不计算梯度。如果并发要求高考虑用 ONNX Runtime 或 TensorRT 加速推理把模型导出成 ONNX 格式推理速度能提升 2 到 3 倍。5. 把 FAQ 系统做扎实的一个小技巧用回译扩充同义问法如果你手里的数据集每条标准问题只有一种问法模型很难学到语义泛化。我一般会做一步回译扩充把中文问题翻译成英文再翻译回中文得到一条语义相同但表述不同的新问题。这个技巧不需要标注成本低对 FAQ 场景特别有效。下面是用googletrans做回译的代码注意这个库需要联网且有时不稳定可以换成百度翻译 API 或彩云小译 API。from googletrans import Translator import pandas as pd import time translator Translator() def back_translate(text): try: # 中文 - 英文 en translator.translate(text, srczh-cn, desten).text time.sleep(0.5) # 避免请求过快 # 英文 - 中文 zh translator.translate(en, srcen, destzh-cn).text return zh except Exception as e: print(f回译失败{text}错误{e}) return None df pd.read_csv(faq.csv, encodingutf-8) augmented [] for q in df[question]: new_q back_translate(q) if new_q and new_q ! q: augmented.append({question: new_q, answer: df[df[question] q][answer].values[0]}) aug_df pd.DataFrame(augmented) # 合并原始数据和回译数据 final_df pd.concat([df, aug_df], ignore_indexTrue) final_df.to_csv(faq_augmented.csv, indexFalse, encodingutf-8) print(f原始数据 {len(df)} 条回译扩充后 {len(final_df)} 条)逻辑说明回译生成的新问题与原问题语义相同但字面不同正好用来构造正样本对。参数说明time.sleep(0.5)是防止请求频率过高被封实际用 API 时按服务商限制调整。回译数据要过滤掉与原问题完全相同的以及翻译质量差的比如出现英文残留。扩充后重新按第 2 章的方法构造正负样本再训练模型验证集上的泛化能力通常能提升 3 到 5 个百分点。这个技巧我踩过坑有一次没加time.sleep请求被限流回译结果全是空白跑一晚上。所以慢一点没关系稳一点更重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表