RAG 重排器对比:从 Bi-Encoder 到 Cross-Encoder,以及为什么 bge-reranker-v2-m3 是中文 RAG 的默认答案

引子:去年我搭第一个知识库时,犯过一个典型错误------召回阶段只靠向量相似度,取 top-5 直接喂给大模型。结果经常是:用户问"病假怎么申请",排在第一的却是年假政策(因为都含"申请""天数"这类高频词),真正相关的病假流程文档反而排第三。大模型基于错误文档生成,回答自然跑偏。后来加了一道重排(reranker),top-1 准确率肉眼可见地提上来了。这篇把"为什么需要重排、三种排序范式怎么选、bge-reranker-v2-m3 凭什么成为默认"讲透,并给一份能直接跑的代码。

0. 先说结论,省你时间

  • 只用向量召回不够:bi-encoder 把 query 和 doc 各自编码,丢失了两者之间的细粒度交互,top-k 排序常常"大致相关但不精确"。
  • 重排是 RAG 性价比最高的一步优化:论文和工程实践都证明,在已经跑通向量检索的管线上加一道 cross-encoder 重排,通常能换来 15%--30% 的精度提升,代价只是几十毫秒延迟。
  • 三种范式:无重排(bi-encoder 直通)/ bi-encoder 召回 + cross-encoder 重排(两段式,主流)/ 纯 cross-encoder(太慢,不适合大规模)。
  • bge-reranker-v2-m3 是当前开源多语言 RAG 的默认选择:568M 参数、Apache-2.0 可商用、100+ 语言、支持 FP16,社区基准(MTEB reranking)约 59.7 分,距离商业 API(Cohere Rerank 3.5 约 63.4)只差 3--4 分,但完全自托管。

下面展开,每一节都尽量给可验证的事实和可落地的代码。

1. 为什么要重排:两段式检索的动机

RAG 的检索本质是个"大海捞针"问题。理想的检索器要同时满足两件事:

  1. 召回率高:相关文档别漏掉(宁可多召回一些无关的)。
  2. 排序准:最相关的排在 top-1、top-3,大模型才好用。

但这两件事对同一个模型是矛盾的。向量检索(bi-encoder)擅长"广撒网"------在一个巨大向量空间里用近似最近邻(ANN)快速捞回一批候选,但它对"query 和 doc 到底哪儿相关"判断粗糙。把它既当召回又当精排,效果会打折。

两段式检索(retrieve-then-rerank)就是为此而生:

  • 第一阶段(召回):用便宜、快速的 bi-encoder 从百万级文档里捞回 top-20 或 top-100 候选。这一步允许"宁可错杀不可放过"。
  • 第二阶段(重排):用贵但准的 cross-encoder,只对这 20--100 个候选做精细相关性打分,重新排序,保留 top-8 或 top-10 喂给大模型。

这个范式不是拍脑袋。它直接对应信息检索里"召回"与"精排"分离的工程常识,也被后面要讲的论文从实验上证实有效。

2. 三种排序范式:架构上的根本差异

理解重排,先理解 bi-encoder 和 cross-encoder 在架构上的分叉。这是整篇文章的骨架。

2.1 Bi-Encoder(双塔)

query 和 document 分别进两个(或共享权重的)编码器,各自产出一个固定维度的向量。相关性 = 两个向量的余弦相似度(或内积)。

text 复制代码
query  ──> [Encoder_Q] ──> vec_q
                              │
                              ├── 余弦相似度 ──> score
                              │
document ──> [Encoder_D] ──> vec_d

优点:document 的向量可以离线预计算、建索引,线上查询时只需编码一次 query,然后用 ANN 检索。这正是 Sentence-BERT 论文的核心卖点。

缺点:query 和 document 在编码阶段互不看见,无法建模它们之间的词级、句级交互。一个问"如何撤销已提交的报销单",另一个文档讲"报销单提交后的审批流程"------两者语义高度相关,但字面重叠少,bi-encoder 容易判低分。

2.2 Cross-Encoder(交叉编码器)

query 和 document 拼在一起(通常是 CLS query SEP document SEP),过一个完整的 Transformer,用 CLS 位置的输出直接预测一个相关性分数。query 和 doc 在每一层注意力里都互相看对方。

text 复制代码
[CLS] query [SEP] document [SEP] ──> [Full Transformer, 全注意力] ──> relevance score

优点:能捕捉 query-doc 之间最细微的语义交互,排序精度远高于 bi-encoder。

缺点:不能预计算。每判断一个 (query, doc) 对,就要跑一次完整前向。如果有 100 万文档,就要跑 100 万次------这正是它只能做"重排"(对少量候选打分)而不能做"召回"(对全库打分)的原因。

2.3 无重排(bi-encoder 直通)

最简单:bi-encoder 召回 top-k 直接交差。没有第二阶段。成本低、延迟小,但排序精度天花板就是 bi-encoder 的上限。适合候选池本来就很小(< 10 篇)或查询极简单的场景。

2.4 一张图看懂三段关系

text 复制代码
            全库 (1M docs)
                │
   ┌────────────┴────────────┐
   │ 第一阶段:召回             │
   │ bi-encoder + ANN          │
   │ 快、可预计算、召回率高      │
   └────────────┬────────────┘
                │ top-20 ~ top-100 候选
   ┌────────────┴────────────┐
   │ 第二阶段:重排             │
   │ cross-encoder            │
   │ 慢、不可预计算、排序准      │
   └────────────┬────────────┘
                │ top-8 ~ top-10
                ▼
           交给 LLM 生成

3. 两篇奠基论文:把"快慢之争"钉死在数据上

下面两篇论文不是凑数,它们正好从两个方向把 bi-encoder 和 cross-encoder 的取舍讲清楚了。

3.1 Sentence-BERT(arXiv:1908.10084,Reimers & Gurevych,EMNLP 2019)

这篇论文的出发点恰恰是"原生 BERT 做相似度搜索太慢"。BERT 原版要做句子对任务,得把两个句子拼一起过网络,这导致:

在一组 10,000 个句子里找最相似的一对,用原生 BERT/ RoBERTa 需要约 5000 万次推理计算,耗时约 65 小时。

作者提出 Sentence-BERT,用孪生(siamese)网络结构把句子独立编码成向量,再用余弦相似度比较。结果:

把"找最相似对"的耗时从 65 小时降到约 5 秒,同时基本保持了 BERT 的精度。

这就是 bi-encoder 存在的根本理由------为了速度,牺牲 query-doc 交互。SBERT 解决的是"能不能用"的问题,但它在摘要里也明说了:它是用余弦相似度这种"廉价但粗糙"的方式比较的。换句话说,SBERT 自己就承认了它在精细排序上的天花板。

3.2 Passage Re-ranking with BERT(arXiv:1901.04085,Nogueira & Cho,2019)

这篇是重排的奠基工作,方向相反:它把"拼一起过 BERT"这个慢但准的做法,专门用在"重排"这一步。

做法:第一阶段用 BM25 等传统检索召回候选,第二阶段用 BERT cross-encoder 对 top-k 候选重新打分排序。结果:

在 MS MARCO passage retrieval 任务上,MRR@10 相对此前的 state-of-the-art 提升了 27%,登顶当时排行榜;在 TREC-CAR 上同样达到 SOTA。

27% 的相对提升,是一个极其震撼的数字。它说明:在召回已经不错的前提下,一道 cross-encoder 重排能带来的收益,往往比"换一个更好的 embedding 模型"大得多。这也是为什么业界常说------重排是 RAG 管线里 ROI 最高的一步优化。

3.3 两篇论文合起来的结论

  • SBERT(bi-encoder):解决"大规模召回的速度问题",代价是排序粗糙。
  • Nogueira & Cho(cross-encoder 重排):解决"精细排序的精度问题",代价是不能用于大规模。

两者不是二选一,而是串联:用 bi-encoder 做召回,用 cross-encoder 做重排。现代 RAG 的标准做法,就是这两篇论文在 2019 年共同铺好的路。

4. 主流重排器横向对比

有了方法论,看具体模型。下面这张表把常见的重排器放在一个维度上比,数据来自各模型卡与 MTEB 排行榜(社区基准,非单篇论文,数值会随榜单更新浮动,给出的是 2026 年中的典型值)。

模型 架构 参数量 语言 MTEB reranking 延迟(50 docs, GPU) 自托管 许可
无重排(bi-encoder 直通) --- --- 取决于 embedding 基准线 ~0ms 是 ---
ms-marco-MiniLM-L-6-v2 cross-encoder ~22M 英语 ~43.1 40--70ms 是 Apache-2.0
ms-marco-MiniLM-L-12-v2 cross-encoder ~33M 英语 ~47.3 70--110ms 是 Apache-2.0
bge-reranker-large cross-encoder ~335M 中英为主 ~60-64 85ms 是 MIT
bge-reranker-v2-m3 cross-encoder 568M 100+ 语言 ~59.7 90--130ms 是 Apache-2.0
bge-reranker-v2-gemma cross-encoder 2.51B 多语言 ~62.1 180--250ms 是 Gemma 许可
Cohere Rerank 3.5 cross-encoder(API) 未公开 多语言 ~63.4 80--120ms(API) 否 商业

怎么读这张表:

  • 延迟敏感、纯英文:MiniLM 系列最快(40--70ms),但 MTEB 只有 40 分出头,且只支持英文。适合候选集本来就排得不错、只需在头部做微调的场景。
  • 中英混合、要精度:bge-reranker-large 是经典选择,但语言覆盖窄。
  • 多语言生产环境:bge-reranker-v2-m3 是共识上的默认。它 568M 参数、2.27GB、Apache-2.0 可商用,100+ 语言,距离商业 API 只差 3--4 分却完全自托管。
  • 精度优先、GPU 充裕:v2-gemma(2.51B)再高 2--3 分,但延迟翻倍、显存 10GB,性价比不如 v2-m3。
  • Cohere 3.5:分数最高(~63.4),但走 API、数据出域、按量计费。对数据主权敏感的场景(比如企业内网知识库)直接排除。

一个常被忽略的点:重排器对"候选集质量"很敏感。它擅长在"已经大致相关的列表头部"做精细区分,不擅长"从一堆垃圾里捞金子"。所以重排的前提是召回阶段不能太差------这又回到前面"混合检索提升召回"的话题(bge-m3 的稠密+稀疏双路正是为此)。

5. bge-reranker-v2-m3 深挖

选它当默认答案,不是因为它分数最高,而是综合维度最均衡。逐条看规格(来自 BAAI 模型卡与社区资料):

  • 架构:cross-encoder,基于 XLM-RoBERTa backbone。query 和 doc 拼接后过完整 Transformer,用 CLS 输出经线性层得到 raw logits,越高越相关。
  • 参数量与体积:568M 参数,磁盘约 2.27GB。相比 v2-gemma 的 2.51B / 10GB,轻得多,消费级显卡也能跑。
  • 许可:Apache-2.0。这是企业最看重的------可商用、无传染性、可改可分发。
  • 语言:继承 bge-m3 的多语言能力,覆盖 100+ 语言,对代码切换(query 一半英文一半中文、文档是另一种语言)的处理明显好于纯英文 cross-encoder。对中文知识库是硬需求。
  • 上下文长度:最大 8192 token,但实际推理常用 512 token(足够覆盖一个 chunk + query)。长文档若要整篇过 reranker,需要注意截断策略。
  • 精度:支持 FP16 和 BF16,推理更快、显存更省,精度仅有轻微下降。自托管务必开 FP16。

评测方式上,BAAI 的设定是:用强召回器先捞 top-100,再用 v2-m3 重排,在 BEIR(英文)、C-MTEB-retrieval(中文)、MIRACL(多语言)上验证。它在这些基准上都显著超过前代 reranker。这恰恰印证了第 1 节说的"两段式"设定------reranker 本来就是配合召回器工作的。

6. 可运行代码:用 FlagEmbedding 跑 bge-reranker-v2-m3

下面是一份端到端可跑的代码。依赖就一个:

text 复制代码
pip install FlagEmbedding

核心用法非常简洁:

python 复制代码
from FlagEmbedding import FlagReranker

# 加载重排器,本地推理开 fp16
reranker = FlagReranker(
    "BAAI/bge-reranker-v2-m3",
    use_fp16=True,          # 开 fp16,速度翻倍、显存减半
)

# 构造 (query, document) 对
pairs = [
    ("病假申请流程", "员工病假需通过 OA 系统提交,附就诊证明,3 天内补交。"),
    ("病假申请流程", "年假需提前 7 天在系统申请,主管审批后生效。"),
    ("病假申请流程", "公司提供补充医疗保险,覆盖门诊与住院费用。"),
]

# 批量打分,normalize=True 把 logits 映射到 [0,1]
scores = reranker.compute_score(pairs, normalize=True)
print(scores)
# 输出类似 [0.91, 0.32, 0.18],第一个明显最相关

注意两点:第一,compute_score 的 normalize=True 会把原始 logits 过 sigmoid 压到 0--1,方便直接当阈值用;第二,批量传 pairs 比循环快很多,GPU 上尤其明显。

6.1 接进完整 RAG 两段式管线

下面把召回和重排串起来。这里用伪召回器占位,你实际可以替换成任何向量库(FAISS / Milvus / pgvector 都行):

python 复制代码
from FlagEmbedding import FlagReranker

# ---------- 第一段:召回(bi-encoder,这里用占位示意) ----------
def retrieve_topk(query, corpus, k=20):
    # 实际中:用 bge-m3 等 bi-encoder 编码 query 和 corpus,
    # 建 ANN 索引,返回 top-k 候选 (doc_id, text)
    # 此处省略向量检索实现
    return [(i, corpus[i]) for i in range(k)]   # 占位

# ---------- 第二段:重排(cross-encoder) ----------
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

def rerank(query, candidates, keep_top_n=8, score_threshold=0.3):
    pairs = [(query, text) for _, text in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(
        zip(candidates, scores),
        key=lambda x: x[1],
        reverse=True,
    )
    # 保留分数高于阈值的 top-n
    out = []
    for (cand, score) in ranked:
        if score < score_threshold:
            continue
        if len(out) >= keep_top_n:
            break
        out.append((cand, score))
    return out

# ---------- 串联 ----------
query = "病假申请需要哪些材料"
corpus = ["..."] * 1000   # 你的文档集
candidates = retrieve_topk(query, corpus, k=20)
final = rerank(query, candidates, keep_top_n=8, score_threshold=0.3)

for (doc_id, text), score in final:
    print(f"score={score:.3f}  doc_id={doc_id}  {text[:40]}")

这段代码里有两个关键旋钮:keep_top_n(重排后保留几条喂给 LLM)和 score_threshold(低于此分直接丢弃)。下一节细讲它们怎么调。

6.2 几个生产环境要注意的点

  • batch_size:候选多时(比如 top-100 重排),把 pairs 一次性传入 compute_score 让它内部 batch,比自己循环快数倍。
  • max_length:默认 512。如果你把整个父 chunk(可能 1000+ token)喂进去,会被截断。要么调大 max_length(更慢),要么控制输入长度(推荐,重排只需 query + 关键片段)。
  • 第一次加载会下载模型(约 2.27GB),之后走本地缓存。生产环境建议预下载并指定本地路径,避免冷启动拉模型。
  • CPU 也能跑,但 50 docs 约 400ms+;上 GPU 后降到 90--130ms。量大的话 GPU 是刚需。

7. 参数调优表:每个旋钮调大调小会怎样

把重排阶段的关键参数列成一张表,方便你按场景调:

参数 论文/社区默认 调大 调小
召回 TopK(第一阶段) 20--100 召回更全,但重排耗时线性增加 可能漏掉相关文档
保留 TopN(重排后) 8--10 喂给 LLM 的上下文更丰富,token 成本升 更聚焦,但可能丢边缘相关信息
score 阈值 0.3 左右 过滤更严,列表更干净 保留更多,可能引入噪声
fp16 开 --- 关则精度略升、速度显存变差
batch_size 自动 吞吐更高 显存更省
max_length 512 能处理更长 doc,但更慢 更快,但长 doc 被截断

经验法则:

  • 候选集质量一般(召回器较弱)→ 把召回 TopK 调大(如 50--100),让重排器有更多"原材料"挑。
  • 查询复杂、多条件(比如"2024 年 Q3 华东区销售额超目标的产品")→ 保留 TopN 调到 10,重排器在头部做精细区分。
  • 简单事实查询、候选本来就少(< 10 篇)→ 可以干脆不重排,省那几十毫秒。

8. 重排阶段的检索链路(ASCII 流程图)

把整条链路画出来,方便你对照自己的管线:

text 复制代码
用户 query
    │
    ▼
[bi-encoder 编码 query]  ──  全库 doc 向量(预计算)
    │                          │
    └──── 余弦/内积 + ANN ──────┘
    │
    ▼
top-20 候选 (召回,可含 BM25 混合)
    │
    ▼
[cross-encoder 逐对打分]  query+doc 拼接过 Transformer
    │
    ▼
按 score 降序 + 阈值过滤
    │
    ▼
top-8 精排结果 ──> 回取原文/父块 ──> LLM 生成

这条链路里,重排器是个"精准过滤器":它不负责从全库找候选,只负责把已经召回的 20 条排好序、剔掉不相关的。角色清晰,所以延迟可控。

9. 对照 CoreNote:它为什么这么设计

下面这段基于 CoreNote 公开的 README 事实,不脑补。CoreNote 的检索链路正好就是上面这套两段式:

  • 重排器用的是 bge-reranker-v2-m3:和本文第 5 节推荐一致------多语言、Apache-2.0 可商用、轻量自托管,对中文个人知识库是合理默认。
  • 检索 Top20 去重后重排 Top8:对应本文第 7 节"召回 TopK=20、保留 TopN=8"的典型配置。先召回 20 条保证不漏,再重排到 8 条控制喂给模型的上下文量。
  • 父子分块 + parent_chunk_id 回取:重排命中的是子块(200--300 token),但通过 parent_chunk_id 回取父块(800--1200 token)喂给模型。这解决了一个重排的副作用------子块被截断后语义不完整,回取父块才给模型完整上下文。
  • 不强制云端:OPENAI_BASE_URL 可指向 Ollama 等本地端点,reranker 也可本地部署。对数据不出域的隐私诉求是硬支撑。
  • embedding 用 bge-m3 稠密+稀疏双路(维度 1024):正是第 4 节说的"重排前提------召回不能太差"的工程落地,混合检索先把候选集质量抬起来,reranker 才能在头部精细排序。

诚实的边界:CoreNote 用的是父子分块,不是 RAPTOR 树状摘要(我们前一篇讲过 RAPTOR)。它的重排是标准两段式,没有用 listwise(考虑候选间相对关系)那种更前沿的设定。这对个人知识库足够,且实现成本和本地部署负担更低------RAPTOR 要额外跑 LLM 摘要建树,对个人频繁更新的库不划算。所以"选更轻的方案"是刻意的工程权衡,不是能力缺失。

10. 什么时候不该加重排

重排不是银弹,下面几种情况加了反而亏:

  1. 候选池本来就很小(< 10 篇):重排器在头部做精细区分才有价值,候选都差不多时它帮不上忙,还白加延迟。
  2. 要求亚 100ms 总延迟:cross-encoder 单批就加 50--200ms,实时性极强的场景要权衡。
  3. 向量检索本身精度已经很高:干净语料上的简单事实查询,bi-encoder 直通可能就够。
  4. 显存极度紧张且不能上 GPU:CPU 跑 v2-m3 一批 400ms+,吞吐起不来。

判断标准很简单:你的 top-1 命中率不够高、或者用户常问复杂多跳问题,就加重排;否则先别加。

11. 小结

  • bi-encoder 和 cross-encoder 不是对立,是串联:前者解决"快",后者解决"准"。
  • 两篇 2019 年的论文把这件事钉死:SBERT 证明 bi-encoder 把 65 小时压到 5 秒;Nogueira & Cho 证明 cross-encoder 重排让 MS MARCO 的 MRR@10 相对涨 27%。
  • 落地选 bge-reranker-v2-m3:568M、Apache-2.0、100+ 语言、FP16,社区基准距离商业 API 仅 3--4 分却完全自托管,是中文/多语言 RAG 的默认答案。
  • 参数就盯两个:召回 TopK(20--100)和保留 TopN(8--10),再加一个 score 阈值过滤噪声。
  • CoreNote 的"Top20 去重重排 Top8 + bge-reranker-v2-m3 + 父子分块回取"是这套方法论的标准工程实现,且刻意选了比 RAPTOR 更轻的方案以适配个人库。

重排是 RAG 里最该先做的那步优化------它便宜、可自托管、收益确定。先把这一段跑通,再考虑更复杂的检索结构。

参考

  • Reimers, N., Gurevych, I. (2019). Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP 2019. arXiv:1908.10084
  • Nogueira, R., Cho, K. (2019). Passage Re-ranking with BERT. arXiv:1901.04085
  • Chen, J., et al. (2024). M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings (BGE-M3). arXiv:2402.03216
  • BAAI. bge-reranker-v2-m3 model card. HuggingFace: BAAI/bge-reranker-v2-m3
  • FlagEmbedding 文档与示例(BAAI/FlagEmbedding)
  • CoreNote 项目:github.com/zhangjianxin477/CoreNote (检索与重排配置参见其 README)
相关推荐
大苦根1 小时前
RAPTOR 树状检索:从论文到一份能跑的代码,以及它为什么不是银弹
github
阿里嘎多学长1 小时前
2026-10-14 GitHub 热点项目精选
开发语言·程序员·github·代码托管
zzu123zsw2 小时前
不只是命令行补全:GitHub Copilot SDK 如何把 CLI 智能体引擎嵌入企业工作流?
github·copilot
miofly2 小时前
GitHub 今日推荐|ps2-web-recomp:浏览器里跑 PS2 游戏的静态重编译方案
c++·开源·github
阿里嘎多学长3 小时前
2026-10-13 GitHub 热点项目精选
开发语言·程序员·github·代码托管
miofly4 小时前
GitHub 日榜趋势速报 | 2026-10-15
开源·github
高频因子挖掘机5 小时前
股票 API 返回 HTTP 429 怎么办?Python 量化系统的限流排查与重试设计
后端·github·api
高频因子挖掘机6 小时前
从回测到实盘:量化策略为什么不应该直接依赖行情 API
后端·github·api
wflynn13 小时前
GitHub 日榜趋势速报 | 2026-10-13
开源·github