在这里插入图片描述


从海量文本中精准揪出答案片段:如何用BERT训练一个堪比真人“划重点”的智能问答引擎

写在前面:搜索引擎的进化下一站

你有没有过这样的体验:在搜索引擎里输入一个问题,Google返回了一整页链接,你不得不一个一个点开看,在密密麻麻的文字里自己找答案——有时候还得读半篇文章才发现要找的信息根本不在这页里。这其实是传统搜索引擎的“通病”:基于关键词匹配返回相关文档,而不是直接回答用户的提问。

问答系统(Question Answering,QA) 正是为了解决这个痛点而诞生的。它不再是“给你一堆可能包含答案的链接”,而是直接给出准确的答案,同时附上来源依据,让用户既能获取答案,也能验证可信度。这种精准、可溯源的特性,正是搜索引擎演进的重要方向。

抽取式问答(Extractive QA)与其他主流范式的核心差异在于:

  • 搜索引擎:根据关键词匹配返回相关文档列表,用户需要自行阅读文档寻找答案。这是一种文档层的近似匹配,而非精准定位。
  • 生成式问答:通过LLM基于问题和上下文自由生成答案(以新文本呈现)。灵活性强,可概括、可重述,但可能产生幻觉。
  • 抽取式问答:直接从给定的上下文中“高亮”出一段连续的片段作为答案,不增加任何多余词汇,严格保证答案的可溯源性和可信度。

抽取式问答的不可替代优势在于:当答案必须严格忠于原文、且需要可溯源验证时,它是最可靠的选择。

在敏感场景中,我们目前还无法完全信任大语言模型直接生成的答案是否出现“似是而非”的错误,但抽取式问答基于来源文档提供端到端的可靠证据,常用于:

  • 医疗文档问答:直接引用病历或医学文献片段作为答案
  • 法律文书查阅:高亮判决书中的原文作为回答依据
  • 企业知识库检索:从产品文档中精准定位用户问题的答案
  • 学术论文阅读:快速定位论文中与特定问题相关的原句

本节课带你走通从理论理解、模型加载到生产优化的完整流程:

  • 抽取式问答的核心逻辑与精确匹配(EM)、F1两类评估指标
  • AutoModelForQuestionAnswering API 的输入构建与输出解析
  • pipeline一行代码实现完整问答推理
  • 针对超长上下文的分块滑动窗口策略
  • 模型服务化与量化加速的部署准备

一、抽取式问答系统核心逻辑——让模型学会“划重点”

1.1 抽取式问答 vs 生成式问答

刚才简单区分了抽取式和生成式,现在我们用系统化的视角进一步对比两类范式的根本差异:

对比维度 抽取式问答 生成式问答
本质行为 从给定上下文中“复制粘贴”一段连续的文本 基于上下文“用自己的话”重新组织答案
输入输出 (问题,段落)→ 答案片段(span) (问题,段落)→ 自由文本
模型结构 仅编码器 + 分类头(定位起止位置) 编码器-解码器 或 仅解码器(逐token生成)
答案粒度 必须是段落中的原句或短语 可以概括、重述、跨句整合
事实准确性 极高(每个词都有出处) 可能产生幻觉(hallucination)
应用场景 文档检索、事实核查、医疗/法律等严谨场景 开放域对话、创意回答、多源信息融合

抽取式问答的核心思想可以简化为:给定上下文(Context)段落查询(Question),让问答模型从篇章中找出词语的起始索引和结束索引,作为最终答案。

📌 形象比喻:生成式问答好比“读过原文后自己总结复述”,而抽取式问答恰好是“在原书上用荧光笔直接划线”。前者的优点在于语言灵活、组织自然,但可能凭空捏造;后者的缺点是不能超出原文的表述范围,但绝对忠实于来源。

1.2 抽取式问答任务的数学建模

抽取式问答任务定义为一个序列标注/跨度选择问题

任务输入
  • 一段上下文(Context/C) :由 N 个token组成的单词序列 C = [c₁, c₂, …, c_N]
  • 一个问题(Question/Q) :由 M 个token组成的单词序列 Q = [q₁, q₂, …, q_M]
任务输出
  • 答案在上下文中的起始索引 start_position结束索引 end_position(整数),满足 1 ≤ start ≤ end ≤ N,输出答案即为 C[start:end+1]
  • 若问题在上下文中无答案(例如SQuAD 2.0中的不可答问题),模型应输出一个特殊的“无答案”标记
分类建模的思路

大多数早期抽取式问答模型都遵循BIDAF(双向注意力流)架构的范式。而到了BERT时代,整个推理逻辑被统一为:

  • 编码器编码上下文与问题的组合序列,输出每个位置的向量表示。
  • 在上下文的每个位置上,预测该token作为答案起点的概率 P_start,以及作为答案终点的概率 P_end
  • 训练时:优化两个独立的交叉熵损失,分别用于学习起点分类和终点分类。
  • 推理时以有效约束(start ≤ end)遍历选择 start * end 得分之和最高的位置对。

因此,抽取式问答本质上是一个跨度选择分类问题。

1.3 SQuAD数据集与评估指标

SQuAD 1.1

SQuAD(Stanford Question Answering Dataset)是抽取式问答领域最经典的数据集,可以说是这一任务的ImageNet时刻。包含来自536篇维基百科文章的超过10万个问题-上下文-答案三元组。特点在于:

  • 答案必须是上下文中的一段连续文本
  • 每个问题由3位标注者独立出题,并标注标准答案,因此每个问题可能有多个被认可的答案
SQuAD 2.0

延续1.0核心格式,额外增加了超过5万个由众包人员编写且在上下文段落中确实没有答案的问题。SQuAD 2.0要求模型不仅要能在有答案时精准定位,还要能自主学习判断“何时不回答”。

评估指标

SQuAD采用两个核心指标评估抽取式问答模型:

  • Exact Match(EM) :模型预测的答案与标准答案在字符串层面完全匹配的比例。这是最严格的指标——哪怕只差一个字符、一个标点,EM也为零。
  • F1分数(F1 Score) :衡量模型预测与标准答案之间的词元重叠度(Precision与Recall的调和平均值)。

F1相比EM更平滑,可以部分匹配的回答(例如“machine learning” vs “machine learning algorithms”也有一定得分),通常被视作更可靠的指标。官方评测脚本对F1赋予较高的权重。

1.4 长文本问答的局限——BERT 512 token上限

SQuAD最长的上下文大约在768个词元左右。主流的BERT模型最大序列长度仅为512个token——这就天然地产生了截断风险。

当给定上下文超过512 token时,你是选择随机截断(从第1个token保留到512),还是用其他方法保留关键信息?后者显然更明智。截断会导致答案段落被硬性缺失,在抽取式问答准确性上有严重损失。

二、问答模型加载与实操——搭建第一个抽取式问答引擎

2.1 AutoModelForQuestionAnswering API解析

在Hugging Face Transformers库中,AutoModelForQuestionAnswering 是专门为抽取式问答设计的模型加载器。它会自动在预训练模型权重的基础之上添加两个可训练的线性层

  • span start classifier:计算上下文中每个token作为答案起点的logits分数
  • span end classifier:计算上下文中每个token作为答案终点的logits分数

这两者的输出维度与input_ids的上下文部分序列长度一致,从而让多层转换器的特征映射自然地端到端地适配答案跨度预测的目标。

SQuAD上微调的预训练模型:通用模型如 bert-large-uncased-whole-word-masking-finetuned-squad 是在SQuAD 2.0上微调过的,在抽取式QA任务上直接取得了很高的基准分数。

此外,还有基于ELECTRA-large、在SQuAD 2.0上微调的 ahotrod/electra_large_discriminator_squad2_512,其已在8个不同跨领域数据集上表现出卓越的泛化能力,综合准确率居于领先水平(约为43%)。这从侧面说明:选择一个靠谱的SQuAD预训练权重,就能得到一个很强的零样本问答模型。

2.2 Tokenizer输入构建(上下文+问题合并且正确遮盖)

问答系统的Tokenizer处理很特殊:它需要将 问题和上下文拼接成一个单一的token序列,同时确保注意力掩码和token类型片段正确区分。

在BERT风格模型中,输入格式为:

[CLS] [Question Tokens] [SEP] [Context Tokens] [SEP]
  • token_type_ids:标识哪些token属于问题(全0),哪些属于上下文(全1)
  • 答案跨度预测只发生在上下文部分(token_type_ids == 1),不会在问题区域产生误判。

下面展开手动处理演示:

from transformers import AutoTokenizer, AutoModelForQuestionAnswering
import torch

# 加载特定模型与分词器(首次使用会自动下载缓存)
model_name = "bert-large-uncased-whole-word-masking-finetuned-squad"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForQuestionAnswering.from_pretrained(model_name)

context = """
The Eiffel Tower is a wrought-iron lattice tower on the Champ de Mars in Paris, France.
It is named after the engineer Gustave Eiffel, whose company designed and built the tower.
Constructed from 1887 to 1889 as the centerpiece of the 1889 World's Fair, it was initially criticized by some of France's leading artists and intellectuals for its design,
but it has become a global cultural icon of France and one of the most recognizable structures in the world.
"""
question = "Who is the Eiffel Tower named after?"

这段处理展示了BERT QA模型输入的标准构建方式:

# 关键步骤:tokenizer接受问题和上下文参数,自动构造[CLS]问题[SEP]上下文[SEP]格式
inputs = tokenizer(
    question,               # 第一个参数:问题
    context,                # 第二个参数:上下文
    max_length=384,         # 截断长度(BERT上限512,实测384足够覆盖大多数SQuAD样本)
    truncation="only_second",   # 如果超长,仅截断上下文,问题部分必须完整保留
    padding="max_length",        # 填充到max_length长度,保持批次一致性
    return_tensors="pt"          # 返回PyTorch张量格式
)

print(f"Tokenized input shape: input_ids={inputs['input_ids'].shape}")
print(f"解码查看序列开头: {tokenizer.decode(inputs['input_ids'][0][:50])}")

2.3 模型输出解读:start_logits, end_logits

前向传播后,模型返回一个QuestionAnsweringModelOutput对象,包含以下字段:

  • start_logits:形状(batch_size, sequence_length),每个token作为答案起点的分数
  • end_logits:形状(batch_size, sequence_length),每个token作为答案终点的分数
  • loss(训练时使用):两个交叉熵损失之和
with torch.no_grad():
    outputs = model(**inputs)

start_logits = outputs.start_logits   # (1, 384)
end_logits = outputs.end_logits       # (1, 384)

# 找到得分最高的起始索引和结束索引
start_idx = torch.argmax(start_logits[0]).item()
end_idx = torch.argmax(end_logits[0]).item()

print(f"预测的答案跨度: start={start_idx}, end={end_idx}")

重要约束:模型可能会预测end_idx < start_idx(例如结束位置提前于起始位置),我们在后处理时必须显式检查这一条件,若出现无效组合则回退到备选推断(例如取次优序列或输出“未找到答案”)。通常回答的跨度范围不应为负。

2.4 从token索引还原答案文本

有了起始索引和结束索引后,我们需要从input_ids中提取对应位置的token ID序列,并用分词器还原为原始文本字符串:

answer_tokens = inputs['input_ids'][0][start_idx:end_idx+1]
answer = tokenizer.decode(answer_tokens, skip_special_tokens=True)

print(f"问题: {question}")
print(f"答案: {answer}")

查看输出,模型精确地定位到了“Gustave Eiffel”这一段内容。

处理无答案情况(SQuAD 2.0风格):

  • 如果模型start_logitsend_logits[CLS]令牌位置以及索引同位置得分过高,可选作象征无答案的输出。
  • 简化的策略:若答案文本为空白或不完整,或两个高分区间的得分低于阈值(如max(logits) < 1.0),则认为此问无有效答案。

2.5 使用pipeline进一步简化推理

上述手动步骤展示了内部数据流。但如果你的首要目标是让模型“工作起来”,Hugging Face的**pipeline**是最好的选择——自动完成tokenization、模型推理、答案后处理的全链条,对QA专用的modeltokenizer一行代码调用即可。

from transformers import pipeline

question_answering = pipeline(
    "question-answering",
    model="bert-large-uncased-whole-word-masking-finetuned-squad"
)

result = question_answering(question=question, context=context)
print(f"模型答案: {result['answer']}")
print(f"置信度: {result['score']}")     # 归一化起点与终点分数综合的后验概率
print(f"起始位: {result['start']}, 结束位: {result['end']}")

pipeline返回一个字典,包含了抽取出的答案文本answer、模型计算的置信度score、以及在原上下文字符串中的偏移量startend。这对于建立前后端支持的可视化应用非常有帮助。

对于score如何得来的细节,典型实现根据预测起止得分的Softmax联合分布权重,求得起止索引配对对数似然的规范化结果。我们可以把它理解为一个置信度度量。

加分项展示:如果你需要手动微调,可以这样做:

from transformers import Trainer, TrainingArguments
from datasets import load_dataset

dataset = load_dataset("squad_v2")  # SQuAD 2.0格式

# 预处理函数:构建tokenizer支持QA格式。详情参考官方示例
# trainer = Trainer(model=model, args=training_args, train_dataset=train_dataset)
# trainer.train()

2.6 短小代码实现自托管Web问答API(可选展示)

以下使用Flask内嵌pipeline,快速创建一个可以联网调用的QA服务:

import flask
from transformers import pipeline

app = flask.Flask(__name__)
qa_pipe = pipeline("question-answering", model="distilbert-base-cased-distilled-squad")

@app.route("/ask", methods=["POST"])
def ask():
    req = flask.request.get_json()
    context = req["context"]
    question = req["question"]
    res = qa_pipe(question=question, context=context)
    return flask.jsonify({"answer": res["answer"], "score": res["score"]})

if __name__ == "__main__":
    app.run(port=8000)

三、问答系统优化与部署准备

3.1 针对超长上下文的滑动窗口策略

许多真实应用中的文档长度可能远超512个token(例如学术论文原文、几十页的法律文书)。此时仅传递前512个token会丢失大量信息。针对这种场景,主流方案是滑动窗口(Sliding Window) + 排名汇总

滑动窗口策略的几个关键步骤:

  • stride(窗口步长)和 max_length 将超长上下文切分成多个区块,确保前后区块之间有一定比例的重叠(例如30%重叠率,关键上下文能跨越边界)。
  • 每个区块独立提出问题给抽取式QA模型,得到候选答案和置信度。
  • 跨窗口汇总候选答案:如果多个窗口给出的答案一致或重叠则保留置信度评分最高的那一个输出;如有冲突,则利用投票或启发式逻辑选择最终答案。

当答案可能跨多个区块时,扩展步长的窗口重叠有助于确保提取的语义片段的完整性。

Hugging Face已经内置支持滑动窗口:只需在tokenizer调用中设置return_overflowing_tokens=Truestride参数,就会自动生成带重叠的分块样本。

3.2 模型蒸馏与量化加速

推理阶段为了满足生产级别的延迟,需要进行轻量化改造。

  • DistilBERT蒸馏版:在SQuAD上可以达到BERT-base的95%以上性能,但模型体积减少约40%,推理速度提升2倍以上。
  • ALBERT(参数共享) :内存占用大幅节省,适合同设备多并发模型加载。
  • INT8 / FP16 量化:使用PyTorch原生torch.ao.quantizationbitsandbytes将权重降低一个数量级。量化后的模型可在CPU端得到理想时延。

量化后模型体积缩减到约1/4,推理速度提升2-3倍,几乎不影响抽取式问答的EM值。

3.3 服务就绪:保存ONNX / TensorRT引擎

需要部署到云端或应用程序时,建议将模型导出为ONNX或TensorRT格式:

  • ONNX导出(跨平台高性能推理):
    1. 通过transformers.onnx包导出模型
    2. 在端侧加载ONNX Runtime的C++/Python API,完成推理任务
  • TensorRT-LLM(NVIDIA GPU专属优化):可以将BERT QA编译成高度优化的GPU推理引擎,比原始PyTorch推理延迟降低数倍。

内存与延迟参考值bert-large-uncased-whole-word-masking-finetuned-squad导出ONNX并运行在ONNX Runtime (CPU)下,每个问答实例约需300~600毫秒(视CPU核心与批处理打包而定)。同样模型运行在NVIDIA T4 GPU上时,使用TensorRT推理引擎可将延迟压缩到约20毫秒。

3.4 工程部署最佳实践

  • 批处理推理:若一次需解答几十个独立问题,将它们合并为批次能极好地摊销内存开销并提高吞吐量。
  • 预加载模型至内存:在API服务中初始化全局模型,model.eval() 关闭dropout,通过长连接复用,避免每次请求重新加载权重。将模型预热(走几步随机推理)后再全量处理线上流量,使GPU时间片调度达到平稳。

四、完整代码整合(可直接运行)

import torch
from transformers import pipeline, AutoTokenizer, AutoModelForQuestionAnswering

def manual_qa(question, context, model_name="distilbert-base-cased-distilled-squad"):
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModelForQuestionAnswering.from_pretrained(model_name)
    inputs = tokenizer(question, context, add_special_tokens=True, return_tensors="pt", truncation="only_second", max_length=384)
    with torch.no_grad():
        outputs = model(**inputs)
    start_idx = torch.argmax(outputs.start_logits[0])
    end_idx = torch.argmax(outputs.end_logits[0])
    if start_idx > end_idx or end_idx - start_idx > 30:
        return "无法确定答案(跨度无效)"
    answer_tokens = inputs['input_ids'][0][start_idx:end_idx+1]
    return tokenizer.decode(answer_tokens, skip_special_tokens=True)

def pipeline_qa(question, context):
    qa_pipe = pipeline("question-answering", model="distilbert-base-cased-distilled-squad")
    return qa_pipe(question=question, context=context)

if __name__ == "__main__":
    test_context = """The Transformer architecture was introduced in the paper "Attention Is All You Need" by Vaswani et al. in 2017. 
    It replaced recurrent neural networks with multi-head self-attention mechanisms and achieved state-of-the-art results in machine translation."""
    test_q = "Which paper introduced the Transformer architecture?"
    print("手动推理结果:", manual_qa(test_q, test_context))
    print("Pipeline结果:", pipeline_qa(test_q, test_context))

五、面试高频问题与项目避坑

Q1:回答片段跨多个非连续句子时,模型如何应对?
A1:抽取式QA无法直接处理分离片段组合(技术上非连续答案),通常采用启发式或组合重叠窗口覆盖。

Q2:如何处理大小写、标点对EM分数的影响?
A2:官方评估中会先对答案做一个标准化清洗(normalize_answer),例如转为小写、去除标点符号等,确保回答对齐。

Q3:如果实测EM很低,如何判断问题来源?
A3:常见原因包含①tokenization边界错位(全词Mask失误);②滑动窗口重叠参数不合理或跨窗口结果合并不佳;③超长文本答案被意外截断。

Q4:高并发场景,QA系统的瓶颈在哪?
A4:显存I/O和Transformer自注意力O(n²)花费仍然占最多时间。工程实战中应使用批处理+量化+蒸馏三管齐下。

六、课后延伸

  1. 切换到bert-large-uncased-whole-word-masking-finetuned-squad观察对长答案片段的匹配差异。
  2. 手动裁剪QA给入max_length=512的文档并补缺滑动窗口。
  3. 学习使用torch.onnx.export将模型转成ONNX,用ONNX Runtime调用获得跨设备推理能力。

下节课预告

抽取式问答的核心是“从已有文本中定位答案”,而下一节课我们将走向更开放的一类任务——文本生成。

第24课:实战案例3——文本生成(续写摘要与对话生成)

  • 了解GPT、T5和BART的生成差异
  • 用GPT-2/Llama做故事续写
  • 用T5做文本摘要微调
  • Beam Search、Temperature采样优化和长文本监控

学完至此,从分类、QA到生成,你将打通Transformer各个主要应用方向的全流程实战。

我们第24课见!


🔗 Transformers模型架构系列课程导航

去专栏阅读

模块1:Transformers入门基础(第1-6课)
模块核心目标:帮助零基础读者快速入门,搭建Transformers的基础认知框架,了解其起源、发展背景及核心应用场景,掌握必备的前置知识,为后续核心原理学习奠定基础,降低入门门槛。
模块2:Transformers核心架构与原理(第7-13课)
模块核心目标:深入拆解Transformers的核心架构(编码器、解码器),掌握每个子模块的工作原理、作用及实现逻辑,理解各模块之间的协同工作机制,突破理论难点,为后续模型解析与实战奠定基础。
模块3:Transformers经典模型解析(第14-20节课)
模块核心目标:逐个拆解Transformers领域的经典模型(BERT、GPT、T5等),分析每个模型的核心改进、预训练任务、适用场景与优缺点,让读者掌握不同模型的差异,能根据实际任务选择合适的模型,兼顾理论深度与应用落地。
模块4:Transformers实战与优化(第21-26课)
模块核心目标:聚焦实战落地,从环境搭建、工具使用到具体任务实操,让读者掌握Transformers模型的训练、微调、部署方法,学习实战中的优化技巧,解决实际项目中的常见问题,确保每节课都有具体的实操案例,让读者“会应用、能落地”。
模块5:Transformers行业应用与前沿拓展(第27-30课)
模块核心目标:结合不同行业的实际应用场景,讲解Transformers的落地案例,让读者了解其行业应用价值;同时覆盖当前Transformers的前沿趋势,帮助读者把握技术发展方向,提升专栏的前沿性与实用性。


🌟 感谢您耐心阅读到这里!
💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐