简介:一套基于Bert的中文智能问答系统完整毕设项目,面向自然语言处理方向的毕业生与学习者,整合命名实体识别(NER)、语义相似度计算与知识库问答(KBQA)等模块,涵盖数据构建、模型训练、推理测试等完整流程,适合课程设计、毕业设计或项目实战练手。压缩包共57个文件,其中25个Python源码文件承载核心逻辑,11个Markdown文档提供使用说明与原理介绍,另有XML配置、Shell脚本、JSON及测试数据等辅助资源,整体1.62MB,轻量易部署;附带中文预训练模型相关文件,能够直接运行调试。该项目经导师指导并认可,评审分98分,源码严格调试保证可运行。目前已有100人学习下载。文档涵盖环境配置、数据构造、训练评估与预测流程,并收录NLPCC2016KBQA数据集、NER与相似度实验数据、运行日志和示意图,目录按NER、SIM、KBQA等模块划分,便于快速定位代码;通过阅读源码与文档,可理解知识图谱问答系统的各环节实现,并基于现有脚本扩展自定义问答场景。
1. 用Bert做智能问答系统,到底在解决什么问题
很多人把“智能问答”想成给对话机器人套一个壳,真正落到毕设和工程里,高频做法是让模型在指定的资料里找答案。用Bert做智能问答系统的核心,是把问答转成机器阅读理解:输入一段文本和一个问题,模型输出答案在原文中的起止位置。这个方案的吸引力在于答案可溯源、训练数据好构造、且不需要很大的算力——一张消费级显卡甚至纯CPU都能做完整个演示闭环。
这篇笔记适合正在赶NLP方向毕设的同学,也适合想快速搭一个“给内部文档回答问题”原型的开发者。下面按选型、数据、训练、部署、排错的顺序把整条链路讲透,最后补两个能让答辩和演示加分的实操技巧。
2. 选型与整体架构:为什么是Bert,而不是GPT或纯检索
2.1 三类问答方案对比:从可解释性到训练成本
做问答系统第一步不是写代码,是选定任务形态。我见过不少同学一开始想用GPT系列做生成式问答,结果训练成本高、输出不可控,答辩时还被老师追问“答案哪来的”。用Bert做的是抽取式问答:模型只负责在一段给定的上下文里圈出答案片段。生成式方案适合开放域闲聊,而抽取式方案适合文档问答、FAQ问答、知识库问答这类需要答案有依据的场景。
| 方案 | 原理 | 答案可解释性 | 标注成本 | 推理耗时 |
|---|---|---|---|---|
| 抽取式(Bert) | 预测答案起止位置 | 高,答案能定位到原文 | 低,只需标起止位置 | 低 |
| 生成式(GPT系列) | 逐token生成答案 | 低,容易编造内容 | 高,需要高质量问答对 | 中高 |
| 纯检索(BM25/向量) | 召回整段文本 | 中,返回的是段落而非精确答案 | 零,但效果粗糙 | 最低 |
纯检索的问题在于它只能返回段落,用户还要自己从段落里找答案;生成式则可能一本正经地胡说八道。Bert正好卡在中间:它把“检索到的段落”压缩成“精确答案的一个片段”,这个片段天然来自原文,答辩时老师问“答案依据是什么”,直接指给老师看就行。
2.2 从原始文本到答案的完整数据流
一个可跑的Bert问答系统,数据流分成四层。第一层是数据准备,把FAQ、文档、PDF转成统一格式的问答对;第二层是训练,加载预训练模型做阅读理解微调;第三层是推理,把模型输出的起止位置还原成文本;第四层是服务化,用HTTP接口把推理能力暴露给前端页面。毕设演示时通常还需要一个最简单的Web界面,输入问题和资料,输出答案和置信度。
数据格式建议直接采用SQuAD 2.0的结构,因为Hugging Face的transformers库原生支持这个格式,社区里大量中文数据集也沿用它。下面是一段样例:
{ "version": "squad2.0", "data": [ { "paragraphs": [ { "context": "BERT模型由Google在2018年提出,它使用Transformer编码器作为核心结构。", "qas": [ { "id": "qa1", "question": "BERT由谁提出?", "answers": [ {"text": "Google", "answer_start": 7} ], "is_impossible": false } ] } ] } ] }这里的answer_start是答案在context字符串中的字符偏移量,从0开始计数。中文场景下,一定要确保这个偏移量按字符算而不是按字节算,否则训练阶段模型学到的位置就是乱套的。常见做法是统一用Python的len()对字符串做偏移计算,避免混入字节偏移。
3. 训练与微调实操:数据清洗、Tokenizer与关键参数
3.1 用脚本把SQuAD格式JSON加载成训练样本
拿到源码包之后,第一步要确认数据入口。多数开源的Bert问答项目都会给一个squad-style的JSON文件,但里面的答案位置经常不干净:文本里有换行、全角空格、HTML标签残留,导致answer_start偏移错位。我一般会先写一个加载脚本,在进模型之前做一次清洗和重定位。
import json def load_squad_samples(path: str) -> list: """加载 SQuAD 格式 JSON,并统一清洗答案锚点""" with open(path, "r", encoding="utf-8") as f: raw = json.load(f) samples = [] for article in raw.get("data", []): for paragraph in article.get("paragraphs", []): context = paragraph["context"].strip() for qa in paragraph.get("qas", []): question = qa["question"].strip() # 2.0 格式支持无答案问题,先过滤掉,降低训练难度 if qa.get("is_impossible", False): continue if not qa.get("answers"): continue ans = qa["answers"][0] answer_text = ans["text"].strip() answer_start = context.find(answer_text) # 重新定位,避免原始标注里有多余空格 if answer_start == -1: continue samples.append({ "id": qa["id"], "context": context, "question": question, "answer": answer_text, "answer_start": answer_start, }) return samples逻辑说明:清洗的核心是用context.find()重新计算答案位置,而不是直接信任JSON里的answer_start。原因很实际,人工标注或程序导出时经常在答案前后带空格、换行,字符串匹配一错位,训练标签就全歪了。过滤掉is_impossible的样本,是为了让第一版模型先专注在有答案的题目上,等主流程跑通后再加入无答案分类头。
参数说明:path指向数据文件;返回值是样本列表,每条包含context、question、answer、answer_start四个字段。answer_start的用途是在编码阶段把它换算成token级别的起止下标,这一步放在下一个脚本里做。
3.2 Tokenizer的偏移映射与训练参数选择
Bert不能直接吃字符串,要先经过Tokenizer。中文场景下bert-base-chinese是默认选择,它按字切分,不会出现英文里subword带来的词表爆炸问题。有一点要特别强调:tokenizer输出的input_ids、attention_mask、token_type_ids三段张量只是模型输入,真正难处理的是把answer_start从字符偏移转换成token下标。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") def encode_sample(sample, max_length=384, stride=128): """将单条样本编码成模型输入,并修正答案的 token 位置""" encoding = tokenizer( sample["question"], sample["context"], max_length=max_length, truncation="only_second", stride=stride, padding="max_length", return_offsets_mapping=True, return_tensors=None, ) start_char = sample["answer_start"] end_char = start_char + len(sample["answer"]) token_start = None token_end = None # offset_mapping 是每个 token 对应原文本的 (start, end) 字符位置 for i, (s, e) in enumerate(encoding["offset_mapping"]): if s == 0 and e == 0: # 特殊 token,如 [CLS]、[SEP]、[PAD] continue if token_start is None and s <= start_char < e: token_start = i if token_end is None and s < end_char <= e: token_end = i if token_start is not None and token_end is not None: break if token_start is None or token_end is None: return None return { "input_ids": encoding["input_ids"], "attention_mask": encoding["attention_mask"], "token_type_ids": encoding["token_type_ids"], "start_positions": token_start, "end_positions": token_end, }逻辑说明:这里用offset_mapping把token位置映射回字符位置。注意pad部分和[CLS]/[SEP]的offset是(0,0),必须跳过,否则会把答案定位到特殊符号上。token_start和token_end的判定条件分别处理“答案起点落在token区间内”和“答案终点落在token区间内”,两者必须都找到才算一条有效样本。
参数说明:max_length=384是中文阅读理解比较常用的长度,比512省显存,又能覆盖大多数文档段落;stride=128表示当context超长时,用128个token的重叠做滑窗,避免切断句子导致答案跨窗丢失。truncation="only_second"表示只截断context不截断question,这是问答任务里约定俗成的做法。
训练参数方面,基于transformers的Trainer是最省事的路径:
from transformers import BertForQuestionAnswering, Trainer, TrainingArguments model = BertForQuestionAnswering.from_pretrained("bert-base-chinese") training_args = TrainingArguments( output_dir="./qa_output", learning_rate=3e-5, per_device_train_batch_size=8, per_device_eval_batch_size=8, num_train_epochs=3, weight_decay=0.01, save_strategy="epoch", evaluation_strategy="epoch", logging_dir="./logs", warmup_steps=500, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, ) if __name__ == "__main__": trainer.train()参数说明:learning_rate=3e-5是Bert微调的安全区,大于5e-5很容易让模型灾难性遗忘;num_train_epochs=3适用于几千到几万条样本的中等规模数据集,如果数据量小可以降到2;warmup_steps=500让前期学习率缓慢爬升,稳定性更好。显存不够时,把per_device_train_batch_size降到4或2,同时增加梯度累积,例如gradient_accumulation_steps=4,效果基本等效。
4. 预测与部署:把微调好的模型变成能交互的问答接口
4.1 解码答案:为什么不能直接对logits取argmax
训练完成之后,模型输出的是start_logits和end_logits两个向量,分别表示每个token作为答案起点和终点的得分。新手最容易在这个环节翻车:直接对两个向量分别取argmax,然后截取中间token。这样大概率得到空答案或者一长串[SEP][CLS]之类的垃圾输出,原因很简单——模型认为最可能的起点在第10个token,最可能的终点在第5个token,这两个位置互相矛盾。
正确的做法是做受限解码:先各自取score最高的若干个候选位置,再从中寻找“起点在终点之前、且长度不超过上限”的组合,取总分最高的一组。
import torch def decode_answer(start_logits, end_logits, input_ids, tokenizer, max_answer_len=30): """ 从 start/end logits 中解码出最佳答案片段 max_answer_len: 答案最大长度,按 token 计 """ seq_len = input_ids.shape[0] start_probs = torch.softmax(start_logits, dim=-1).flatten() end_probs = torch.softmax(end_logits, dim=-1).flatten() # 每个方向各取 top10 候选,避免全序列遍历带来的噪声 top_starts = start_probs.topk(10).indices.tolist() top_ends = end_probs.topk(10).indices.tolist() best_score = -1.0 best_span = None for s in top_starts: for e in top_ends: if e < s: continue if e - s + 1 > max_answer_len: continue score = start_probs[s] * end_probs[e] if score > best_score: best_score = score best_span = (s, e) if best_span is None: return "", 0.0 s, e = best_span answer = tokenizer.decode(input_ids[s:e+1], skip_special_tokens=True) return answer, best_score逻辑说明:topk(10)把搜索空间限制在20个位置上,只需最多100次组合判断,比全序列O(n²)轻量得多。score用起点概率乘终点概率,体现“起止同时成立”的联合强度。best_span为None表示该问题在文本中找不到合理答案,此时返回空字符串,前端可以据此展示“未找到答案”。
参数说明:max_answer_len=30按token算,中文场景下一句话通常在10到30个字左右,设太大会混入无关内容,设太小会截断答案。如果发现答案总被截断,优先调大这个值,不要动模型。
4.2 用FastAPI封装接口,并处理CPU/GPU差异
模型跑通后,下一步是让前端UI能调用。常见做法是直接用FastAPI包一层HTTP接口,用pydantic做入参校验,避免前端传了非法数据导致服务崩溃。接口设计上,context和question都作为必填字段,answer为空时正常返回200,不要抛异常。
from fastapi import FastAPI from pydantic import BaseModel from transformers import BertForQuestionAnswering, BertTokenizerFast import torch app = FastAPI() class QaRequest(BaseModel): context: str question: str model_path = "./qa_output/checkpoint-1200" tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") model = BertForQuestionAnswering.from_pretrained(model_path) model.eval() def predict(context: str, question: str): inputs = tokenizer( question, context, max_length=384, truncation="only_second", stride=128, return_tensors="pt", ) with torch.no_grad(): outputs = model(**inputs) answer, score = decode_answer( outputs.start_logits[0], outputs.end_logits[0], inputs["input_ids"][0], tokenizer, ) return answer, score @app.post("/qa") def qa_endpoint(req: QaRequest): answer, score = predict(req.context, req.question) return {"answer": answer, "score": round(float(score), 4)}逻辑说明:推理时用torch.no_grad()关闭梯度计算,显存占用和耗时都会显著下降。BERT的输出在CPU上也能跑,但单条推理耗时通常在1到3秒之间,如果演示时觉得卡,常见做法是把模型导出为ONNX格式,推理速度能提升2到3倍。注意FastAPI默认并发是线程池,对单个CPU上的Bert来说并发反而会加剧排队,演示场景下单进程单线程就够了。
参数说明:model_path指向训练时保存的最优checkpoint,不要直接加载bert-base-chinese,那只是预训练模型,不经过微调它只会输出原文中概率上“像答案”的片段。score返回的是0到1之间的概率乘积,可以用作前端展示“置信度”。
5. 问答效果翻车的五个常见排查:从答案错位到训练失效
5.1 答案错位、输出垃圾字符这两个高频故障
我把这类问题排在第一,是因为十个跑这个项目的同学里至少有六个会撞上。现象是预测答案总比真实答案往前或往后偏移一两个字符,有时候答案是“。十年”这种半截话。原因有两个层面:一是tokenizer把答案切到了子词边界上,中文里表现为错半个字;二是offset_mapping里padding部分的(0,0)没过滤,把[PAD]也算进了候选位置。
解决方法是严格按第3.2节的offset_mapping逻辑转换答案锚点,并在decode时用tokenizer.decode而不是手动拼input_ids。另一个直接原因是decode端没有限制e >= s,导致终点索引小于起点索引,解码出一段包含[SEP]的垃圾。这个检查我用得最多:看到输出里有“的。是”这类字样,第一反应就是起止位置没做约束。把第4.1节的topk受限解码代进去,问题基本消失。
5.2 训练侧翻车:loss不降、val指标差、显存溢出
训练阶段有三个典型故障。第一个是loss不降,往往是把answer_start传成了字符偏移而不是token偏移,模型学了个寂寞。排查方法很简单:训练前打印一两条样本的start_positions,看它是否落在context对应的token区间内,不要等20个epoch跑完才回头查数据。
第二个是eval loss下降但实际预测效果差,原因是数据集里存着大量近义答案,比如“Google”和“谷歌”同时出现,标注时answer_start指向了其中一处,模型学到的却是另一个位置。缓解办法是把答案重定位逻辑改成“优先精确匹配、其次正则匹配”,并且在构建数据集时统一实体表述。
第三个是显存溢出。per_device_train_batch_size=8在12GB显卡上跑bert-base-chinese通常没问题,如果改用了更大的模型或者max_length=512,就很容易OOM。此时把batch_size降到4,再加gradient_accumulation_steps=4,效果几乎无损。还有个玄学问题是warmup_steps设得太大,小数据集上模型还没来得及进入稳定学习区就结束了,warmup_steps建议不要超过总训练步数的10%。
5.3 演示环境只有CPU:推理慢得让人尴尬
毕设答辩现场经常没有GPU,CPU上一条问答要等待两三秒钟,交互体验很煎熬。常见做法是把模型导出成ONNX,推理速度通常能提升2倍以上。导出本身不复杂,用transformers.onnx导出脚本即可,但要注意动态轴问题,因为每次输入的question长度可能不同,导出时要设置dynamic_axes。
如果连ONNX都不想折腾,还有一个讨巧做法:预热。服务启动后先跑几条固定问答,让显存或CPU缓存预热,之后单条查询的响应时间会明显下降。这个技巧在演示前特别管用,属于血泪经验。
6. 从能跑到能答辩:滑窗扩展与半自动数据增强
6.1 长文档裁剪的滑窗拼接
Bert的单次输入长度限制是512个token,而实际问答场景里文档动辄几千字。直接用truncation截断会丢掉答案所在区域,用stride滑窗又可能出现答案被切成两半。常见做法是把文档按stride切成多个窗口,每个窗口单独过模型,再按答案得分跨窗口取最大值。
def predict_long_document(context: str, question: str, max_len=384, stride=128): """用滑窗处理超长文档,返回全局最优答案""" best_answer = "" best_score = 0.0 start = 0 while start < len(context): chunk = context[start:start + max_len] answer, score = predict(chunk, question) if score > best_score: best_answer = answer best_score = score start += stride return best_answer, best_score这里stride取128,比max_len小得多,确保切在答案内部的概率降至最低。滑窗会带来重复计算,但对演示级别的项目来说,准确率优先于速度。这个功能建议放在文档问答界面的高级选项里,答辩时可以现场演示“长文档照样能答”。
6.2 没有现成数据时,用FAQ半自动构造训练集
很多毕设会卡在没有标注数据上。其实手里握着几页FAQ就能构造一个可用的训练集:把FAQ的“问题”当作question,把答案所在的整段说明当作context,答案文本用字符串匹配自动定位answer_start。这个过程可以用脚本批量完成,构造出的数据虽然比纯人工标注粗糙,但用来微调Bert已经足够跑通流程。
最后说一个我自己的教训:刚开始做这类项目时,我总想把数据清洗、训练、部署一步到位,结果在答案偏移上反复折腾了两天。后来养成习惯,每跑通一步就打印一条中间结果看一眼再继续,反而更快。数据一旦歪了,模型再强也救不回来。希望帮到你。
本文还有配套的精品资源,点击获取