☰
Python基于BERT的智能问答系统:从抽取式问答到毕设落地
2026/10/1 19:11:08 网站建设 项目流程

简介:这是一个基于Bert的中文智能问答系统毕业设计项目,面向计算机专业学生和NLP初学者,特别适合用于课程设计、毕业设计或项目实战练习。项目主要解决如何利用预训练模型完成知识图谱问答与命名实体识别的问题,聚焦KBQA与NER两大核心任务,融合Bert、LSTM-CRF等经典结构,提供完整可运行的源码和文档说明,设计评审得分98分,具备较高参考价值。压缩包共57个文件,大小约1.62MB,文件以Python脚本为主(约25个py文件),辅以Markdown说明文档、XML/JSON等配置文件、Shell脚本以及训练与测试数据集;目录涵盖模型参数、输出结果、数据预处理、实验结果日志等模块,结构清晰易读。已有100人学习下载,使用者可从中获得从数据构造、实体识别训练到相似度计算的完整实现思路,包含NLPCC2016知识图谱数据集与相关预处理脚本,配合中文文档能快速复现和调试。项目源码已经过严格调试,可直接运行,适合在此基础上扩展为实用的智能问答系统。

1. Python基于Bert的智能问答系统:为什么它是高分毕设里性价比最高的一档

“python基于bert的智能问答系统”近年在NLP方向的毕业设计里出镜率很高。很多人觉得它难,是因为一上来就想做大而全的开放问答;实际按“给一段文本、问一个问题、在原文中框出答案区间”的阅读理解范式来做,模型、训练、指标、演示全都可控。它的价值是把数据清洗、预训练模型微调、评估体系、交互演示四条线一次性串起来,正好覆盖毕业设计评审需要的完整证据链。适合NLP基础一般但想拿一段完整项目经历的同学,也适合想在四到六周内产出一版可演示成果、并有余力加入领域数据的人。即使手里只有一张显卡甚至只靠CPU推理,这个方向依然可控;真正劝退人的不是模型复杂度,而是数据编码和标签对齐里的那些细节。

2. Bert问答三条技术路线:生成式、检索式和抽取式怎么选

2.1 同样叫问答,先分清楚三种任务形态

围绕“智能问答系统”这个标题,第一件要厘清的事是:同样叫问答,底层任务有三种,训练成本、演示效果和答辩可解释性差别很大。

生成式用类似T5或Bart的seq2seq模型逐字生成答案,能从上下文里组织出一句完整回答。听起来最先进,但普通显卡训练门槛高,而且模型会“编答案”,生成一段原文里根本没有的内容。这种不可控性在答辩演示时最危险,评审问“这答案从哪来的”,你很难解释清。

检索式把问答拆成召回和排序两段:先用句向量在知识库里找Top-K候选,再让排序模型挑最接近的答案。它适合开放域,难点在于知识库组织、负样本构造和阈值选择,每一样都要额外调一段时间,评估指标也容易变成“命中就算过”,说服力弱。

抽取式完全复用机器阅读理解范式。它把question和context拼成BERT的一段输入,模型在token序列上预测答案的起始位置和结束位置。模型不生成新内容,只做原文区间选择,答案天然是原文的子串,没有自由生成带来的幻觉压力。输出可以直接计算EM精确匹配和F1重叠度,答辩时一张表就能说明白。

路线任务定义典型模型训练成本答案可控性毕设适配度
生成式生成自由文本T5 / Bart高低,可能幻觉一般不建议
检索式召回加排序DPR / Sentence-BERT中中,依赖知识库适合开放域选题
抽取式预测start和endBertForQuestionAnswering低高,答案来自原文最推荐

这张表可以直接放进毕设文档的“技术选型”一节,改写成你自己的对比分析。选题时另一个常被追问的问题是:为什么用Bert而不是更新的预训练模型。常见回答是Bert-base参数规模约1.1亿,单卡可训、CPU可推,且问答任务上公开实验多、可解释材料充足;越大越新的模型对毕设算力要求不友好,反而不容易收口。

2.2 中文数据集怎么选:公开数据、自建数据还是混合用

抽取式问答的中文数据主要有三类来源。最常见的公开数据集是CMRC 2018,任务定义就是给定context和question,要求给出答案起止位置;DRCD来自繁体中文语料,需要先做转简体和标点统一;SQuAD是英文集,但流传的中文翻译版标点、空格和答案偏移在二次清洗时很容易出错,个人不推荐毕设直接用它。

如果想让系统带点“领域定制”的味道,可以自建几百到上千条问答对。这类自建数据规模不大,但注释格式必须和模型读取格式一致。通常使用SQuAD风格的JSON格式,一条context下面挂若干qas,answers里同时存答案文本和answer_start。这里有个关键认知:answer_start是字符偏移而不是token偏移,中文里一个汉字按一个字符计算。后面做输入编码时,还要把字符偏移换算成token偏移,这地方被很多人说成“玄学”,其实只是一次逐token区间匹配。

{ "context": "广东工业大学校训是团结、勤奋、求是、创新。", "qas": [ { "question": "广东工业大学的校训是什么?", "answers": [ {"text": "团结、勤奋、求是、创新", "answer_start": 13} ] } ] }

这段JSON里,“团结、勤奋、求是、创新”从第13个字符开始。“广东工业大学校训是”正好13个字,这个偏移必须在写完标注脚本后手工验一遍。评估时EM会去掉标点再比,但answer_start仍以原始文本为准,否则F1计算会出现答案截半的假阳性。

2.3 先把文档说明的结构想清楚,再决定代码怎么写

高分毕设通常要求“源码+文档说明”,这里的文档不是配角。很多同学先跑通代码再补文档,最后发现训练参数、数据统计、实验结果三处对不上,只能临时改图。我一般会把文档结构当作写代码时的清单:任务定义、数据处理、模型结构、实验设计、结果分析五个板块,代码每完成一个阶段,就把日志和数据量记录到对应板块。

还有一个必须提前定下来的内容:验收指标。建议文档写清楚要报告EM和F1,不只报测试集整体分数,还要按context长度分段、按问题类型分段统计指标,比如短段落和长段落差多少。这些分段统计会直接影响第3章滑动窗口的参数设计,所以文档框架要先立住,再写代码。

3. 把问答数据喂给BERT:输入编码、滑动窗口与标签对齐

这章是整篇最容易翻车的地方。数据不对,模型训得再久也是白费。动手前先确认Python环境、模型权重目录和数据集的原始JSON格式。

3.1 先固定依赖版本,bert模型参数提前放到本地目录

python安装教程很多,真正难的是把transformers、torch和数据集库的版本关系理清楚。毕设机器环境五花八门,最怕的不是模型不收敛,而是训练到一半版本冲突。按顺序建环境最省心。

conda create -n qa python=3.8 -y conda activate qa pip install torch transformers datasets

安装顺序要点:先把torch装上,再装transformers和datasets。transformers在运行时会依赖torch的设备管理能力,顺序反了容易出现“装好了transformers但导入报错”的问题。如果训练机不能直接访问外网下载模型,就在能联网的机器上把bert-base-chinese目录整个下载下来,连同配置文件、词表和权重一起拷贝到项目model子目录,代码改为from_pretrained("model/bert-base-chinese"),它就不会每次启动都去检查网络。这是bert参数下载的离线下分发做法,很多实验室机器都这么用。

3.2 用AutoTokenizer把question和context拼成一条输入

BERT处理问答的标准输入格式是:[CLS] question [SEP] context [SEP]。transformers的AutoTokenizer可以直接完成这种拼接,不需要手工拼字符串再去tokenize。

from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("model/bert-base-chinese") def encode_qa(question, context, max_length=512): encoding = tokenizer( question, context, max_length=max_length, truncation="only_second", stride=128, padding="max_length", return_offsets_mapping=True, return_tensors="pt", ) return encoding

这里三个参数容易出错。truncation="only_second"表示超长时只截断第二段text_pair,也就是context,question不会被牺牲。stride=128是滑动窗口的重叠步长,让靠后部分的答案有机会跨窗口被召回。return_offsets_mapping会在分词后返回每个token在原始文本里的字符起止位置,这是把答案从字符偏移换算成token偏移的必需信息。注意当打开overflowing_tokens时,offset_mapping是二维的,第一个维度对应窗口序号。

3.3 把答案字符偏移对齐成start/end token位置

对齐这一步是问答系统的核心黑匣子,错一位就全错。步骤是:先拿到答案的字符区间start_char到end_char,然后遍历offset_mapping,找到第一个覆盖答案起始字符的token,记为start_token;再找到第一个覆盖答案结束字符的token,记为end_token。

def find_span(offset_mapping, answer_start, answer_end): start_token = None end_token = None for idx, (char_start, char_end) in enumerate(offset_mapping): if start_token is None and char_start <= answer_start < char_end: start_token = idx if end_token is None and char_start < answer_end <= char_end: end_token = idx if start_token is not None and end_token is not None: return start_token, end_token return None

逻辑说明:字符区间如果落在某个token的内部,就取这个token的索引。这样即使答案和分词边界错位,也能以token粒度去预测。容易忽略的是end_token的判断条件,右侧用了char_start < answer_end <= char_end,而不是<=。原因是一个字符的结束位置恰好等于下一个token的起始位置时,它应该归入前一个token;用错边界会导致预测答案永远多出半个字。

3.4 滑动窗口截断时,答案可能不在窗口里

当context超过max_length,tokenizer会按stride把长文本切成多个窗口。每个窗口都要判断一次答案是否完整落在里面,完整才保留,不完整直接跳过,绝不要把半个答案硬塞进标签。

def build_qa_samples(example, max_length=512, doc_stride=128): samples = [] encodings = tokenizer( example["question"], example["context"], max_length=max_length, truncation="only_second", stride=doc_stride, return_overflowing_tokens=True, return_offsets_mapping=True, padding="max_length", ) answer_start = example["answer_start"] answer_end = answer_start + len(example["answer"]) for i, offset_mapping in enumerate(encodings["offset_mapping"]): span = find_span(offset_mapping, answer_start, answer_end) if span is None: continue start_token, end_token = span samples.append({ "input_ids": encodings["input_ids"][i], "attention_mask": encodings["attention_mask"][i], "token_type_ids": encodings["token_type_ids"][i], "start_positions": start_token, "end_positions": end_token, }) return samples

doc_stride参数如果设得过大,答案跨窗口丢失的概率变高;设得过小,样本数膨胀,训练变慢。常见做法是设为max_length的四分之一,512配128。还有一种边界:答案本身长度超过单个窗口,比如把一段摘要当作答案。此时要么把答案截断到窗口内再标注,要么换更长的序列配置,而不是硬塞进滑动窗口。

3.5 把样本封装成Dataset,让Trainer直接读取

build_qa_samples返回一个list,每个元素都是等长的训练样本,包含input_ids、attention_mask、token_type_ids和start/end标签。最后再包一层torch.utils.data.Dataset,让transformers的Trainer能直接消费。

import torch class QADataset(torch.utils.data.Dataset): def __init__(self, samples): self.samples = samples def __len__(self): return len(self.samples) def __getitem__(self, idx): sample = self.samples[idx] return { "input_ids": torch.tensor(sample["input_ids"], dtype=torch.long), "attention_mask": torch.tensor(sample["attention_mask"], dtype=torch.long), "token_type_ids": torch.tensor(sample["token_type_ids"], dtype=torch.long), "start_positions": torch.tensor(sample["start_positions"], dtype=torch.long), "end_positions": torch.tensor(sample["end_positions"], dtype=torch.long), }

封装完成后,把train、dev、test三个集合分别保存成独立的训练样本文件。这里常见的坑是token_type_ids:部分新版本tokenizer会把token_type_ids从model输入里移除,因为新的模型可以在没有它的情况下工作,但QA任务还是需要它来区分question和context两个语义区域,所以统一保留,不删。

4. 训练与调参:用Trainer跑通并盯住EM和F1

4.1 用Transformers的Trainer做标准微调

数据质量过关后,训练是相对标准化的部分。transformers的Trainer封装了batch、梯度更新、日志、保存等细节,毕设代码用它的可读性也最好。

from transformers import BertForQuestionAnswering, Trainer, TrainingArguments model = BertForQuestionAnswering.from_pretrained("model/bert-base-chinese") training_args = TrainingArguments( output_dir="checkpoints/qa", num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=16, learning_rate=3e-5, warmup_ratio=0.1, logging_steps=50, save_steps=500, evaluation_strategy="epoch", load_best_model_at_end=True, fp16=True, ) trainer = Trainer( model=model, args=training_args, train_dataset=qa_train_dataset, eval_dataset=qa_dev_dataset, ) trainer.train()

BertForQuestionAnswering是一个包含BERT主干加两个线性输出头的模型,一个预测start位置,一个预测end位置。训练时它接收start_positions和end_positions两个标签,内部计算交叉熵。fp16只在显卡支持混合精度时打开,可以明显降低显存占用;CPU环境不要开。epoch结束时会自动计算loss并保留效果最好的checkpoint,这就是答辩报告里“最优模型按验证集挑选”的来源。

4.2 五个必调参数:先按参考值跑,再根据现象定向调

以下参数是微调BERT做抽取式问答的常规起点。这些数字来自公开实验的经验区间,不是玄学。按表里的值跑一遍,再根据三天的实验现象做调整。

参数参考值作用失效现象与调整
max_seq_length512或384决定输入序列上限答案找不到时加滑动窗口,不要无限加长度
per_device_train_batch_size16或8单卡显存占用显存OOM就先减半,再用gradient_accumulation_steps补
learning_rate3e-5(2e-5到5e-5)微调学习率loss震荡就降,loss不降就看数据对齐
warmup_ratio0.1前10%步数线性升学习率训练初期loss爆炸就调大它
num_train_epochs3(2到4)全量数据遍历次数验证EM不再上升就早停

调参原则是一次只动一个变量,每次实验的EM、F1、训练时长记在表里。高频翻车现象是loss下降但验证EM不涨,这多半不是参数问题,而是第3章的标签对齐错了。

4.3 评估指标EM和F1的代码与解读

抽取式问答的常用指标是EM和F1。EM是精确匹配,答案经过标准化后完全一致才算对;F1是所有测试样本预测token与真实token之间的精确率和召回率的调和平均。两条曲线要一起看,EM太苛刻,F1太宽松,合在一起才有说服力。

import re import string import collections def normalize_answer(s): def remove_articles(text): return re.sub(r"\b(a|an|the)\b", " ", text) def white_space_fix(text): return " ".join(text.split()) def remove_punc(text): exclude = set(string.punctuation) return "".join(ch for ch in text if ch not in exclude) return white_space_fix(remove_articles(remove_punc(s.lower()))) def exact_match(pred, gold): return int(normalize_answer(pred) == normalize_answer(gold)) def token_f1(pred, gold): pred_tokens = normalize_answer(pred).split() gold_tokens = normalize_answer(gold).split() common = collections.Counter(pred_tokens) & collections.Counter(gold_tokens) num_same = sum(common.values()) if num_same == 0: return 0.0 precision = num_same / len(pred_tokens) recall = num_same / len(gold_tokens) f1 = (2 * precision * recall) / (precision + recall) return f1

normalize_answer先把英文标点、冠词、大小写统一,再用split切词。中文走同样逻辑时,string.punctuation不包含中文标点,需要把“,。!?、;:”这类字符一并加入过滤集合,否则评估口径会不一致。代码跑完后,建议先人工构造一组标准答案和预测答案,确认EM和F1结果符合预期,再上全量测试集计算。

5. 避坑排查:这五类问题答辩前自己先翻一次车

5.1 现象:训练刚开始就OOM,显存直接打满

训练一开始就报CUDA out of memory,是最常见的起步坑。原因不只是batch_size,还有max_seq_length。BERT的中间激活值大小和序列长度近似线性相关,当max_length取512、batch_size取16时,8GB显存基本到顶。

解决方式:先per_device_train_batch_size减半,再检查max_seq_length;仍不够时开启gradient_accumulation_steps,用多个小batch累积梯度模拟大batch。这样牺牲一点训练速度,但能让流程先跑起来。

5.2 现象:预测答案总多一个字或少一个字

推理阶段经常出现答案边界偏差。原因大概率是token和字符的边界对不上。BERT的WordPiece会把一部分中文词组拆成更细的子token,简单用字符下标当作token下标自然会错。

解决方式:以offset_mapping作为唯一依据,从字符区间换算token区间,不要手工猜位置。推理时同样用offset_mapping把模型输出的token区间反解回字符串。这一步在前处理里做好了,推理阶段基本不会出现边界问题。

5.3 现象:loss降到很低但验证集EM几乎为0

这是最隐蔽的翻车现场。loss正常下降、模型没有爆炸,但验证集一个精确匹配都出不来。原因通常是滑动窗口切分后label对错了样本:某个实现没有检查第i个窗口是否真的包含答案,把所有窗口的start和end全部标成同一个值,模型学到的是错位映射。

解决方式:写一个数据自检脚本,随机抽几十条样本,打印question、context、answer、start_token、end_token,以及用tokenizer.decode(input_ids[start_token:end_token+1])得到的文本,确认解码文本和真实答案一致。这个过程只要做一次,能省掉后面几天无用功。

5.4 现象:标准化代码评估出来的F1忽高忽低

评估脚本昨天跑F1是0.82,今天重跑变成0.79。原因多数是normalize_answer没有统一处理中文标点。string.punctuation只覆盖英文标点,中文的逗号、句号、顿号不会被清除,导致对比时基准不一致。

解决方式:在remove_punc中显式加入中英文标点集合。更稳妥的做法是把评估脚本里涉及标准化逻辑的部分单独抽出来,配若干条人工用例跑一遍再上全集。评估口径一致性在毕设文档的“实验设置”一节里要写清楚,否则评阅老师自己重跑一遍对不上数,整个实验可信度都会受质疑。

5.5 现象:答辩演示时点一次要等几秒,甚至答案落到[SEP]上

演示阶段如果每次都重新加载模型、每次都用大batch,延迟会很难看。另一个经常出现的问题是推理代码没有对logits做mask,start和end的预测最高分落在[SEP]或padding token上,答案直接越界。

解决方式:模型只在启动时加载一次,之后放在显存里复用;对start_logits和end_logits做一个mask,只保留question和context覆盖到的token位置,padding位置全部置为负无穷;预测区间反解后还要用答案长度约束做一次修正。演示脚本里固定十几个典型问题,答辩前整轮跑一遍,确认输出稳定再上场。

这五条踩坑经验对应五条不同的排错路径,根子都落在数据编码、模型推理和评估口径三块。答辩前花半小时把这几类问题自查一遍,比临时改参数靠谱得多。

6. 把“文档说明”写成证据链:结构、表格与断网自查

6.1 文档按“三分钟能看懂”的结构组织

高分毕设的文档说明,核心是让评审快速找到三类东西:任务为什么这样定义、数据怎么来的、结论由哪些数字支撑。建议按“摘要、背景与任务、数据说明、模型方案、实验设计、结果分析、总结”的顺序写。摘要不写空话,直接给结论:任务是什么、用了什么模型、最优EM和F1是多少。

数据说明部分放一张表:公开数据集和自建数据集各多少条,答平均长度、context平均长度、滑动窗口参数是多少。实验分析部分要有对比,至少放两组:bert-base-chinese微调结果对比随机初始化结果,不同训练数据量对EM的影响。这两组对比是答辩时最常被追问的,提前做掉能挡掉一半问题。

6.2 源码里的README和演示脚本

源码说明不是把训练的代码复制进文档,而是把运行步骤、数据格式、复现指标三段写清楚。运行步骤要能从零开始:下载数据、跑预处理脚本、跑train.py、跑eval.py、跑demo.py。数据格式要给出一个和上面JSON样例一致的新样例。复现指标要写明在什么版本依赖、什么随机种子下得到的EM和F1。

演示阶段还有一个容易忽略的细节:线上答辩网络波动、推理变慢都可能发生。最好把模型、数据、演示脚本全部放到本地离线跑通。我每次答辩前都会做一件事:把训练日志最后一轮的EM、F1、总样本数、模型参数表一并截屏,和演示样例放到同一个目录。老师问指标直接截屏调出来,而不是现场翻终端。这个习惯帮我挡过不少次现场追问,也推荐你建立起来。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询