Skip to content

14.13-Rerank重排序

要点

  • Bi-encoder 在编码阶段就让查询和文档「隔离」了——无法捕捉细粒度的语义交互,这是检索精度的天花板
  • Cross-encoder 把查询和文档拼在一起输入 Transformer,逐层做双向注意力,判断精度远高于向量余弦距离
  • Rerank 模型就是 cross-encoder,但它比重排每条文档都要重新推理——成本高、延迟大,必须先用 bi-encoder 粗筛
  • 选型维度:多语言能力、部署方式(API / 本地)、延迟、成本;中文场景 BGE 和 Cohere 是第一梯队
  • Rerank 不是万能的——如果检索阶段根本没召回正确答案,Rerank 救不回来

1. Bi-encoder 的精度天花板

向量检索用的是 bi-encoder——查询和文档分别编码成向量,然后通过余弦距离找最近的候选。

这个架构有一个根本性的限制:查询和文档在编码时完全独立,彼此不知道对方的存在。编码完成之后才计算距离,此时交互已经不可能发生了。

来看一个具体的例子:

查询: "支持微信支付吗"

文档 A: "我们支持多种支付方式,包括支付宝和微信支付"
  → cosine = 0.86  ✅ 确实支持

文档 B: "支付方式说明:目前支持支付宝,微信支付即将上线"
  → cosine = 0.88  ❌ 分数反而最高,但「即将上线」≠「已支持」

Bi-encoder 被「微信支付」这个关键词的语义信号误导了。文档 B 虽然提到了微信支付,但说的是「即将上线」,并没有真正回答用户的问题。模型无法区分这两种情况,因为编码文档时它根本不知道查询在问什么。

这不是某个 embedding 模型的问题,而是 bi-encoder 架构的上限。只要查询和文档独立编码,就无法捕捉这种需要交叉理解的细粒度语义关系。

2. Cross-encoder:让查询和文档充分交互

Cross-encoder 从架构层面解决了 bi-encoder 的交互缺失。

它不做两次独立编码,而是把查询和文档拼成一段文本,一起输入 Transformer:

输入: [CLS] 支持微信支付吗 [SEP] 支付方式说明:目前支持支付宝,微信支付即将上线 [SEP]
输出: relevance_score = 0.42  ← 模型理解「即将上线」不等于「已支持」

输入: [CLS] 支持微信支付吗 [SEP] 我们支持多种支付方式,包括支付宝和微信支付 [SEP]
输出: relevance_score = 0.93  ← 明确匹配

底层机制是这样的:Transformer 的每一层 self-attention 都会计算查询 token 和文档 token 之间的注意力权重。到了网络最后几层,模型已经积累了足够的交叉信息来做精确的相关性判断。

这和 bi-encoder 的区别不是程度上的,而是能力层级上的:

维度Bi-encoderCross-encoder
编码方式查询和文档独立编码拼接后联合编码
交互深度编码完成后才通过距离间接交互每一层 self-attention 都直接交互
判断能力语义相似度(向量空间距离)语义匹配 + 逻辑判断(接近 NLI)
推理成本文档可预计算、缓存,查询只需一次编码每对 (查询, 文档) 都要完整推理一次
典型延迟毫秒级(ANN 检索)百毫秒到秒级(取决于文档数)

Cross-encoder 做的事情更接近自然语言推理(NLI)——给定查询的意图,判断文档是否真的满足这个意图。这种交互深度决定了它的精度远高于 bi-encoder。

代价也很明显:bi-encoder 可以对百万文档预计算向量,而 cross-encoder 必须对每一对查询-文档实时推理。不可能对所有文档都做 cross-encoder 打分,这就是为什么需要两阶段架构。

3. 两阶段检索架构

把 bi-encoder 的速度优势和 cross-encoder 的精度优势组合起来,就得到了 RAG 系统的标准检索链路:

不加 Rerank:
  查询 → bi-encoder 检索 topK → 直接进 Prompt

加 Rerank:
  查询 → bi-encoder 检索 topN(N ≈ 50)
       → cross-encoder Rerank → 取 topK(K ≈ 5)→ 进 Prompt

第一阶段用 bi-encoder 做粗筛,从海量文档中快速召回 50 个候选。第二阶段用 cross-encoder 对这 50 个候选逐条精排,挑出最相关的 5 个。

typescript
async function searchWithRerank(
  query: string,
  queryVector: number[],
  options: { topK?: number; topN?: number } = {}
): Promise<SearchResult[]> {
  const { topK = 5, topN = 50 } = options

  // 第一阶段:bi-encoder 粗筛,多取一些候选
  const candidates = await vectorDB.search(queryVector, { topK: topN })

  // 第二阶段:cross-encoder 精排
  const reranked = await reranker.rerank(query, candidates)

  // 取 topK 进入 Prompt
  return reranked.slice(0, topK)
}

topN 的选择是一个工程权衡:太小会漏掉正确答案(Rerank 无从选择),太大会增加 Rerank 的延迟和成本。经验值是 20-50,具体取决于你的文档粒度和查询复杂度。

4. 主流 Rerank 模型选型

四个主流选项,用统一维度比较:

维度Cohere RerankBGE RerankerJina RerankerWorkers AI Rerank
模型rerank-multilingual-v3.0bge-reranker-v2-m3jina-reranker-v2-base-multilingualbge-reranker-base
部署API本地 / APIAPICloudflare 边缘
多语言强(中英文均优)强(m3 版多语言)中上
分数范围0-1,可跨查询比较原始 logits,需 softmax0-10-1
50 文档延迟300-800msCPU: 3-5s / GPU: 200-500ms200-600ms100-300ms
成本$1/1000 次搜索免费(自部署)$0.15/1M tokenWorkers AI 计费
适用场景多语言、追求精度中文优先、有 GPU已有 Jina 生态Cloudflare 生态

选择决策

  • 中文为主、英文为辅——BGE Reranker v2-m3 和 Cohere 效果都很好。有 GPU 资源选 BGE(零边际成本),追求省之选 Cohere
  • 纯 API 不想运维——Cohere 最成熟,Jina 性价比高
  • 已在 Cloudflare 生态——Workers AI 延迟最低,集成最方便,但模型选择有限

各模型的接入方式:

typescript
// Cohere Rerank
import { CohereClient } from 'cohere-ai'

const cohere = new CohereClient({ token: process.env.COHERE_API_KEY })

async function rerankWithCohere(
  query: string,
  documents: Array<{ id: string; content: string }>
): Promise<Array<{ id: string; score: number }>> {
  const response = await cohere.rerank({
    query,
    documents: documents.map((d) => d.content),
    model: 'rerank-multilingual-v3.0',
    topN: 5,
  })

  return response.results.map((r) => ({
    id: documents[r.index].id,
    score: r.relevance_score,
  }))
}
typescript
// BGE Reranker(本地部署,使用 @xenova/transformers)
import { pipeline } from '@xenova/transformers'

const reranker = await pipeline('text-classification', 'BAAI/bge-reranker-v2-m3')

async function rerankWithBGE(
  query: string,
  documents: Array<{ id: string; content: string }>
): Promise<Array<{ id: string; score: number }>> {
  const results = []

  for (const doc of documents) {
    const output = await reranker(`${query} [SEP] ${doc.content}`, {
      pooling: 'cls',
    })
    results.push({ id: doc.id, score: output[0].score })
  }

  results.sort((a, b) => b.score - a.score)
  return results
}
typescript
// Jina Reranker
async function rerankWithJina(
  query: string,
  documents: Array<{ id: string; content: string }>
): Promise<Array<{ id: string; score: number }>> {
  const response = await fetch('https://api.jina.ai/v1/rerank', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.JINA_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      model: 'jina-reranker-v2-base-multilingual',
      query,
      documents: documents.map((d) => d.content),
      top_n: 5,
    }),
  })

  const data = await response.json()
  return data.results.map((r: any) => ({
    id: documents[r.index].id,
    score: r.relevance_score,
  }))
}
typescript
// Workers AI Rerank(Cloudflare 环境)
async function rerankWithWorkersAI(
  query: string,
  documents: Array<{ id: string; content: string }>,
  ai: Ai
): Promise<Array<{ id: string; score: number }>> {
  const response = await ai.run('@cf/baai/bge-reranker-base', {
    query,
    documents: documents.map((d) => d.content),
  })

  return response.results.map((r, i) => ({
    id: documents[i].id,
    score: r.score,
  }))
}

5. 完整检索→Rerank 链路

把前面的片段组装成一个可直接使用的 Rerank 服务。

typescript
// src/services/rag/rerank/index.ts

export type RerankProvider = {
  name: string
  rerank(
    query: string,
    documents: Array<{ id: string; content: string }>
  ): Promise<Array<{ id: string; score: number }>>
}

export class RerankService {
  private provider: RerankProvider

  constructor(provider: RerankProvider) {
    this.provider = provider
  }

  async rerank(
    query: string,
    candidates: SearchResult[],
    options: { topK?: number; maxLength?: number } = {}
  ): Promise<SearchResult[]> {
    const { topK = 5, maxLength = 500 } = options

    // 截断文档——Rerank 不需要看全文,只看相关性
    const documents = candidates.map((c) => ({
      id: c.id,
      content: (c.content ?? '').slice(0, maxLength),
    }))

    // 调用 provider 打分
    const scores = await this.provider.rerank(query, documents)

    // 按分数排序,取 topK
    const scoreMap = new Map(scores.map((s) => [s.id, s.score]))

    return candidates
      .map((c) => ({ ...c, score: scoreMap.get(c.id) ?? 0 }))
      .sort((a, b) => b.score - a.score)
      .slice(0, topK)
  }
}

// Cohere provider 示例
export function createCohereProvider(apiKey: string): RerankProvider {
  const cohere = new CohereClient({ token: apiKey })

  return {
    name: 'cohere',
    async rerank(query, documents) {
      const response = await cohere.rerank({
        query,
        documents: documents.map((d) => d.content),
        model: 'rerank-multilingual-v3.0',
        topN: documents.length,
      })
      return response.results.map((r) => ({
        id: documents[r.index].id,
        score: r.relevance_score,
      }))
    },
  }
}

业务层只需要知道 RerankServiceRerankProvider 接口。切换底层模型(Cohere → BGE → Jina)不影响上层调用。

6. 延迟与成本量化

Rerank 的推理速度比 embedding 慢一个数量级——cross-encoder 需要对每对 (查询, 文档) 做完整的 Transformer 前向传播,不能像 bi-encoder 那样预计算向量。

模型50 文档延迟单次成本
BGE Reranker(CPU)3-5s免费(本地)
BGE Reranker(GPU)200-500ms电费 / GPU 租赁
Cohere Rerank API300-800ms~$0.001/次
Jina Reranker API200-600ms~$0.0002/次
Workers AI Rerank100-300msWorkers AI 计费

四个优化手段,按收益排序:

  1. 截断文档长度——Rerank 判断相关性不需要看全文,截断到 512 字符可以减少 40-60% 延迟。这是性价比最高的优化
  2. 控制候选数量——topN 不要超过 50,20-30 在多数场景已经够用
  3. 批量接口——Cohere 和 Jina 都支持一次传入多个文档,比逐条调用快很多
  4. 结果缓存——同一查询短时间内重复请求时,复用 Rerank 结果
typescript
// 截断优化——在 RerankService 中已经内置
const documents = candidates.map((c) => ({
  id: c.id,
  content: (c.content ?? '').slice(0, maxLength), // 默认 500 字符
}))

截断不影响精度的前提是:文档的核心信息通常在开头。如果你的文档结构特殊(比如关键信息在末尾),需要调整截断策略。

7. 什么时候不需要 Rerank

Rerank 是有代价的——增加延迟、增加成本、增加系统复杂度。以下三种情况可以跳过:

检索质量已经足够好。如果你的向量检索 Recall@5 已经达到 0.9 以上,加 Rerank 的边际提升可能不到 2-3%,不值得多花 300-800ms 延迟。先量化再决定。

正确答案根本没被召回。Rerank 只能在已召回的候选里重新排序——如果正确答案不在 top50 里,Rerank 无从选择。

向量检索 top50 没有正确答案 → Rerank 只能在这 50 个里挑 → 结果依然不对

这种情况该优化的是 embedding 模型、切分策略或检索方式(比如加混合检索),而不是加 Rerank。

延迟敏感且无法接受降级。实时聊天场景用户期望秒级响应,如果 Rerank 导致延迟超过 2 秒,体验会变差。替代方案是用更快的轻量模型、减少 topN,或者先返回向量检索结果、Rerank 完成后再更新排序。

决策原则:先跑 baseline 看检索质量,只在 Recall 不够用时才加 Rerank。加了之后对比 Rerank 前后的 Recall 提升,提升 < 5% 就考虑去掉。

8. 检索链路的下一环

Rerank 解决了「从候选中挑出最相关文档」的问题,但它只是检索→精排链路的终点。Rerank 无法弥补 embedding 模型或切分策略的缺陷——如果基础检索质量太差,加再多精排也救不回来。

精排完成后,你手里有 5 个高相关性的文档片段。下一步的问题变成:如何把这些片段组装成 LLM 能高效利用的 Prompt?拼接顺序、token 预算分配、去重策略——这些是上下文拼接(14)要解决的问题。

基于 MIT 协议开源