主题
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-encoder | Cross-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 Rerank | BGE Reranker | Jina Reranker | Workers AI Rerank |
|---|---|---|---|---|
| 模型 | rerank-multilingual-v3.0 | bge-reranker-v2-m3 | jina-reranker-v2-base-multilingual | bge-reranker-base |
| 部署 | API | 本地 / API | API | Cloudflare 边缘 |
| 多语言 | 强(中英文均优) | 强(m3 版多语言) | 中上 | 中 |
| 分数范围 | 0-1,可跨查询比较 | 原始 logits,需 softmax | 0-1 | 0-1 |
| 50 文档延迟 | 300-800ms | CPU: 3-5s / GPU: 200-500ms | 200-600ms | 100-300ms |
| 成本 | $1/1000 次搜索 | 免费(自部署) | $0.15/1M token | Workers 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,
}))
},
}
}业务层只需要知道 RerankService 和 RerankProvider 接口。切换底层模型(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 API | 300-800ms | ~$0.001/次 |
| Jina Reranker API | 200-600ms | ~$0.0002/次 |
| Workers AI Rerank | 100-300ms | Workers AI 计费 |
四个优化手段,按收益排序:
- 截断文档长度——Rerank 判断相关性不需要看全文,截断到 512 字符可以减少 40-60% 延迟。这是性价比最高的优化
- 控制候选数量——topN 不要超过 50,20-30 在多数场景已经够用
- 批量接口——Cohere 和 Jina 都支持一次传入多个文档,比逐条调用快很多
- 结果缓存——同一查询短时间内重复请求时,复用 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)要解决的问题。