跳至主要内容
重排序:两阶段架构的收益与代价

重排序:两阶段架构的收益与代价

一个很典型的 bad case:融合后的 30 条候选里,真正能回答问题的那段排在第 11 位。召回率是合格的,Recall@30 等于 1,但最终上下文只装得下 5 条,模型根本没看到它。换嵌入模型、调 RRF 参数都动不了这个排序——因为问题不在「能不能召回来」,在「排序模型有没有同时看过查询和文档」。

这是重排序存在的全部理由:第一阶段求全,第二阶段求准。

本系列第 9/15 篇 · 上一篇:混合检索:稀疏、稠密与分数融合 · 下一篇:上下文构建:召回之后才刚开始

为什么双编码器排不准

第 6 篇讲过双编码器把查询和文档分别压成向量,编码阶段零交互。这个设计让千万级候选的检索成为可能,但也意味着:查询里的限定词、否定、时间范围、比较关系,都没机会和文档里的具体表述逐词对齐。向量只能表达「整体像不像」。

重排序模型恢复了交互。最典型的是 cross-encoder,把两段拼成一个序列送进模型:

TEXT1 行
1
[CLS] 查询文本 [SEP] 候选文档文本 [SEP]  →  相关性 logit

自注意力在查询 token 和文档 token 之间全连接,模型能看见「文档里写的是 2024 版,而查询要 2026 版」这种细节。代价非常直接:文档侧无法预计算。每个候选都要和当前查询重新跑一次前向,30 个候选就是 30 次(或 30 条组成一个 batch 的一次)模型推理。所以 cross-encoder 只能放在召回之后、候选数已经被压到几十条的阶段,永远不可能用来扫全库。

这就是两阶段架构的经济学:用便宜的、可预计算的方法把 N 缩到 K,再用贵的、有交互的方法把 K 排成有序。

三类重排器,交互粒度不同

Cross-Encoder:全交互,精度最高,通常也最慢。适合最终候选 20–50 条、能接受几十到几百毫秒额外延迟的场景。分数是 logit,不是概率,不同查询之间不可比,只能用于同一次查询内部的排序。想拿它做「相关性低于阈值就拒答」,得先在同一批候选上校准,别直接拍一个 0.5。

后期交互模型(ColBERT 一类):查询和文档各自编码成 token 级向量组,打分时对每个查询 token 找文档里最相似的 token 取最大值再求和(MaxSim)。

TEXT1 行
1
score(q, d) = Σ_i  max_j  ( q_i · d_j )

文档侧的 token 表示可以离线预计算并存储,在线只算查询侧和一组 MaxSim 运算。它介于双编码器和 cross-encoder 之间:比双编码器多保留了词级对齐信息,比 cross-encoder 省得多。代价是存储膨胀——每个 chunk 存一组 token 向量而不是一个,百万级 chunk 时索引体积可能大一个数量级。这是一个很实在的取舍,选型前先算存储账。

LLM 重排:把候选列表连同编号一起交给大模型,让它输出排序。三种形式:

形式 输入 输出 特点
Pointwise 单个 (q, d) 相关性分数/等级 可并行,但看不到候选间相对关系
Pairwise (q, d1, d2) 哪个更好 精度好,比较次数随候选平方涨
Listwise 一次性给出全部候选 重排后的编号列表 一次调用,但受上下文长度与位置偏差影响

Listwise 最省调用次数,但有两个坑:一是对输入顺序敏感,候选怎么排进去会影响输出(位置偏差),通常要打乱或滑动窗口多次;二是候选一多,编号和文本一起塞进去,上下文成本和注意力稀释都上来了。我一般只对 10–20 条候选用 listwise,并要求模型只输出编号序列,不解释理由,便于解析和校验。

LLM 重排最大的优势是能处理复杂判断:时效性、权威性、是否直接回答了问题而不是只在旁边提到关键词。这些用规则写很别扭。代价是延迟(几百毫秒到数秒)和成本,而且输出不稳定,需要严格校验编号合法性、去重、缺失补齐。

延迟与成本:先把账算清楚

两阶段的总延迟大致是:

TEXT1 行
1
T_total ≈ T_recall(两路并发) + T_rerank(batch) + T_context + T_generate_first_token

重排这一项由三个量决定:候选条数 K、每条的 token 长度 L、模型尺寸与是否 GPU。cross-encoder 通常把 (q, d_i) 拼成 batch 一次前向,所以 K 增大时延迟近似线性(直到显存或 batch 上限),而不是 K 次串行调用。

我的调参流程是固定评测集,扫「候选深度 K × 模型档位」,记录 nDCG@5 和 P95 延迟:

K 小模型 P95 nDCG@5 大模型 P95 nDCG@5
10 低 提升有限 中 中
30 中 明显提升 高 更好
50 中—高 增益趋平 很高 增益趋平

真实数字随模型、语料和硬件差别很大,表里只表示形状:收益曲线通常在 K=20–40 之后趋于平缓,而延迟还在涨。所以不要盲目把重排候选开到 100。更划算的顺序往往是:先保证召回深度足够(第 8 篇的每路 50),把重排候选控制在 30 左右,把省下的预算留给生成。

几个能实打实省延迟的做法:

  • 缓存:相同查询或相同 (query, chunk_id) 对的重排分数缓存一小段时间,对高频问答很有效;
  • 批量与截断:候选文档按 embed_text 长度截断到重排模型的上限,别把整篇父文档送进去;
  • 级联:先用极轻量的模型把 50 条筛到 15 条,再用强模型排这 15 条;
  • 蒸馏:用强 cross-encoder 或 LLM 在业务日志上打分,蒸馏出一个小模型,线上跑小模型。这条投入最大,但对高 QPS 系统最值;
  • 并行:重排与上下文构造里不依赖排序结果的部分(比如取父文档、拉元数据)并发做。

排序结果怎么用,比怎么排更容易出错

跨批次分数不可比。 cross-encoder 的 logit、LLM 的等级、RRF 的融合分,都不能拿来当全局置信度。第 11 篇讲拒答时会强调:如果要用阈值判断「有没有相关材料」,得在同一批候选内部做相对判断(比如 top1 与 top2 的差距、top1 是否明显高于中位数),或者单独训一个校准过的判别器。

别把重排当成过滤器用得太狠。 有些查询确实需要多份互补证据,只留 top1 会丢掉支撑材料。我一般最终送 5–8 条,并保留来源多样性约束(同一 doc_id 最多贡献 2 条,除非它们来自不同章节)。

排序稳定性要监控。 LLM 重排尤其容易出现「同样的输入两次结果不同」,导致线上答案抖动。我会记录每次重排前后的 top5 编号,把「同一查询重复请求排序不一致」的比例作为一个监控指标;超阈值就退回确定性更强的 cross-encoder。

降级路径。 重排服务超时或返回非法输出时,直接用融合后的原顺序取 top N,标记 degraded=true,绝不让整个请求失败。这个分支要在压测里真的跑到,第 7 篇说过同样的话——没走过的降级路径等于不存在。

评测:排序指标和端到端要分开看

召回阶段用 Recall@K,排序阶段更适合 nDCG@K 或 MRR:前者关心「有没有」,后者关心「排在前面且相关度分级合理」。我会同时记三组数:

  1. 检索层:融合后候选的 Recall@30(重排的上限,重排永远救不回没召回的东西);
  2. 排序层:重排后 top5 的 nDCG@5、MRR;
  3. 端到端:最终答案的忠实度、可回答率、以及 P95 延迟与单次成本。

只看第 2 组容易自欺:nDCG 涨了但答案没变好,常见原因是最终上下文里本来就装得下正确答案,排序提升没有转化成生成收益。反过来,端到端变差也可能是上下文构造的锅(第 10 篇),不全是重排。三组一起看,才能判断该往哪一层投入。

优劣与边界

收益:在召回宽度不变的前提下,把正确答案推到模型真正会读的位置;能处理时效、否定、多条件这类需要交互的细粒度判断;允许第一阶段用更便宜、更粗糙的检索(反正后面会重排),整体上常常反而更省。

代价:额外的模型服务与延迟;候选深度、模型档位、截断长度形成一个新的参数空间;LLM 重排还带来不确定性和成本;分数不可比,做拒答要额外设计。

什么时候不做:语料很小、每次查询的相关文档只有一两篇时,排序根本没有意义(我那个几百 chunk 的博客 RAG 就没上重排);延迟预算极紧、或完全没有 GPU 的部署环境;以及召回本身就不合格的情况——Recall@50 很低时先回去修切分、嵌入和混合检索,加重排只是把错误的候选排得更整齐。

到这一篇,从语料到「排好序的候选片段」这条链路就完整了。但片段排对了,答案仍可能错:五段都相关,换个拼接顺序模型就答反了;片段太长,关键信息被挤到上下文中间没人看。下一篇讲召回之后的上下文构建。你现在线上最终送进模型的是几条片段,其中最相关的那条通常排在第几位?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#RAG #架构 #重排序

评论