14.13-Rerank重排序

要点

  • 向量检索是「双塔模型」——查询和文档独立编码,交互不够深
  • Rerank 模型是交叉编码器——同时看查询和文档,判断更精细
  • 检索流程变成:向量检索 top50 → Rerank top10 → 取 top5 进 Prompt
  • 主流 Rerank 模型:Cohere Rerank、BGE Reranker、Jina Reranker、Workers AI
  • Rerank 增加延迟和成本——只在检索质量不够用时才加
  • Rerank 不是万能的——如果检索阶段完全没召回正确答案,Rerank 救不回来

1. 为什么需要 Rerank

向量检索用的是双塔模型(bi-encoder)——查询和文档分别编码成向量,然后算距离。

查询: "退款多久到账"
  → embed → [0.12, -0.34, ...]  (768 维)

文档 A: "退款一般在 3-5 个工作日到账"
  → embed → [0.11, -0.32, ...]  (768 维)

文档 B: "退货流程需要 7 天处理"
  → embed → [0.15, -0.30, ...]  (768 维)

cosine(query, A) = 0.92
cosine(query, B) = 0.88

双塔模型的问题是:查询和文档在编码时完全独立,不知道对方长什么样。只有编码完成后才算距离。

这意味着模型无法捕捉「查询和文档之间的细粒度交互」。比如:

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

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

文档 B: "我们接受微信支付、支付宝、银行卡等多种支付方式"
  → cosine = 0.85

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

双塔模型被「微信支付」这个关键词的语义相似度误导了。

Rerank 用的是交叉编码器(cross-encoder)——把查询和文档拼在一起输入模型:

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

输入: "[CLS] 支持微信支付吗 [SEP] 我们支持多种支付方式,包括支付宝和微信支付 [SEP]"
输出: relevance_score = 0.92

交叉编码器能看到查询和文档的每一个词之间的交互,判断更精确。

2. 检索 + Rerank 流程

原始检索流程:
  查询 → 向量检索 topK → 拼进 Prompt

加了 Rerank 后的流程:
  查询 → 向量检索 topN(N >> K,通常 N=50, K=5)
       → Rerank topN → 取 topK → 拼进 Prompt
async function searchWithRerank(
  query: string,
  queryVector: number[],
  options: { topK?: number; topN?: number } = {}
): Promise<SearchResult[]> {
  const { topK = 5, topN = 50 } = options
 
  // 第一步:向量检索,多取一些
  const candidates = await vectorDB.search(queryVector, { topK: topN })
 
  // 第二步:Rerank
  const reranked = await reranker.rerank(query, candidates)
 
  // 第三步:取 topK
  return reranked.slice(0, topK)
}

为什么先取 topN 再 Rerank?因为 Rerank 模型比 embedding 模型重很多——逐条打分很慢。如果直接对所有文档 rerank,成本太高。先用向量检索做粗筛,再用 Rerank 做精选。

3. 主流 Rerank 模型

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,
  }))
}

Cohere Rerank 的特点:

  • 多语言支持好(中英文都强)
  • 返回 0-1 的分数,可以直接比较
  • 价格:$1/1000 次搜索(每次搜索最多 50 个文档)

BGE Reranker(开源)

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
}

BGE Reranker 的特点:

  • 开源免费,可以本地部署
  • 中文效果好(bge-reranker-v2-m3 是多语言版)
  • 缺点:推理速度比 API 慢——没有 GPU 的话,50 个文档要几秒

Workers AI Rerank

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,
  }))
}

Workers AI 的好处是和 Vectorize 集成方便,延迟低。模型选择有限。

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,
  }))
}

4. 延迟和成本

Rerank 模型的推理速度比 embedding 模型慢一个数量级。

模型50 文档延迟成本
BGE Reranker(CPU)~3-5s免费(本地)
BGE Reranker(GPU)~200-500ms电费
Cohere Rerank API~300-800ms$1/1000 次
Jina Reranker API~200-600ms$0.15/1M token
Workers AI Rerank~100-300msWorkers AI 计费

优化策略:

  1. 减少候选数量:topN 不要太大,20-50 足够
  2. 截断文档长度:Rerank 只看查询和文档的相关性,不需要看全文——截断到前 500 字符
  3. 并行调用:如果 Rerank 支持批量,用批量接口
  4. 缓存:同一个查询短时间内多次请求,复用 Rerank 结果
async function rerankWithTruncation(
  query: string,
  documents: Array<{ id: string; content: string }>,
  maxLength = 500
): Promise<Array<{ id: string; score: number }>> {
  // 截断文档——Rerank 不需要看全文
  const truncated = documents.map((d) => ({
    ...d,
    content: d.content.slice(0, maxLength),
  }))
 
  return reranker.rerank(query, truncated)
}

5. 什么时候不需要 Rerank

Rerank 不是万能的。以下场景可以跳过:

场景一:检索质量已经足够

如果向量检索 top5 的 Recall@5 已经有 0.9 以上,加 Rerank 的提升有限——不值得增加延迟和成本。

场景二:检索阶段完全没召回

Rerank 只能在已召回的候选里重新排序——如果正确答案不在 top50 里,Rerank 也救不回来。

向量检索 top50 里没有正确答案 → Rerank 无从选择 → 最终结果依然不对

这种情况应该优化 embedding 模型或切分策略,而不是加 Rerank。

场景三:延迟敏感

实时聊天场景,用户期望秒级响应。如果加 Rerank 导致延迟超过 2 秒,体验会变差。

替代方案:

  • 用更快的 Rerank 模型(比如 base 版代替 large 版)
  • 减少 topN(从 50 降到 20)
  • 流式返回——先返回向量检索结果,Rerank 完成后更新排序

6. 统一 Rerank 服务

封装统一的 Rerank 接口:

// 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
 
    // 截断文档长度
    const documents = candidates.map((c) => ({
      id: c.id,
      content: (c.content ?? '').slice(0, maxLength),
    }))
 
    // 调用 Rerank
    const scores = await this.provider.rerank(query, documents)
 
    // 按分数排序
    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)
  }
}

7. Rerank 效果评估

评估 Rerank 是否有效,对比加 Rerank 前后的 Recall:

async function evaluateRerankImpact(
  testCases: Array<{ query: string; relevantDocIds: string[] }>,
  searchFn: (q: string) => Promise<SearchResult[]>,
  searchWithRerankFn: (q: string) => Promise<SearchResult[]>,
  k = 5
): Promise<{ before: number; after: number }> {
  let beforeRecall = 0
  let afterRecall = 0
 
  for (const { query, relevantDocIds } of testCases) {
    const before = await searchFn(query)
    const after = await searchWithRerankFn(query)
 
    const beforeCorrect = before.slice(0, k).filter((r) => relevantDocIds.includes(r.id)).length
    const afterCorrect = after.slice(0, k).filter((r) => relevantDocIds.includes(r.id)).length
 
    beforeRecall += beforeCorrect / k
    afterRecall += afterCorrect / k
  }
 
  return {
    before: beforeRecall / testCases.length,
    after: afterRecall / testCases.length,
  }
}

如果 Rerank 后 Recall 提升 < 5%,可能不值得加——延迟成本高于收益。

总结

回顾这一节的要点:

  • 向量检索是双塔模型,查询和文档独立编码,交互不够深
  • Rerank 是交叉编码器,同时看查询和文档,判断更精细
  • 流程:向量检索 topN → Rerank → 取 topK 进 Prompt
  • 主流模型:Cohere Rerank、BGE Reranker、Jina Reranker、Workers AI
  • Rerank 增加延迟(200ms-5s)和成本——按场景决定是否值得
  • 截断文档长度可以减少延迟
  • Rerank 不能救回完全没召回的情况——embedding 和切分策略才是基础
  • 统一 Rerank 服务屏蔽底层模型差异

下一篇讲上下文拼接——把检索到的 chunk 组装成 Prompt。