为什么检索"够了"还不够?
RAG系统的召回阶段(向量检索+BM25)追求查全率,返回Top-50甚至Top-100个候选;但LLM上下文有限,塞进去的噪声越多,回答越差。Rerank(重排序)就是第二道精排关卡:用交叉编码器(Cross-Encoder)把"查询-文档"成对打分,只留Top-5喂给LLM。实测在多数业务数据集上,双阶段检索比单阶段向量检索的命中率提升20%-30%。
一、Rerank的核心原理:交叉编码器 vs 双编码器
| 类型 | 代表模型 | 原理 | 速度 | 精度 |
|---|---|---|---|---|
| 双编码器 | bge-m3, text-embedding | 查询和文档各自编码,算向量余弦 | 快(可离线) | 中 |
| 交叉编码器 | bge-reranker-v2-m3 | 查询+文档拼接一起过Transformer | 慢(需在线) | 高 |
交叉编码器能看到查询与文档每个token的交互,所以精度高;但无法预计算向量,只能在线对候选逐条打分。因此正确的架构是:粗召回用双编码器,精排用交叉编码器。
二、最小实现:用FlagEmbedding跑通Rerank
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
query = "Agent如何做记忆管理"
candidates = [
"基于向量的长期记忆存储方案",
"如何给猫咪洗澡",
"对话摘要与滑动窗口的短期记忆实现",
]
scores = reranker.compute_score([[query, c] for c in candidates])
print(scores) # [0.72, 0.05, 0.65] 噪声文档得分显著更低
# 取Top-K
top_k = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:2]
三、生产级Rerank服务:缓存+批量+超时
Rerank是在线推理,必须考虑性能,三个要点:
import time
from functools import lru_cache
@lru_cache(maxsize=2048)
def score_pair(query: str, doc: str) -> float:
return reranker.compute_score([[query, doc]])[0]
def rerank(query: str, docs: list, top_k: int = 5) -> list:
start = time.time()
scored = []
for d in docs:
# 批量计算,单条超时保护
try:
scored.append((d, score_pair(query, d)))
except Exception:
scored.append((d, 0.0)) # 打分失败降级为原始顺序
scored.sort(key=lambda x: x[1], reverse=True)
print(f"rerank {len(docs)} docs cost {time.time()-start:.2f}s")
return [d for d, s in scored[:top_k]]
四、效果评估:别拍脑袋说"变好了"
用离线数据集做A/B对比,指标用Recall@K和MRR:
def recall_at_k(ground_truth, ranked_docs, k=5):
top = set(ranked_docs[:k])
return len(set(ground_truth) & top) / len(set(ground_truth))
# 对比:仅向量检索 vs 向量+Rerank
# 典型结果:Recall@5 从 0.61 -> 0.82,MRR 从 0.42 -> 0.67
五、避坑指南
2. 别对全库Rerank:交叉编码器在线推理慢,必须配合粗召回
3. 阈值过滤:得分低于0.3的文档直接丢弃,防止低质内容混入
4. 监控打分分布:如果分数普遍偏高,说明查询与语料分布漂移,需要重训或换模型
总结:Rerank是RAG链路里性价比最高的一环——不用换Embedding模型、不用改向量库,只加一层精排就能显著提升答案质量。先把粗召回做扎实,再上Rerank,效果立竿见影。