---
url: /rag/rerank-in-rag.md
description: >-
  向量召回的 top10
  常常混进不相关的段落，重排模型用一个更贵但更准的打分器把真正的答案排到前面。这篇讲双塔和交叉编码器为什么差这么多、bge-reranker-v2-m3 和
  Cohere Rerank 3.5 怎么选、top_n 与重叠窗口怎么设，以及我调重排时踩过的延迟和分数漂移问题。
---

# 检索完再加一层重排，RAG 的命中率还能再涨一截

上一篇混合检索里提到交叉编码器重排（rerank），只给了一段公式。这篇展开讲：它到底在算什么，什么时候值得加，怎么加才不把接口延迟拖垮。

## 双塔和交叉编码器，差在哪

向量检索是双塔结构（bi-encoder）。查询和文档各自过一次编码器，算余弦相似度。文档向量化是离线做的，查询来了只算一次，快。

代价是两边编码时互相看不见。文档「错误码 ERR\_SSL\_PROTOCOL\_ERROR 表示 TLS 握手失败」，和查询「https 打不开 报错」，分开编码后向量未必靠近。

重排模型是交叉编码器（cross-encoder）。把查询和文档拼成一对，一起送进模型，直接输出一个相关性分数。token 两两之间都做了注意力，判断准得多。

代价也要认。每个候选文档都要跑一遍前向计算，召回 50 条就是 50 次推理。

所以重排永远放在召回之后，只处理小候选集。这个架构叫两阶段检索（two-stage retrieval）。

## 可用的模型

自托管首选 BAAI 的 bge-reranker-v2-m3。568M 参数，基于 XLM-RoBERTa-Large，多语言（BAAI 官方文档，2026-10-08 核实），中英都好用。用 FlagEmbedding 库（注意 pip 包名是 FlagEmbedding）：

```python
from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
scores = reranker.compute_score([["查询文本", "候选段落1"], ["查询文本", "候选段落2"]])
```

不想自己管 GPU 就用 API。Cohere Rerank 3.5 按 search unit（查询单元）计费，约 $2 / 1000 次（Cohere 官方 pricing 页，2026-10-08 核实）。一个查询单元是一个 query 配至多 100 个文档，单文档超 500 token 会被切分，每块单独计件。

Jina 的 jina-reranker-v2-base-multilingual 是 278M 参数、1024 token 上下文（HuggingFace 模型卡，2026-10-08 核实），也有托管 API。

小体量、有 GPU，选 bge-reranker-v2-m3。没 GPU 或者量不大，Cohere 的账单一般比一张卡便宜。

## 代码怎么接

以 LangChain 为例，假设已经有一个混合检索器 retriever，返回 top 20：

```python
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder

model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-v2-m3")
reranker = CrossEncoderReranker(model=model, top_n=5)

chain = ContextualCompressionRetriever(
    base_compressor=reranker,
    base_retriever=retriever,
)
```

Cohere 版就是换一个 compressor，`CohereRerank(model="rerank-v3.5", top_n=5)`。

召回 20 到 50 条，重排后取 top 5，是我常用的配置。候选集太小重排没发挥，太大延迟顶不住。

### top\_n 和相邻切片

另一个细节：重排得分高的段落，相邻段落往往也得跟着给。答案跨在两段里，只给 top 1 那段，模型看到的是半句。

我的做法是重排后不只取 top\_n，而是取 top 段落加上它前后各 1 个相邻切片（去重）。切片是固定 500 token 切的话，这个操作就是按 index 取邻居。

```python
def expand(chunks, picked_idx, neighbors=1):
    keep = set()
    for i in picked_idx:
        keep.update(range(max(0, i - neighbors), i + neighbors + 1))
    return [chunks[i] for i in sorted(keep)]
```

## 我踩过的两个坑

一个是延迟。我用 CPU 跑过 568M 的重排模型，50 条候选要 8 秒往上，线上根本没法用。

bge-reranker-v2-m3 必须 GPU 加 fp16，再不济也得把候选数砍到 20 以内。后来我上了 T4 加 fp16，50 条压到 400ms 左右。

另一个是分数漂移。重排分数不是概率，同一对文本换个模型版本分数就变。

我一开始拿 bge 的分数做阈值过滤（低于 0.3 就丢），升级模型后整套阈值全失效。教训是分数只用来排序，过滤交给 top\_n，别依赖绝对值。

## 重排不是每条链路都值得加

它给每条查询加一次推理，成本和延迟都上去了。

语料小（几百个切片以内）、召回本来就准的场景，加重排基本是白花钱。我记得自己试过一个内部文档库，不到 200 切片，加重排前后答案质量没差别。

反过来说，语料几千切片以上、用户查询五花八门、又能接受几十毫秒延迟的，重排基本是性价比最高的一级：不用换 embedding 模型，不用重新入库，只在查询链路末尾插一段代码。
