面试知识库
高 进阶

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 * point
python

负例怎么来:随机负例太简单,模型学不到细粒度区分;只用 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 docs
python

LLM 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]
python

6. 常见模型族与选型#

具体型号和版本更新快,以官方模型卡为准。

类别代表适用
开源 cross-encoderBGE:bge-reranker-base/large、多语言 bge-reranker-v2-m3(基于 BGE-M3,输入上限 512 token);ms-marco MiniLM 系列(英文)自部署、可微调、延迟可控
基于 LLM 底座的 rerankerBGE: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,一次对多条文档打分,权重为非商用许可)精度更高,显存和延迟也更高;注意许可证
商业 APICohere 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 阈值” → 阈值依赖模型和数据分布,必须在自己的数据上标定。