向量检索的问题不是"不准",而是它用一次相似度计算替你做完了整个判断。把一段几百字的文档压成一个定长向量,再和问题算一个余弦距离,这个过程中必然会丢信息:否定词被平均掉了、数字与专有名词被"语义相近"糊过去、长文档里只出现一次的关键句被整体稀释。
重排(rerank)就是给这一步加一道复核:召回放宽,再用一个更贵但更准的模型精挑。
1. 为什么只靠向量检索不够
| 问题 | 机制原因 | 典型错例 |
|---|---|---|
| 单向量表达有损 | 整段文本压成一个点,细节被平均 | 长文档里一句关键限制被淹没 |
| 长文本相似度失真 | 向量对长度敏感,短文更容易高分 | 短摘要挤掉真正详细的那篇 |
| 否定与条件难区分 | "支持退款"与"不支持退款"向量极近 | 召回结论完全相反的两条 |
| 专有名词/编号 | 语义相近但实体不同 | 订单 A12 与 订单 A21 |
| 同义改写 | 向量擅长,但排序不分层 | 十条都相关,无法排优先级 |
| 多条件组合 | 单向量无法表达"既要又要" | 只命中其中一个条件 |
关键词检索(BM25)能补一部分(编号、精确词),但对"语义相关但字面不同"无能为力。所以不是换掉向量,而是加一层能同时看 query 和 doc 的判断。
2. 两阶段架构
用户问题
│
├─ 阶段一 召回(宽):向量 / BM25 / 混合 → topK 50~200
│ 目标:不漏(recall 优先)
│
└─ 阶段二 重排(精):Cross-Encoder 逐对打分 → topN 3~8
目标:排对(precision 优先)
| 阶段 | 模型形态 | 输入 | 耗时特征 | 调参重点 |
|---|---|---|---|---|
| 召回 | Bi-Encoder / 倒排 | query 与 doc 分别编码 | 可预计算,与库大小近似无关 | topK 别太小 |
| 重排 | Cross-Encoder | (query, doc) 拼在一起 |
与候选数线性相关 | 候选数与阈值 |
架构上最关键的一个数字是召回的 topK。 重排只能从候选里挑,候选里没有正确答案,重排再强也救不回来。这一点下面还会再提。
3. Cross-Encoder 与 Bi-Encoder 的机制差异
| 维度 | Bi-Encoder(召回) | Cross-Encoder(重排) |
|---|---|---|
| 编码方式 | query、doc 各自独立编码 | 拼成一个序列联合编码 |
| 是否交互 | 只在最后算向量距离 | 每个 token 之间全交互 |
| 能否预计算 | 能,doc 向量可离线建库 | 不能,每次都要现算 |
| 精度 | 中 | 高 |
| 延迟 | 低(一次向量检索) | 高(候选数 × 一次前向) |
| 规模上限 | 百万级文档 | 几十到几百条候选 |
# 机制示意:Cross-Encoder 的输入是拼接后的序列
pair = f"{query} [SEP] {doc_text}"
score = model.predict(pair) # 输出一个相关性分数
这也解释了为什么重排不能替代召回:它没法预计算索引。百万文档逐对打分,成本不可接受。
最小可用的两段式调用,把召回与重排拼成一个函数:
def retrieve_and_rerank(query, top_k=100, top_n=5, thr=0.0):
cands = hybrid_search(query, top_k) # 阶段一:宽召回
pairs = [(query, c["text"][:2000]) for c in cands]
scores = reranker.predict(pairs) # 阶段二:逐对打分
ranked = sorted(zip(cands, scores), key=lambda x: -x[1])
return [c for c, s in ranked if s >= thr][:top_n]
4. 输入输出与阈值
| 项 | 说明 | 实践要点 |
|---|---|---|
| 输入 | (query, passage) 对 |
passage 要截断,超长部分会被丢弃 |
| 输出 | 相关性分数 | 通常是 logit 或归一化分,不是概率 |
| 排序 | 按分数降序 | 同分时用召回分或时间做二级排序 |
| 阈值 | 低于阈值视为不相关 | 必须用自有数据校准,不能照搬默认值 |
| 截断 | 每条候选的最大长度 | 截断位置错了会丢掉关键句 |
分数不能当置信度直接对外。 一个 0.82 的重排分,含义是"这条比那条更相关",不是"有 82% 概率能回答问题"。两者差得很远,把前者当后者会导致"高置信度但答错"。
阈值校准的做法很朴素:拿一批标注过的样本,画出分数分布,选一个能同时满足"该拒的拒掉、该留的留住"的位置。
def gate(results, thr: float, top_n: int):
kept = [r for r in results if r["score"] >= thr]
if not kept:
return [] # 全部低于阈值 → 走"无法回答"分支
return sorted(kept, key=lambda r: -r["score"])[:top_n]
"全部低于阈值"这个分支必须显式处理,否则系统会硬着头皮用最不相关的那条去生成,产生看起来很像真的胡说。
5. 可选的重排模型类别
这里只讲类别与选型维度,具体型号与规模按当前版本核对:
| 类别 | 形态 | 适合 | 选型要看 |
|---|---|---|---|
开源重排模型系列(如 bge-reranker 一类) |
可本地部署,有不同参数规模 | 数据不出内网、成本敏感 | 语言覆盖、最大长度、显存占用 |
| 托管重排 API(如 Cohere Rerank 一类) | 一次调用返回排序结果 | 不想运维、候选数不大 | 多语言效果、单价、并发限制 |
| 通用大模型打分 | 用 prompt 让 LLM 给相关性分 | 需要解释理由、候选很少 | 稳定性、成本、延迟 |
| 自建微调 | 在自己的标注数据上训练 | 领域术语重、通用模型效果差 | 标注量、过拟合、迭代成本 |
选型的四个维度,按优先级排:
- 语言与领域匹配:中文/中英混排效果差别很大,先小样本试;
- 最大输入长度:文档块比模型上限长,就会被截断;
- 延迟与吞吐:本地小模型单条几十毫秒,API 多一次网络往返;
- 可运维性:本地要管显存与扩缩容,API 要管配额与降级。
6. 部署与延迟
| 方案 | 单次延迟量级 | 吞吐扩展方式 | 注意 |
|---|---|---|---|
| 本地小模型(CPU) | 数十毫秒/条 | 加进程、批量 | 候选多时线性叠加 |
| 本地模型(GPU) | 更低的单条延迟 | 批处理 + 多卡 | 显存决定 batch 上限 |
| 托管 API | 含网络往返 | 并发请求 | 要限流,避免拖垮整体 |
| 大模型打分 | 最高 | 并发 | 只适合候选极少 |
批量重排的吞吐技巧,按收益排序:
| 技巧 | 效果 | 代价 |
|---|---|---|
| 先把候选截断到固定长度 | 减少无效计算 | 可能丢尾部关键句 |
| 动态 batch(按长度分桶) | 显著提升 GPU 利用率 | 实现复杂度上升 |
| 两段式:先粗筛再精排 | 减少精排条数 | 多一层模型 |
缓存 (query, doc) 分数 |
高频 query 命中 | 缓存键设计要稳 |
| 降 topK 到刚好够用 | 线性降低耗时 | 召回风险 |
延迟预算的算法应该是倒推的:总预算减去生成耗时与网络开销,剩下的才是重排可用的时间;用它除以单条耗时,得到能精排的候选条数上限。
7. 怎么量化重排的收益
| 指标 | 衡量什么 | 怎么算 |
|---|---|---|
| recall@k(召回阶段) | 正确答案是否进了候选 | 标注集上统计命中率 |
| nDCG@n / MRR | 排序质量 | 需要相关性等级标注 |
| 端到端答案正确率 | 最终用户感知 | 同一份问题集,人工或模型判分 |
| 拒答准确率 | 该拒的是否拒了 | 负样本集上的通过率 |
| P95 延迟 | 体验 | 压测或线上采样 |
| 单位查询成本 | 经济性 | token/调用次数折算 |
def compare(baseline, reranked, labeled):
"""在同一份标注集上对比:只看排序变化是否真的帮到最终答案"""
out = {"recall@k": 0, "ndcg@n": 0, "answer_ok": 0}
for q, gold in labeled.items():
b = baseline[q][:5]
r = reranked[q][:5]
out["recall@k"] += int(any(d in gold for d in r)) / len(labeled)
out["ndcg@n"] += ndcg(r, gold) / len(labeled)
return out
对比实验有三条纪律:同一份数据集、同一个生成模型、同一套 prompt。只换重排,其他全固定,否则测出来的差异归因不到重排头上。
8. 什么时候不需要重排
| 情况 | 判断依据 |
|---|---|
| 召回本来就准 | 标注集上 recall@5 已经很高,重排提升空间小 |
| 延迟预算极紧 | 倒推出来的候选条数小于 10,重排收益有限 |
| 库很小且结构化 | 几百条 FAQ,关键词匹配就够 |
| 查询以精确匹配为主 | 订单号、错误码查询,BM25 更合适 |
| 生成阶段会自行过滤 | 上下文窗口足够大且模型能忽略噪声 |
反过来,这些情况重排收益最明显:文档块长且内容杂、query 含多个条件、领域术语多、召回用混合检索且分数不可比。
9. 排错:七种"加了重排反而更差"
| 现象 | 原因 | 处理 |
|---|---|---|
| 重排后正确答案消失 | 召回 topK 太小,候选里本来没有 | 提高 topK 再重排 |
| 排序和召回分完全相反 | 两套分词/归一化口径不同 | 统一预处理,别一边去停用词一边不去 |
| 长文档总是排后面 | 输入被截断到很短 | 换更大长度的模型,或先切块 |
| 中文效果差 | 模型主要面向英文 | 换多语言/中文模型,小样本验证 |
| 阈值一设就全被过滤 | 用了别人的默认阈值 | 在自有标注集上重新校准 |
| P95 延迟翻了几倍 | 候选数 × 单条耗时没算进预算 | 降 topK、加 batch、加缓存 |
| 分数当置信度对外 | 把排序分解释成概率 | 单独训练校准层,或干脆不对外 |
10. 边界:什么时候重排不是答案
| 场景 | 该做什么 | 理由 |
|---|---|---|
| 召回完全跑偏 | 先修召回(分块、嵌入、混合检索) | 重排救不了没进候选的答案 |
| 文档切块切坏了 | 调分块策略 | 块内语义不完整,任何模型都判不准 |
| 需要多跳推理 | 用查询改写/多轮检索 | 单轮重排解决不了缺信息 |
| 库只有几十条 | 直接全量喂给模型 | 重排是多余的中间层 |
| 对可解释性要求极高 | 规则 + 关键词加权 | 重排分数难解释 |
一句话定位:重排是把"召回够宽"变成"排序够准"的那一步。它提升的是排序质量,不是召回质量——这两件事经常被混为一谈。
11. 自测
- 向量检索为什么难以区分"支持退款"与"不支持退款"?
- Cross-Encoder 为什么不能替代召回阶段?
- 重排的 topK 设得太小会发生什么?
- 重排分数为什么不能直接当置信度对外?
- 量化重排收益时,为什么要固定生成模型与 prompt?
- 什么情况下加一层重排反而没有意义?