news 2026/10/23 9:44:07

用Bert搭建智能问答系统:从数据准备到模型部署全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Bert搭建智能问答系统:从数据准备到模型部署全流程

简介:一套基于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已经足够跑通流程。

最后说一个我自己的教训:刚开始做这类项目时,我总想把数据清洗、训练、部署一步到位,结果在答案偏移上反复折腾了两天。后来养成习惯,每跑通一步就打印一条中间结果看一眼再继续,反而更快。数据一旦歪了,模型再强也救不回来。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 13:56:33

钉钉微应用免登实战:Java服务端与H5全链路接入指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/22 13:56:33

pstack-claude:用命令行打造Claude本地AI开发工作流

1. 项目缘起与整体设计思路1.1 为什么会有 pstack-claude 这个项目先说说 pstack-claude 这个名字。pstack 在技术圈里通常指的是一套“进程栈”式的工具集思路——把多个独立的命令行工具像搭积木一样叠起来&#xff0c;每一层负责一件事&#xff0c;层与层之间通过标准输入输…

作者头像 李华
网站建设 2026/10/22 13:56:33

XML 核心语法与实战解析:从 XSD 校验到流式处理全指南

做后端、做数据交换、做配置系统的朋友&#xff0c;对 XML 应该再熟悉不过。这几年 JSON 和 YAML 在轻量接口场景里占了主流&#xff0c;但 XML 从来就没有真正退场——配置文件、行业数据协议、文档标记、图形描述、办公文档的底层结构&#xff0c;到处都有它的身影。我见过不…

作者头像 李华
网站建设 2026/10/22 16:06:06

字符级BiLSTM-CRF实战:中文NER绕过分词的正确姿势

简介&#xff1a;基于字符的BiLSTM-CRF序列标注模型是一个面向自然语言处理课程设计与毕业设计的完整源码项目&#xff0c;适用于计算机相关专业学生学习和二次开发。项目内包含模型训练、推理、评估等Python脚本&#xff0c;以及配套的项目说明文档&#xff0c;可直接运行并复…

作者头像 李华
网站建设 2026/10/22 16:06:08

基于大数据的招聘数据爬取与可视化分析实战

毕设选题这件事&#xff0c;每年都让不少人头疼。大数据方向的课题名倒是好起&#xff0c;但答辩现场需要你讲清楚、能演示、还能从头复现。我这次做的题目是“基于大数据的招聘职业爬取与分析可视化”&#xff0c;听着像一套平台&#xff0c;实际上就是把招聘网站的公开职位信…

作者头像 李华
网站建设 2026/10/22 16:06:08

SpringBoot+MyBatis+Vue全栈实现汽车票网上预订系统

去年代理了一个客运站的信息化改造项目&#xff0c;其中一个核心模块就是汽车票网上预订系统。当时拿到需求第一反应是&#xff1a;这不就是个简化版12306吗&#xff1f;真做起来才发现完全不是一回事。长途客运有自己的业务规则——中途站能不能上车、同一班次不同区段票价怎么…

作者头像 李华