Reranker重排原理与选型#
一句话答案#
Reranker 通常是 cross-encoder:把 query 和文档拼在一起过一遍 Transformer,让两边的 token 互相做注意力,所以比分开编码的 bi-encoder 准;代价是每个候选都要单独做一次前向、结果不能预先算好,所以只放在召回之后,对几十条候选做精排。
核心要点
RAG分块与召回策略 里说过「先粗排 top-50 再用 Cross-Encoder 精排 top-5」,本篇讲为什么这样分工、reranker 怎么训练、LLM 怎么当 reranker、线上怎么控制延迟。
1. bi-encoder vs cross-encoder:准和快的来源#
bi-encoder: q → Enc → v_q d → Enc → v_d score = v_q · v_d
cross-encoder: [CLS] q [SEP] d [SEP] → Enc → [CLS] 隐状态 → 线性层 → score(logit)
late interaction(ColBERT): 各自编码保留所有 token 向量 → MaxSim 求和plaintext| 维度 | bi-encoder(双塔) | cross-encoder |
|---|---|---|
| 交互时机 | 编码完才比较,query 和文档从不「看见」对方 | 从第一层起 query 和文档 token 就互相做注意力 |
| 信息瓶颈 | 整段文本压成一个向量,细节丢失 | 没有这个瓶颈,能判断否定、数量、约束是否满足 |
| 文档侧能否离线算 | 能,建 ANN 索引 | 不能,分数依赖 query |
| 单 query 成本 | 1 次 query 编码 + ANN 检索(毫秒级) | N 个候选 = N 次前向,注意力开销随 len(q)+len(d) 平方增长 |
| 适合的位置 | 从百万级库里召回 | 对几十到一两百条候选精排 |
bi-encoder 的训练和选型见 Embedding原理与选型;ColBERT/BGE-M3 多向量介于两者之间,见 混合检索与RRF融合。
cross-encoder 输出的是 logit,不是概率:不同模型的分数范围差别很大。要当阈值用(比如「低于 X 就丢掉」),先过 sigmoid,再在自己的数据上标定阈值,不要照搬别人的数字。
2. 召回 → 粗排 → 精排 漏斗#
flowchart LR A[全库 百万级] -->|ANN + BM25 召回| B[数百条] B -->|RRF 融合 / 轻量模型| C[20~50 条] C -->|cross-encoder 精排| D[top 3~10] D --> E[拼进 LLM 上下文]
- 每一层都只能在上一层给的候选里排序:召回漏掉的,rerank 补不回来。rerank 效果的上限是候选池的 Recall@N。
- 候选池加深不一定有收益:池子越深,rerank 越慢,而新增的多是低质量候选。要用评测集测「池子深度 vs 最终 NDCG@k」再决定。
- 搜索推荐里的精排常用 LTR 模型融合点击率等业务特征,见 搜索系统设计;RAG 里一般只有文本相关性,cross-encoder 就是精排。
3. 训练目标:pointwise / pairwise / listwise#
| 类型 | 学什么 | 典型损失 | 特点 |
|---|---|---|---|
| pointwise | 单条 (q,d) 的相关度 | BCE(二值标签)/ 回归(分级标签) | 分数有绝对含义,能当阈值用;但不直接优化排序 |
| pairwise | 同一 query 下 d+ 应排在 d− 前面 | RankNet(log(1+e^{-(s+ − s−)}))、margin loss | 只关心相对顺序;样本对数量随候选数平方增长 |
| listwise | 一个 query 的整组候选的排序 | ListNet(组内 softmax 交叉熵)、LambdaRank、ApproxNDCG | 最贴近排序指标;分数只在组内有意义,校准会变差 |
cross-encoder 常见的微调方式是「1 个正例 + n 个负例」为一组,做组内 softmax 交叉熵,本质是 listwise 的特例。有分级标签(如 ESCI 的 E/S/C/I)时,把档位转成组内的目标分布:
import torch
import torch.nn.functional as F
def listnet_loss(scores: torch.Tensor, gains: torch.Tensor, tau: float = 1.0) -> torch.Tensor:
"""scores: [B, G] 模型对每组 G 个候选的打分; gains: [B, G] 分级标签,如 E=3,S=2,C=1,I=0"""
target = F.softmax(gains.float() / tau, dim=-1) # 标签档位 → 目标分布
log_pred = F.log_softmax(scores, dim=-1)
return -(target * log_pred).sum(-1).mean()
def mixed_loss(scores, gains, binary_labels, w_point: float = 0.3):
"""listwise 管排序,加一项 pointwise BCE 保住分数的绝对含义(要做阈值判别时)"""
point = F.binary_cross_entropy_with_logits(scores, binary_labels.float())
return listnet_loss(scores, gains) + w_point * pointpython负例怎么来:随机负例太简单,模型学不到细粒度区分;只用 ANN 挖的难负例,又容易混进「其实相关但没标注」的假负例,而且模型没见过完全无关的样本。常见做法是难负例 + 随机负例混合,再用一个更强的模型过滤假负例。
4. LLM 当 reranker#
| 方式 | 做法 | 问题 |
|---|---|---|
| pointwise | 让 LLM 对每条回答「相关吗 yes/no」,取 yes 的 logprob 当分数 | 调用次数 = 候选数;需要能拿到 logprob |
| pairwise | 每次比较两条哪条更相关,再聚合 | 比较次数多,成本最高 |
| listwise | 一次给 N 条带编号的段落,让模型输出排列,如 [3] > [1] > [2] | 受上下文长度限制;输出可能漏号、重号,要校验和补齐 |
滑动窗口(RankGPT 的做法):候选数超过一次能放下的数量时,窗口从列表末尾向前滑,每次对窗口内的段落排序,窗口之间有重叠(常见设置如窗口 20、步长 10)。一轮下来,相关的段落能被一路「冒泡」到前面,保证 top 部分比较可靠,但后部的顺序不完全可靠。
def sliding_window_rerank(query, docs, rank_fn, window=20, step=10):
"""rank_fn(query, docs) -> 这批 docs 的新顺序(LLM listwise 排序,内部需校验编号)"""
docs = list(docs)
end = len(docs)
while end > 0:
start = max(0, end - window)
docs[start:end] = rank_fn(query, docs[start:end])
if start == 0:
break
end -= step
return docspythonLLM reranker 的其他坑:对段落在 prompt 里的位置敏感(位置偏差)、结果不稳定、延迟和成本远高于小型 cross-encoder。常见折中是用 LLM 标注数据,再蒸馏到小的 cross-encoder 上线。
5. 延迟与 top-k 取舍#
cross-encoder 的延迟大致 ∝ 候选数 × 单条序列长度的计算量,控制手段:
| 手段 | 做法 |
|---|---|
| 限候选数 | 只对融合后 top-20~50 精排,给候选数设硬上限 |
| 截断 | max_length 截断文档(如 512 token);chunk 过长时先按段落切再打分取最大值 |
| 批处理 | 一个 query 的所有 (q,d) 对放一个 batch;按长度分桶减少 padding |
| 推理优化 | FP16、ONNX/TensorRT 导出、专门的推理服务(批合并) |
| 缓存 | 以 (query 归一化, doc_id, 模型版本) 为 key 缓存分数 |
| 降级 | reranker 超时或挂掉时直接用融合排序,别让整个请求失败 |
最终送给 LLM 的 top-k:太小容易漏掉答案,太大则噪声多、token 成本高,还可能出现长上下文中段信息被忽略的问题(见 长上下文处理)。k 要在端到端评测上定。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
def rerank(query: str, docs: list[dict], top_k: int = 5, max_candidates: int = 50) -> list[dict]:
cands = docs[:max_candidates] # 候选数硬上限
scores = reranker.predict([(query, d["text"]) for d in cands], batch_size=32)
for d, s in zip(cands, scores):
d["rerank_score"] = float(s)
return sorted(cands, key=lambda d: d["rerank_score"], reverse=True)[:top_k]python6. 常见模型族与选型#
具体型号和版本更新快,以官方模型卡为准。
| 类别 | 代表 | 适用 |
|---|---|---|
| 开源 cross-encoder | BGE:bge-reranker-base/large、多语言 bge-reranker-v2-m3(基于 BGE-M3,输入上限 512 token);ms-marco MiniLM 系列(英文) | 自部署、可微调、延迟可控 |
| 基于 LLM 底座的 reranker | BGE:bge-reranker-v2-gemma、bge-reranker-v2-minicpm-layerwise、bge-reranker-v2.5-gemma2-lightweight;Qwen3-Reranker(0.6B / 4B / 8B,用 yes/no token 的 logit 打分,支持指令);Jina reranker v3 / v3.5(0.6B listwise,一次对多条文档打分,权重为非商用许可) | 精度更高,显存和延迟也更高;注意许可证 |
| 商业 API | Cohere Rerank 4(rerank-v4.0-pro / rerank-v4.0-fast,32K 上下文、多语言),Voyage rerank-2.5 / rerank-2.5-lite(支持指令),Jina、Pinecone 等托管 rerank | 免运维,按调用计费,数据要出域 |
| 通用 LLM | 用 listwise prompt 直接排序 | 离线标注、小流量、需要推理判断的场景 |
型号迭代很快,以上为 2026-09 的情况,上线前以官方模型卡和 changelog 为准。
选型看四点:语言(中文或多语言要选多语言模型);延迟预算(top-50 候选时在目标硬件上实测 p95);分数怎么用(只排序,还是要当阈值做判别,后者要标定);能否微调(领域差异大时,开源模型 + 自有标注往往比换更大的模型更有效)。
面试回答(2分钟版)
Reranker 一般是 cross-encoder。它和召回用的双塔 bi-encoder 的区别在交互时机:双塔是 query 和文档各自编码成一个向量再算点积,文档向量能离线算好建索引,所以快,但整段文本压成一个向量会丢细节;cross-encoder 把 query 和文档拼成一个序列输入,从第一层起两边的 token 就互相做注意力,能判断否定、数量、约束是否满足,所以准,但每个候选都要单独做一次前向,没法预先计算,只能放在召回之后,对几十条候选精排。所以整体是一个漏斗:ANN 加 BM25 召回几百条,RRF 融合到几十条,cross-encoder 精排出 top 3 到 10 条给 LLM。rerank 的上限是候选池的召回率,召回漏掉的它补不回来。训练目标分三类:pointwise 用 BCE 学单条相关度,分数能当阈值;pairwise 学正例排在负例前面;listwise 对整组做 softmax 交叉熵,最贴近排序指标,但分数只在组内有意义,拿来卡阈值会失效。LLM 也能当 reranker,listwise 方式一次给多条带编号的段落让模型输出排列,候选多时用滑动窗口从后往前排,代价是慢、贵、对位置敏感。线上要控制候选数、截断长度、批处理和缓存,还要有超时降级。另外要先看清楚 rerank 分数在线上是怎么被用的:如果线上只拿它做阈值判别,用 listwise 损失训出来的模型即使离线排序指标涨了,判别能力也可能变差,不能直接上线。结合项目时可以讲:分数在哪些地方被消费、用什么数据验证了换模型的收益。
追问与易错
追问方向:
- “cross-encoder 为什么比 bi-encoder 准?” → bi-encoder 的 query 和文档各自编码,信息被压进一个向量后才比较;cross-encoder 把两者拼接后做全注意力,每一层都能看到对方的 token,能捕捉否定、约束是否满足这类细粒度匹配。
- “cross-encoder 为什么不能用来直接检索全库?” → 分数依赖 query,文档侧没法预先计算,每个候选都要一次完整前向;对百万级库做一次查询等于跑百万次模型。
- “pointwise 和 listwise 训练出来的分数,使用上有什么不同?” → pointwise(BCE)的分数有绝对含义,可以设阈值做判别;listwise 只优化组内相对顺序,分数整体偏移不影响损失,所以跨 query 不可比,拿来卡阈值会失效。两种都要时可以在 listwise 上加一项 pointwise 损失。
- “rerank 的候选池是不是越深越好?” → 不一定。池子深了延迟线性增长,新增的多是低质量候选;rerank 上限是池子的 Recall@N,要在评测集上测池深和最终 NDCG@k 的关系再定。
- “LLM listwise rerank 的滑动窗口怎么滑,为什么从后往前?” → 窗口从列表末尾向前移动,相邻窗口有重叠;从后往前滑时,排在后面的相关段落能一步步被带到前面,一轮就能把最相关的段落送进 top。代价是后部顺序不可靠,而且调用次数约等于候选数除以步长。
- “LLM 输出的排列漏号或重号怎么办?” → 解析后去掉重复编号和不存在的编号,没出现的编号按原顺序补到末尾;多次失败就降级回原顺序。
- “reranker 延迟太高怎么优化?” → 限候选数、截断 max_length、同 query 的候选合成一个 batch 并按长度分桶、FP16 或 ONNX/TensorRT 推理、按 (query, doc_id, 模型版本) 缓存分数,超时就降级到融合排序。
- “cross-encoder 的分数能直接当 0~1 的相关概率用吗?” → 不能。输出是 logit,不同模型的范围不同;要先过 sigmoid,再用自己的标注数据标定阈值(看误杀率和漏放率),换模型必须重新标定。
- “难负例越多越好吗?” → 不是。难负例里常混有没标注的正例(假负例),全用难负例还会让模型没见过完全无关的样本,跨类判别变差。一般混入随机负例,并用更强的模型过滤假负例。
易错点:
- ❌ “rerank 能弥补召回不足” → 它只能在已召回的候选里重新排序。
- ❌ “离线 NDCG 涨了就可以上线” → 要先确认线上怎么用 rerank 分数:线上用它做阈值判别时,排序指标涨了,判别能力可能反而变差。
- ❌ “照搬别人的 rerank 阈值” → 阈值依赖模型和数据分布,必须在自己的数据上标定。