ARTICLE DETAIL

资讯详情

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

基于深度学习的中文问答系统毕设实战:从BERT检索到FAISS加速

基于深度学习的中文问答系统毕设实战:从BERT检索到FAISS加速 简介这份毕业设计资源面向计算机相关专业学生与NLP入门开发者提供一套基于深度学习的中文问答系统完整源码可用于课程设计、毕设答辩或自学自然语言处理。压缩包共26个文件约16.39MB以9个Python脚本为核心涵盖编码器、注意力解码器、数据预处理与训练入口另有7个txt语料与配置说明、6个xml工程配置、1个md说明文档及license等辅助文件目录结构清晰便于按模块阅读与二次开发。项目围绕中文问答任务串联分词、词向量化、RNN/LSTM/GRU与Transformer等模型训练、损失函数与优化器调参、准确率与BLEU评估等关键环节并涉及模型微调与部署思路。已有243人学习下载适合希望从代码层面理解NLP问答系统实现、积累工程经验并完成毕业设计的读者参考。1. 从一份「中文问答系统」毕设压缩包说起它到底在解决什么问题每年毕业季计算机和软件工程专业的选题里基于深度学习的中文问答系统都是高频出现的一类。你拿到手的往往是一个压缩包里面塞着数据、模型代码、训练脚本和一份说明文档。但真正让人头疼的不是「有没有代码」而是「这套东西到底能不能跑起来、跑起来之后效果对不对、答辩时老师问的那几个问题我能不能答上来」。中文问答系统和英文问答最大的差别在于分词、语义歧义和语料规模同样一句「苹果多少钱一斤」在不同语境下指向水果还是手机品牌模型必须靠上下文判断。这个方向适合两类人一类是想借毕设真正入门深度学习项目实战的本科生另一类是需要一个可复现 baseline 再往上做改进的研究生。它不要求你从零发明算法但要求你能把数据处理、模型选型、训练调参、推理部署这条链路完整走通。接下来我会按「先搞清楚系统由哪几块组成再动手把最小版本跑通最后处理那些一定会遇到的坑」这个顺序讲每一步都给出可抄的代码和参数说明。2. 中文问答系统的技术选型检索式、生成式还是混合式2.1 三种主流架构的适用边界在动手写代码之前必须先确定你的系统属于哪一类。中文问答系统按技术路线通常分为三种检索式、生成式和混合式。检索式Retrieval-based的核心思路是从一个预先构建的问答库中找到与用户问题最匹配的问题然后返回对应的答案。它的优点是答案可控、不会胡说缺点是只能回答库里有的问题。生成式Generative-based则是让模型直接「写」出答案典型代表是基于 Seq2Seq 或 Transformer 的模型灵活度高但容易生成看似合理实则错误的内容。混合式先检索候选再生成或重排兼顾两者但工程复杂度最高。对于毕业设计来说我一般建议先从检索式做起。原因很实际检索式系统的效果容易量化召回率、准确率训练成本低而且答辩时老师问「你怎么评估效果」你能答得上来。生成式虽然听起来更高级但训练不稳定、评估困难很容易做到最后发现模型只会输出「我不知道」或者重复通用回复。选型时还要考虑你的数据情况。如果你手头有几千到几万条问答对检索式足够用如果只有几百条那连检索式都勉强需要考虑用预训练模型做迁移学习。常见做法是先用 BERT 做句向量编码再用向量相似度做召回最后用一个小的排序模型做精排。2.2 用 BERT 向量检索搭一个最小可用版本下面这段代码展示的是检索式问答系统的核心逻辑把知识库里的所有问题编码成向量用户提问时同样编码然后计算余弦相似度取 Top-K。import torch import numpy as np from transformers import BertTokenizer, BertModel from sklearn.metrics.pairwise import cosine_similarity # 加载预训练的中文 BERT 模型和分词器 # bert-base-chinese 是最常用的中文预训练模型参数量约 110M tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertModel.from_pretrained(bert-base-chinese) model.eval() def encode_texts(texts, batch_size32): 将一批文本编码为固定维度的向量取 [CLS] 位置的输出 all_embeddings [] for i in range(0, len(texts), batch_size): batch texts[i:i batch_size] # paddingTrue 自动补齐到批次内最长truncationTrue 截断超长文本 # max_length128 是问答场景的常用值覆盖绝大多数中文问题 inputs tokenizer(batch, paddingTrue, truncationTrue, max_length128, return_tensorspt) with torch.no_grad(): outputs model(**inputs) # 取最后一层的 [CLS] 向量作为句子表示 cls_embeddings outputs.last_hidden_state[:, 0, :].numpy() all_embeddings.append(cls_embeddings) return np.vstack(all_embeddings) # 假设 knowledge_base 是从 JSON 或 CSV 加载的问答对列表 # 格式[{question: ..., answer: ...}, ...] kb_questions [item[question] for item in knowledge_base] kb_answers [item[answer] for item in knowledge_base] # 离线编码知识库中的所有问题这一步只需做一次 kb_vectors encode_texts(kb_questions) def retrieve(query, top_k5): 检索与用户问题最相似的知识库问题 query_vec encode_texts([query]) # 计算余弦相似度shape 为 (1, len(kb_questions)) sims cosine_similarity(query_vec, kb_vectors)[0] # argsort 返回升序索引取反后取前 top_k top_indices np.argsort(sims)[::-1][:top_k] results [] for idx in top_indices: results.append({ question: kb_questions[idx], answer: kb_answers[idx], score: float(sims[idx]) }) return results这段代码的关键参数有三个。第一是max_length128中文问题通常不超过 50 个字128 足够覆盖且不会浪费显存。第二是batch_size32在 8GB 显存的显卡上跑 BERT-base 推理没问题如果显存更小就降到 16 或 8。第三是相似度阈值实际使用时不能只看 Top-1因为如果用户问了一个知识库里完全没有的问题Top-1 的相似度可能只有 0.3这时候应该返回「抱歉我暂时无法回答这个问题」而不是硬答。我一般会设一个阈值比如 0.75低于这个值就触发兜底逻辑。2.3 生成式方案什么时候值得上如果你的毕设要求里明确写了「需要生成答案」或者你想冲一下更好的答辩评价那可以在检索式的基础上加一个生成模块。常见做法是用检索出的 Top-K 问答对作为上下文拼接成一个 prompt 喂给生成模型。但要注意本科毕设的计算资源通常有限从头训练一个生成模型不现实更可行的是用已经开源的中文预训练生成模型做微调或者直接调用现成的推理接口。这里不展开具体模型名称因为版本迭代太快你只需要知道生成式方案的数据预处理比检索式复杂至少一倍训练时间至少多三倍评估指标也更难定义。如果你的时间只有两三个月先把检索式做扎实。3. 数据准备与预处理中文语料到底要怎么清洗3.1 问答对的采集与格式统一中文问答系统的数据来源通常有三种公开数据集、爬取的 FAQ 页面、以及自己构造的问答对。公开数据集里比较常见的是基于百科或社区问答整理的版本格式一般是 JSON 或 CSV。不管来源是什么第一步都是统一格式。我一般会定义一个标准结构# 标准问答对格式 { id: q_0001, question: 如何申请校园网账号, answer: 请携带学生证到网络中心填写申请表三个工作日内开通。, category: 校园服务, source: manual }字段说明id用于去重和追踪question和answer是核心字段category方便后续做分类检索source标记数据来源以便排查脏数据。如果你的原始数据是 CSV用 pandas 读进来之后做列名映射即可。import pandas as pd import json df pd.read_csv(raw_qa.csv) # 假设原始列名是 提问 和 回答 df df.rename(columns{提问: question, 回答: answer}) # 去掉空值和重复 df df.dropna(subset[question, answer]) df df.drop_duplicates(subset[question]) # 导出为标准 JSON records df.to_dict(orientrecords) with open(clean_qa.json, w, encodingutf-8) as f: json.dump(records, f, ensure_asciiFalse, indent2)这里有个容易翻车的地方中文文本里经常混有全角空格、零宽字符、HTML 标签残留。如果不清理编码出来的向量会带噪声。我一般会加一步正则清洗import re def clean_text(text): # 去掉 HTML 标签 text re.sub(r[^], , text) # 去掉零宽字符和不可见字符 text re.sub(r[\u200b-\u200f\u2028-\u202f\ufeff], , text) # 全角空格转半角 text text.replace(\u3000, ) # 多个连续空格合并为一个 text re.sub(r\s, , text) return text.strip()3.2 分词、去停用词与数据增强中文和英文不同词与词之间没有天然空格所以分词是绕不开的一步。虽然 BERT 这类模型用的是字级别或子词级别的 tokenizer不需要你手动分词但如果你用的是 TF-IDF 或 BM25 做召回分词质量直接影响效果。常用的中文分词工具是 jieba用法很简单import jieba def tokenize(text): # cut_allFalse 表示精确模式适合问答场景 tokens jieba.lcut(text, cut_allFalse) # 过滤掉单字和停用词 stopwords set([的, 了, 是, 在, 和, 就, 都, 而, 及, 与]) tokens [t for t in tokens if len(t) 1 and t not in stopwords] return tokens数据增强在问答系统里也很实用尤其是当你的问答对数量偏少时。常见做法包括同义词替换用同义词词典替换问题中的非关键实体、回译把中文翻译成英文再翻译回中文但需要翻译接口、以及模板生成针对同一意图构造不同问法。我一般会先做同义词替换因为实现成本最低# 简易同义词替换示例 synonym_dict { 如何: [怎么, 怎样], 申请: [办理, 申领], 账号: [账户, 帐号] } def augment_question(question): augmented [question] for word, synonyms in synonym_dict.items(): if word in question: for syn in synonyms: augmented.append(question.replace(word, syn)) return augmented注意数据增强不是越多越好增强后的数据要人工抽检避免出现语义偏移。我见过有人把「如何注销账号」增强成「怎么注销账户」这没问题但如果把「苹果手机」替换成「香蕉手机」那就闹笑话了。4. 模型训练与调参从 BERT 微调到效果验证4.1 微调 BERT 做问答匹配的完整流程检索式问答系统的核心是一个匹配模型给定用户问题和知识库问题判断它们是否语义相同。这本质上是一个二分类或排序任务。下面是用 HuggingFace 的 Trainer 做微调的完整代码框架import torch from torch.utils.data import Dataset from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments class QAPairDataset(Dataset): def __init__(self, pairs, tokenizer, max_length128): self.pairs pairs self.tokenizer tokenizer self.max_length max_length def __len__(self): return len(self.pairs) def __getitem__(self, idx): q1, q2, label self.pairs[idx] encoding self.tokenizer( q1, q2, paddingmax_length, truncationTrue, max_lengthself.max_length, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(label, dtypetorch.long) } # 构造训练数据正样本 label1负样本 label0 # 负样本通常从知识库中随机采样与正样本不匹配的问题 tokenizer BertTokenizer.from_pretrained(bert-base-chinese) model BertForSequenceClassification.from_pretrained(bert-base-chinese, num_labels2) training_args TrainingArguments( output_dir./qa_model, num_train_epochs3, # 中文问答微调通常 3-5 轮足够 per_device_train_batch_size16, # 根据显存调整8GB 显存建议 16 per_device_eval_batch_size32, learning_rate2e-5, # BERT 微调的标准学习率范围 1e-5 到 5e-5 warmup_ratio0.1, # 前 10% 步数做学习率预热 weight_decay0.01, logging_steps50, evaluation_strategyepoch, save_strategyepoch, load_best_model_at_endTrue, metric_for_best_modelaccuracy ) trainer Trainer( modelmodel, argstraining_args, train_datasettrain_dataset, eval_dataseteval_dataset ) trainer.train()参数说明learning_rate2e-5是 BERT 微调的经典值太大容易震荡太小收敛慢。num_train_epochs3是因为 BERT 已经在海量中文语料上预训练过微调只需要少量轮次。warmup_ratio0.1能防止训练初期梯度爆炸。per_device_train_batch_size16在 8GB 显存上是安全的如果你用 16GB 显存可以提到 32。4.2 评估指标怎么选、怎么算问答系统的评估不能只看准确率。检索式系统常用的指标有三个RecallK、MRR平均倒数排名和准确率。RecallK 衡量的是正确答案是否出现在前 K 个结果里MRR 衡量正确答案的平均排名位置。对于毕设来说我建议至少报告 Recall5 和 MRR 两个指标。def evaluate_retrieval(model, tokenizer, test_pairs, kb_questions, top_k5): 评估检索效果 correct_at_k 0 reciprocal_ranks [] for query, true_answer_idx in test_pairs: # 对知识库中所有问题打分 scores [] for kb_q in kb_questions: inputs tokenizer(query, kb_q, paddingTrue, truncationTrue, max_length128, return_tensorspt) with torch.no_grad(): logits model(**inputs).logits score torch.softmax(logits, dim1)[0][1].item() scores.append(score) # 按分数降序排列 ranked_indices np.argsort(scores)[::-1] # RecallK if true_answer_idx in ranked_indices[:top_k]: correct_at_k 1 # MRR rank np.where(ranked_indices true_answer_idx)[0] if len(rank) 0: reciprocal_ranks.append(1.0 / (rank[0] 1)) recall_at_k correct_at_k / len(test_pairs) mrr np.mean(reciprocal_ranks) return {Recall{}.format(top_k): recall_at_k, MRR: mrr}这段代码的逻辑是对每个测试问题用模型给知识库中所有候选问题打分然后看正确答案排在第几位。注意这里用的是逐条打分实际部署时应该用向量化方式批量计算否则推理速度会很慢。4.3 训练不收敛时的排查顺序训练不收敛是深度学习项目里最常见的翻车场景。我一般按这个顺序排查先看数据标签有没有错正负样本标反了是最常见的低级错误再看学习率是不是太大loss 震荡剧烈然后看 batch size 是不是太小梯度噪声大最后看模型是不是加载错了比如加载了随机初始化的权重而不是预训练权重。还有一个容易被忽略的点中文文本没有做 truncation导致超长文本被截断后语义完全变了。如果你发现训练 loss 正常下降但评估指标不动大概率是评估数据的构造有问题比如负样本采样时不小心把正样本也采进去了。5. 避坑与排查中文问答系统落地时最容易翻车的 5 个地方5.1 现象模型在训练集上准确率 99%测试集只有 60%原因过拟合或者训练集和测试集有重叠。中文问答数据集如果是从同一个来源切分的很容易出现同一个问题的不同问法同时出现在训练集和测试集里。解决按问题意图分组切分确保同一意图的所有问法只出现在一个集合里。另外加 dropout 和权重衰减。5.2 现象用户问「怎么退款」系统返回「如何申请发票」原因这两个问题的向量相似度确实高因为都涉及「申请」和「流程」类词汇。解决在检索阶段加入关键词过滤或类别约束先做意图分类再在对应类别内检索。另外可以引入 BM25 做混合召回BM25 对关键词匹配更敏感能弥补纯语义向量的不足。5.3 现象推理时显存溢出OOM原因知识库太大一次性把所有问题编码成向量时 batch_size 设得太大或者 max_length 设成了 512。解决把编码过程改成流式处理每批处理完就存到磁盘不要全部留在内存里。max_length 从 512 降到 128 通常能省一半以上显存。如果知识库超过 10 万条考虑用 FAISS 做向量索引而不是暴力计算余弦相似度。5.4 现象同一个问题每次返回的答案不一样原因模型没有设成 eval 模式dropout 还在起作用。解决推理前调用model.eval()并且用torch.no_grad()包裹推理代码。这个坑很隐蔽因为训练时一切正常只有推理时才会暴露。5.5 现象中文标点导致匹配失败原因用户输入「如何申请账号」和知识库里的「如何申请账号」因为问号导致向量有差异。解决在预处理阶段统一标点把全角标点转半角或者直接去掉句末标点。这个坑在英文系统里不明显但中文用户输入习惯差异大必须处理。6. 把系统跑得更稳向量索引加速与兜底策略当你把基础版本跑通之后下一步要考虑的是性能和鲁棒性。知识库超过一万条时暴力计算余弦相似度的延迟会明显上升。我一般会用 FAISS 做近似最近邻搜索把检索延迟从几百毫秒降到几毫秒。import faiss # 假设 kb_vectors 是 numpy 数组shape 为 (N, 768) dimension kb_vectors.shape[1] # IndexFlatIP 使用内积作为距离度量配合归一化后的向量等价于余弦相似度 index faiss.IndexFlatIP(dimension) # 先做 L2 归一化 faiss.normalize_L2(kb_vectors) index.add(kb_vectors) def fast_retrieve(query_vec, top_k5): faiss.normalize_L2(query_vec) distances, indices index.search(query_vec, top_k) return distances[0], indices[0]参数说明IndexFlatIP是精确搜索适合知识库在十万条以内的场景。如果超过十万条换成IndexIVFFlat并设置nlist参数通常取 sqrt(N)但需要额外训练索引。归一化这一步不能省否则内积不等于余弦相似度。兜底策略方面我一般会设三层第一层是相似度阈值低于阈值返回「无法回答」第二层是敏感词过滤命中敏感词直接返回预设话术第三层是超时保护检索超过 500 毫秒就返回缓存中的热门答案。这三层看起来简单但能避免 90% 的线上尴尬场景。还有一个我踩过的坑知识库更新后忘记重新编码向量。如果你用 FAISS 建了索引新增问答对之后必须重新构建索引或者用index.add()追加否则新问题永远检索不到。我当时的做法是写了一个定时任务每天凌晨检查知识库文件是否有变更有变更就自动重建索引。这个习惯帮我省了很多后悔药。最后说一个验证技巧不要只用准确率判断系统好坏找几个真实用户同学也行做盲测记录他们的问题和系统的回答人工判断是否满意。我做过一次这样的测试发现模型在标准测试集上 Recall5 有 85%但真实用户满意度只有 60%原因是用户的问题更口语化、更短、更模糊。后来我在预处理阶段加了口语化映射表把「咋弄」「咋整」映射成「如何」满意度才提上来。这个经验告诉我毕设系统可以跑通只是起点能不能让人用起来舒服才是分水岭。希望帮到你。本文还有配套的精品资源点击获取
返回列表