Skip to content

约 10 分钟阅读

20. 向量与重排模型:让检索找对制度条款 ​

上一篇用对比学习的思路收尾,这一篇回到资料助手的主线。助手的回答依赖检索:先从制度文档里找出相关条款,再交给大模型组织回答(整条链路见第一个 AI 应用和 RAG 与工具调用)。小林问“年假能拆成半天休吗?”,检索返回的是讲年假申请流程的条款,真正规定“年假最小休假单位为半天”的条款排在第 17 位,没有进入上下文,大模型只好按申请流程泛泛作答。提示词怎么改都救不回来,因为正确的条款根本不在模型眼前。这一篇讲检索里的两种模型:负责快速召回的向量模型和负责精细排序的重排模型,它们怎样用对比学习训练,怎样从资料助手的日志和文档里构造训练数据,怎样评估,以及换掉向量模型后为什么所有向量都要重算。读完应能判断检索问题出在召回还是排序,并为一个领域检索任务设计训练和评估。

先定位:是召回错了,还是排序错了 ​

动手训练之前先量一下。团队从日志里抽了 300 个真实问题,人工标出每个问题的正确条款(教学示例,下同)。用现有的检索链路跑一遍,统计正确条款出现在前几名:

位置命中比例
前 5 名(会送进大模型)62%
前 50 名91%

这组数字说明,大多数情况下正确条款已经被捞上来了,只是排得不够靠前。这时首先要改进的是排序,而不是召回。反过来,如果前 50 名也只有六成命中,问题就在召回阶段的向量模型、切块或索引上。还有一类问题不归模型管:条款被切块切断了,“最小单位为半天”和“年假”分在两块里,哪个模型都很难找对,这要先回去修切块。条款编号、“OKR”这类精确词,关键词检索往往比向量检索更准,混合检索就能解决,同样不必训练。

双塔和交叉编码器:快与准的分工 ​

检索里的两种模型对应两种结构。

双塔编码器(bi-encoder)也叫向量模型或嵌入模型:问题和条款各自独立地编码成一个向量,相关程度用两个向量的余弦相似度衡量。因为条款的编码和问题无关,所有条款可以提前编码好存进向量索引,在线时只需编码问题一次,再在索引里找最相近的向量,几毫秒就能从上万条里取出候选。代价是问题和条款在编码时看不到对方:条款向量要提前把自己的所有含义压进一个固定长度的向量,不知道将来会被怎样提问。

交叉编码器(cross-encoder)也叫重排模型:把问题和一条条款拼成一段输入,送进同一个 Transformer,每一层注意力里两段文字都能互相看,最后输出一个相关度分数。它能注意到“拆成半天”和“最小休假单位为半天”的对应关系,比双塔准得多;但每一对都要现算,对 1.2 万条条款逐一打分,相当于每个问题跑 1.2 万次前向,在线上不可行。

所以实际系统分两段:

两阶段检索:离线时双塔编码器把 1.2 万条条款编码进向量索引;在线时问题只编码一次,向量召回 top-50,再由交叉编码器对 50 对逐一打分,取前 5 条给大模型

向量召回负责“别漏掉”,在全部条款里快速取出 50 条候选;交叉编码器负责“排准”,只在这 50 条里精细打分。前面量出的 62% 和 91% 正好对应这两段:91% 是召回阶段的上限,从 62% 往上提主要靠重排。两种模型可以分别训练、分别替换。

对比学习:让问题靠近正确条款 ​

向量模型的训练方式和上上篇 CLIP 的结构相同,只是两边都是文字。训练数据是一组(问题,正确条款)对。一个批次里有 B 对,B 个问题和 B 条条款两两算相似度,得到 B×B 的矩阵;每个问题的正确条款在对角线上,同一批次里其他问题的正确条款自动充当它的负例,这叫批内负例(in-batch negatives)。在此基础上,每个问题还可以附带一条专门挑出来的难负例:

一个批次 4 个问题,对角线是各自的正确条款,同批其他条款充当批内负例,每个问题附带一条难负例;问题 1 和问题 3 都讲年假,问题 3 的正确条款对问题 1 可能是假负例

每一行做一次 softmax 交叉熵,这种损失叫 InfoNCE:

text
第 i 行的损失 = −log( exp(s_i⁺ / τ) ÷ Σ_j exp(s_ij / τ) )

s_ij:问题 i 与第 j 条候选的余弦相似度,取值 −1 到 1
s_i⁺:问题 i 与它的正确条款的相似度
τ:  温度,把相似度的差距放大

批次大小直接决定每个问题要和多少负例比较。B = 4 时,每行只有 3 个批内负例,而且多半和问题毫不相关,模型很容易全部答对,学不到什么;B = 256 时有 255 个,其中更可能出现主题相近、真正需要区分的条款。所以向量模型训练通常希望批次越大越好,显存不够时可以用梯度缓存这类技巧(sentence-transformers 里的 CachedMultipleNegativesRankingLoss)在不增加显存的前提下扩大批次。

温度 τ 的作用可以代入数字看。假设正确条款相似度 0.80,一个难负例 0.75:

text
τ = 0.05:两者的指数比 = exp((0.80 − 0.75) / 0.05) = e¹ ≈ 2.7
τ = 0.01:两者的指数比 = exp(0.05 / 0.01) = e⁵ ≈ 148

温度越小,0.05 的相似度差距就被放大得越厉害,损失越集中在那几个最难区分的负例上。BGE 的官方说明里对比学习温度用 0.01,sentence-transformers 的 MultipleNegativesRankingLoss 默认 scale=20,相当于温度 0.05。这也解释了 BGE 模型的相似度分数为什么大多挤在 0.6 到 1 之间:温度小,模型不需要把分数拉得很开就能区分正负。用这类模型时,不能把 0.7 理解为“比较相关”,阈值要在自己的数据上定。

难负例与假负例 ​

批内负例大多太容易。真正让检索出错的是“年假申请流程”这种和问题讲同一件事、却不能回答问题的条款,这就是难负例。挖掘方法:用当前的向量模型对每个训练问题检索,从排名靠前、但不是正确答案的条款里选一条作为难负例。

难负例挖得太狠会引出另一个问题:假负例。资料助手的制度里,“年假最小单位为半天”写在第 4.3 条,附则第 7.2 条又重复了一遍。标注时只标了 4.3,挖难负例时 7.2 排在最前面,就被当成负例,训练反而教模型把一条正确答案推远。前面那张图里,“年假怎么申请”和“年假能拆半天吗”两个问题的正确条款也可能互相能答,在同一批次里同样会变成假负例。

几种缓解办法:不要从排名第 1、2 位里取难负例,而是从第 10 到 50 名之间取;用一个更强的交叉编码器给候选打分,分数高于某个阈值的不作为负例;sentence-transformers 的 GISTEmbedLoss 用一个引导模型自动过滤批内可疑的负例;用 NO_DUPLICATES 批采样避免同一条条款在一个批次里重复出现。最可靠的仍是抽查:随机拿一批挖出的难负例,人工看看是不是其实也对。

从日志和文档构造训练对 ​

资料助手有三个训练数据来源。

问答日志。员工点开了回答里引用的条款、点了“有用”,或者追问后得到满意答案,这些记录里的(问题,条款)可以作为正例。日志里的问题最接近真实分布,但有偏:只能覆盖当前检索已经能找到的条款,原本就找不到的条款不会被点开。日志还包含员工的个人信息和具体事由,进入训练前要脱敏。

人工标注。让熟悉制度的同事为每条重要条款写几种问法,或者为日志里检索失败的问题标出正确条款。数量少,但能补上日志覆盖不到的地方,也是评测集最可靠的来源。

大模型合成。对每一条条款,让大模型写出几个员工可能会问的问题。这样能快速为 1.2 万条条款各造出几条问题,但合成问题有两个常见毛病:太像条款原文(直接抄条款里的词,检索起来过于容易),以及问的东西这条条款其实回答不了。过滤步骤因此必不可少。一个常用的做法是往返一致性过滤:用一个初始检索器拿合成的问题去检索,只有它的来源条款排在第 1 名时才保留(Promptagator 论文的做法)。再补充几条规则:去掉和条款字面重合度过高的问题;要求提示词里给出口语化的示例;人工抽查一小部分。

评测集必须和训练数据分开,而且要按条款分开,不能只按问题分开。如果同一条条款既有问题进了训练集、又有问题进了评测集,模型可能只是记住了这条条款,评测分数会偏高。更稳妥的做法是留出几个完整的制度文件,它们的条款和问题都不参与训练。

微调一个开源向量模型 ​

起点通常是一个已经在大规模数据上训练过的开源模型,在自己的数据上继续微调。几个常见的选择(核对于 2026 年 10 月,以模型卡为准):

模型类型许可证向量维度说明
BAAI/bge-large-zh-v1.5向量MIT1024中文;small/base 版为 512/768 维;短查询建议加检索指令前缀
BAAI/bge-m3向量MIT1024多语言,最长 8192 token,同时支持稠密、稀疏和多向量检索
Qwen/Qwen3-Embedding-0.6B向量Apache-2.032—1024 可选支持指令,32K 上下文;另有 4B、8B
BAAI/bge-reranker-v2-m3重排Apache-2.0输出分数基于 bge-m3,可用 sentence-transformers 的 CrossEncoder 加载
Qwen/Qwen3-Reranker-0.6B重排Apache-2.0输出分数有 4B、8B 版本

这些模型都可以用 sentence-transformers 加载和微调。下面是用它的训练接口微调向量模型的骨架(示意,按 sentence-transformers 3.x 以上的接口写,未实际运行;真实运行需要一张 GPU,数据换成自己的三元组):

python
from datasets import Dataset
from sentence_transformers import (SentenceTransformer, SentenceTransformerTrainer,
                                   SentenceTransformerTrainingArguments, losses)
from sentence_transformers.training_args import BatchSamplers

model = SentenceTransformer("BAAI/bge-base-zh-v1.5")
prefix = "为这个句子生成表示以用于检索相关文章:"   # BGE 中文模型的查询指令,训练和检索时保持一致
train = Dataset.from_dict({
    "anchor":   [prefix + "年假能拆成半天休吗?", ...],
    "positive": ["4.3 年休假的最小休假单位为半天……", ...],
    "negative": ["4.2 申请年休假应提前三个工作日……", ...],   # 挖出的难负例
})
loss = losses.MultipleNegativesRankingLoss(model)   # 批内负例 + 难负例,默认 scale=20
args = SentenceTransformerTrainingArguments(
    output_dir="out/bge-policy", num_train_epochs=1,
    per_device_train_batch_size=64, learning_rate=2e-5, warmup_ratio=0.1,
    batch_sampler=BatchSamplers.NO_DUPLICATES, bf16=True)
SentenceTransformerTrainer(model=model, args=args, train_dataset=train, loss=loss).train()

bge-base-zh-v1.5 约 1 亿参数,按第 03 篇的每参数 16 字节算,全参训练状态约 1.6 GB,比大语言模型小两个数量级,向量模型通常直接全参微调。显存主要花在批次上:批次越大,同时编码的文本越多。重排模型的微调思路相同,数据换成(问题,条款,相关/不相关)的标签,用交叉编码器的训练接口和二分类或列表式损失。

评估:Recall@k、MRR、nDCG ​

检索评估看的是排名。用三个问题举例,正确条款分别排在第 1、第 3,以及前 10 名之外:

text
Recall@5:正确条款进入前 5 的问题比例 = 2 / 3 ≈ 0.667
MRR:     每个问题取 1 / 正确条款排名,没找到记 0,再平均
          = (1/1 + 1/3 + 0) / 3 ≈ 0.444

Recall@k 只关心“有没有进前 k”,对应“正确条款能不能送进大模型”;MRR(平均倒数排名)更看重排在第 1 还是第 3。

一个问题可能有多条相关条款,而且有主次之分,这时用 nDCG(归一化折损累计增益):给每条条款一个相关等级,排名越靠后,增益按对数打折,再除以理想排序时的值。例如“年假能拆半天吗”,第 4.3 条相关等级 2,附则 7.2 条相关等级 1,模型把它们分别排在第 2、第 4 名:

text
DCG  = 2 / log₂(2 + 1) + 1 / log₂(4 + 1) = 1.262 + 0.431 = 1.693
理想排序(第 1、第 2 名):IDCG = 2 / log₂(1 + 1) + 1 / log₂(2 + 1) = 2.000 + 0.631 = 2.631
nDCG = 1.693 / 2.631 ≈ 0.643

评估分两部分。一是在留出的制度文件上算这些指标,并同时报告整条链路的端到端结果(最终回答是否正确),因为检索指标提高未必转化成回答变好。二是回归测试:准备一组通用问题(不涉及公司制度的常识检索),对比微调前后的表现。在窄领域数据上训练很多轮后,向量模型可能“只认识制度条款”,换成其他文档库就明显变差;如果资料助手的索引里还有技术文档、会议纪要,这个回归必须做。

换了向量模型,所有向量都要重算 ​

向量模型微调完,不能只对新文档用新模型编码。旧模型和新模型的向量空间不同,新问题的向量和旧条款的向量算出来的相似度没有意义;即使维度相同,也不能混用。所以每换一次向量模型(包括微调后的新版本),索引里所有文档都要用新模型重新编码。

资料助手只有 1.2 万条条款,重编码几分钟就完成。如果知识库有 1000 万个切块,按单卡每秒编码 1000 条估算(示意,实际取决于模型大小、文本长度和硬件),要近 3 小时,再加上索引重建。上线时通常并行维护新旧两套索引:新索引建好、评估通过后再切换查询流量,出问题能立即切回。每个向量都应记录生成它的模型名称和版本,避免混用。如果新模型的向量维度变了,向量库的表结构也要跟着改。

这也是先训练重排模型的又一个理由:重排模型只在查询时对候选打分,不在索引里存任何东西,更换它不需要重算向量,回滚也只是换回一个模型文件。对资料助手,前面量出的“前 50 名命中 91%、前 5 名只有 62%”,正适合先微调重排模型;召回仍然不够时,再考虑微调向量模型并承担重建索引的成本。

检索找错条款时,先量出问题出在召回还是排序,排除切块和关键词这类不需要训练的原因;双塔编码器快、可预先索引,交叉编码器准、只能用在少量候选上,两者分段配合;训练都用对比学习,批次越大负例越多,难负例让模型学会区分相近条款,假负例则要靠过滤和抽查防止教错;训练对来自日志、标注和经过往返一致性过滤的合成问题,评测按条款留出,并做通用回归。证明这一切真的“更好且没变坏”需要一套系统的评估方法,这正是下一篇的内容:21. 评估。

别急,先让缓存热一下。