AI 工程基础体系 · 第 90/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

RAG 重排:Cross Encoder、Late Interaction、候选规模和延迟

RAG(Retrieval-Augmented Generation,检索增强生成)的基本流程是:先从外部知识源检索与问题相关的内容,再把检索结果交给生成模型生成答案。Lewis 等人在 RAG 原始论文中将其描述为“参数化生成模型”与“非参数化外部记忆”的组合;在工程系统中,这通常表现为:

query召回重排上下文组装生成\text{query} \rightarrow \text{召回} \rightarrow \text{重排} \rightarrow \text{上下文组装} \rightarrow \text{生成}

其中,“召回”负责尽量不要漏掉可能有用的文档,“重排”负责在有限候选中提高顺序质量。重排不是生成模型的附属步骤,而是一个独立的相关性估计问题:给定查询 qq 和候选文档或文本块 dd,计算一个更准确的相关性分数 s(q,d)s(q,d),然后按分数排序。

本文重点讨论三件相互制约的事情:

  1. Cross Encoder 如何通过联合编码查询和文档建模细粒度交互;
  2. Late Interaction 如何保留部分交互能力,同时降低在线计算成本;
  3. 候选规模 KK 如何影响召回质量、重排成本、上下文长度和端到端延迟。

1. 重排在 RAG 中到底解决什么问题

1.1 召回和重排优化的是不同目标

最常见的第一阶段召回器是双编码器(Bi-Encoder)。它分别编码查询和文档:

q=Eq(q),d=Ed(d)\mathbf{q}=E_q(q), \qquad \mathbf{d}=E_d(d)

然后使用向量相似度,例如余弦相似度:

sbi(q,d)=qTdqds_{\text{bi}}(q,d) = \frac{\mathbf{q}^{\mathsf T}\mathbf{d}} {\|\mathbf{q}\|\|\mathbf{d}\|}

文档向量可以预先计算并建立 ANN(Approximate Nearest Neighbor,近似最近邻)索引,因此在线查询时只需要编码查询,再在大规模索引中查找近邻。

双编码器的关键优势是:

文档向量可以离线计算\text{文档向量可以离线计算}

但它也有一个结构性限制:查询和文档在进入相似度计算前已经分别压缩为单个向量。模型在编码文档时不知道当前查询,因而难以精确处理以下关系:

  • 查询中的否定词是否改变文档含义;
  • 某个实体是否与查询中的另一个实体发生关系;
  • 查询要求“2024 年以后”,文档是否真的满足时间条件;
  • 查询要求“支持 A 而不是 B”,文档是否明确表达了这个区别;
  • 一段文档中只有某个局部短语与查询高度相关,其他内容是否造成整体向量偏移。

因此,双编码器擅长大规模初筛,但其相似度通常不是最终的精确相关性判断。

1.2 重排不能找回未召回的文档

设第一阶段返回候选集合:

CK(q)={d1,d2,,dK}C_K(q)=\{d_1,d_2,\ldots,d_K\}

重排器只能对 CK(q)C_K(q) 内的文档重新排序:

πrerank(CK(q))\pi_{\text{rerank}}(C_K(q))

如果真正相关的文档 d\*d^\* 不在 CK(q)C_K(q) 中,那么无论重排模型多强,都无法把 d\*d^\* 排到最终结果中:

d\*CK(q)d\*πrerank(CK(q))d^\* \notin C_K(q) \Rightarrow d^\* \notin \pi_{\text{rerank}}(C_K(q))

这条边界非常重要。重排器提高的是候选内部的排序质量,而不是第一阶段的召回上限。

因此,一个合理的 RAG 评测至少要拆成两个问题:

  • 候选召回率 Recall@K:相关文档是否进入候选集合;
  • 重排后的 Precision@k 或 nDCG@k:相关文档是否出现在靠前位置。

如果 Recall@K 很低,继续更换重排模型通常不能解决根因;应该检查分块、查询改写、混合检索、过滤条件或召回模型。


2. Cross Encoder:让查询和文档共同经过模型

2.1 定义和计算过程

Cross Encoder(交叉编码器)把查询和文档拼接为一个输入,由同一个 Transformer 联合编码:

x=[CLS],q,[SEP],d,[SEP]x=[\text{CLS}],q,[\text{SEP}],d,[\text{SEP}]

模型输出一个相关性分数:

scross(q,d)=fθ(x)s_{\text{cross}}(q,d) = f_\theta(x)

在 Transformer 的自注意力层中,查询 token 可以直接关注文档 token,文档 token 也可以关注查询 token。因此,模型能够计算更细粒度的词语、实体和句法交互。

一个简化的注意力公式是:

Attention(Q,K,V)=softmax(QKTd)V\operatorname{Attention}(Q,K,V) = \operatorname{softmax} \left( \frac{QK^{\mathsf T}}{\sqrt{d}} \right)V

其中:

  • QQ 是查询向量;
  • K,VK,V 是所有输入 token 的键和值;
  • dd 是向量维度。

由于查询和文档 token 被放入同一个序列,QKTQK^{\mathsf T} 中会出现跨查询、跨文档的注意力项。这就是 Cross Encoder 能理解“查询中的这个词是否修饰文档中的那个实体”的原因。

2.2 为什么它通常比向量相似度更准确

假设有两个候选:

  • 查询:如何关闭生产环境中的自动部署
  • 文档 A:自动部署可以通过项目设置关闭。
  • 文档 B:生产环境部署流程包括自动构建、自动测试和自动发布。

双编码器可能因为“生产环境、自动部署、部署流程”等词的整体语义接近,而把文档 B 排得很高。Cross Encoder 则可以更明确地判断:

  • 文档 A 包含“关闭”这一操作;
  • 文档 B 只描述部署流程,没有回答“如何关闭”。

其优势不是简单地“理解更多词”,而是能够对查询和文档之间的关系进行条件化计算:

P(相关q,d)P(\text{相关}\mid q,d)

而不是分别计算两个独立表示后再比较:

sim(Eq(q),Ed(d))\operatorname{sim}(E_q(q),E_d(d))

2.3 Cross Encoder 的训练目标

若训练数据包含查询 qq、正例 d+d^+ 和负例 dd^-,可以使用 pairwise ranking loss:

L=logσ(sθ(q,d+)sθ(q,d))\mathcal{L} = -\log \sigma \left( s_\theta(q,d^+)-s_\theta(q,d^-) \right)

其中 σ\sigma 是 sigmoid 函数。这个损失鼓励正例分数高于负例。

也可以对一组候选使用 softmax:

L=logexp(sθ(q,d+))dCexp(sθ(q,d))\mathcal{L} = -\log \frac{\exp(s_\theta(q,d^+))} {\sum_{d\in C}\exp(s_\theta(q,d))}

这里的负例质量非常关键。随机负例通常太容易,模型学不到真正有区分度的边界。更有价值的负例包括:

  • 第一阶段召回器经常排在前面的错误文档;
  • 词面相似但结论相反的文档;
  • 属于相同主题但不回答当前问题的文档;
  • 时间、权限、产品版本不匹配的文档。

这些被称为 hard negatives(困难负例)。不过,困难负例也可能包含实际正例,训练前需要人工或规则复核,否则会把正确答案标成负例。

2.4 Cross Encoder 的计算代价

如果候选数量为 KK,每个候选都要进行一次查询—文档联合编码。设:

  • LqL_q:查询 token 数;
  • LdL_d:文档 token 数;
  • L=Lq+LdL=L_q+L_d:拼接后的序列长度;
  • Cenc(L)C_{\text{enc}}(L):一次模型编码成本。

则总成本近似为:

Ccross(K)=KCenc(L)C_{\text{cross}}(K) = K\cdot C_{\text{enc}}(L)

Transformer 自注意力在朴素实现下对序列长度的注意力计算大致呈 O(L2)O(L^2) 关系,因此文档变长和候选数增加都会增加成本:

Ccross(K,L)KO(L2)C_{\text{cross}}(K,L) \approx K\cdot O(L^2)

实际硬件上的耗时还会受到 batch size、GPU 利用率、padding、模型大小、量化方式和并发队列影响,所以不能仅凭这个公式预测毫秒数。但公式准确表达了两个方向:

  1. 候选数量 KK 增加,推理次数增加;
  2. 单个文档变长,单次推理成本通常非线性增加。

2.5 输入截断是准确性和延迟的共同边界

Cross Encoder 通常有最大输入长度。若:

Lq+Ld>LmaxL_q+L_d>L_{\max}

系统必须截断、滑窗或重新分块。

直接从文档尾部截断,可能丢掉标题、定义或结论;只保留前部,也可能丢掉真正回答问题的段落。更稳妥的方式是:

  1. 在入库时将文档切成较小、语义相对完整的 chunk;
  2. 查询时先召回 chunk,而不是把整篇文档交给重排器;
  3. 对特别长的 chunk,使用标题、父文档摘要和正文片段组合;
  4. 评测截断前后的 Recall@K 和重排指标。

一个常见误解是“把更多上下文一次性给 Cross Encoder 一定更准确”。实际上,过长上下文可能同时带来:

  • 输入截断导致关键信息消失;
  • 无关内容稀释相关信号;
  • 更高的计算和显存成本;
  • 多个主题混在同一 chunk 中,分数解释困难。

3. Late Interaction:不完全独立,也不完全联合

3.1 它位于双编码器和 Cross Encoder 之间

Late Interaction(晚交互)保留查询和文档的 token 级表示,但把交互推迟到编码完成之后。

查询被编码为 token 向量序列:

Eq(q)=[q1,q2,,qm]E_q(q)= [\mathbf{q}_1,\mathbf{q}_2,\ldots,\mathbf{q}_m]

文档被编码为 token 向量序列:

Ed(d)=[d1,d2,,dn]E_d(d)= [\mathbf{d}_1,\mathbf{d}_2,\ldots,\mathbf{d}_n]

以 ColBERT 风格的 MaxSim 为例,相关性分数为:

slate(q,d)=i=1mmax1jnqiTdjs_{\text{late}}(q,d) = \sum_{i=1}^{m} \max_{1\le j\le n} \mathbf{q}_i^{\mathsf T}\mathbf{d}_j

含义是:

  1. 对查询中的每个 token qi\mathbf q_i
  2. 找到文档中与它最相似的 token;
  3. 把这些最大相似度相加。

它与单向量相似度不同,因为查询中的不同 token 可以分别在文档的不同位置找到匹配。它也与 Cross Encoder 不同,因为查询和文档在交互前已经分别编码,交互阶段通常只使用向量相似度和聚合操作,而不是重新进行跨序列 Transformer 注意力。

3.2 一个完整算例

假设查询有三个 token:

q1=“关闭”,q2=“生产环境”,q3=“自动部署”q_1=\text{“关闭”},\quad q_2=\text{“生产环境”},\quad q_3=\text{“自动部署”}

两个候选文档的相似度矩阵如下。行是查询 token,列是文档 token:

SA=[0.900.200.100.100.880.300.200.300.86]S_A= \begin{bmatrix} 0.90 & 0.20 & 0.10\\ 0.10 & 0.88 & 0.30\\ 0.20 & 0.30 & 0.86 \end{bmatrix}

对于文档 A:

slate(q,A)=max(0.90,0.20,0.10)+max(0.10,0.88,0.30)+max(0.20,0.30,0.86)s_{\text{late}}(q,A) = \max(0.90,0.20,0.10) + \max(0.10,0.88,0.30) + \max(0.20,0.30,0.86)

=0.90+0.88+0.86=2.64=0.90+0.88+0.86=2.64

文档 B 的相似度矩阵为:

SB=[0.250.300.200.800.200.300.750.250.40]S_B= \begin{bmatrix} 0.25 & 0.30 & 0.20\\ 0.80 & 0.20 & 0.30\\ 0.75 & 0.25 & 0.40 \end{bmatrix}

则:

slate(q,B)=0.30+0.80+0.75=1.85s_{\text{late}}(q,B) = 0.30+0.80+0.75=1.85

文档 B 对“生产环境”和“自动部署”可能有较强局部匹配,但对“关闭”没有可靠匹配,因此总分低于文档 A。

下面的 NumPy 示例可以直接运行,展示这一计算:

import numpy as np

# 行:查询 token;列:文档 token
sim_a = np.array([
    [0.90, 0.20, 0.10],
    [0.10, 0.88, 0.30],
    [0.20, 0.30, 0.86],
])

sim_b = np.array([
    [0.25, 0.30, 0.20],
    [0.80, 0.20, 0.30],
    [0.75, 0.25, 0.40],
])

def maxsim(similarity_matrix: np.ndarray) -> float:
    # 每个查询 token 在文档中寻找最相似 token,再求和
    return float(np.max(similarity_matrix, axis=1).sum())

print("document A:", maxsim(sim_a))
print("document B:", maxsim(sim_b))

预期输出:

document A: 2.64
document B: 1.85

这个示例省略了真实模型的 token 化、向量归一化和批量计算,但完整表达了 MaxSim 的聚合机制。

3.3 Late Interaction 的索引和在线路径

Late Interaction 的难点是:文档不再只有一个向量,而是一组 token 向量。离线阶段通常需要:

  1. 对每个 chunk 进行 token 化;
  2. 编码每个 token,得到 token embedding;
  3. 对向量做归一化、压缩或量化;
  4. 为 token 向量建立适合的近似检索结构;
  5. 在线接收查询 token 向量;
  6. 进行候选召回或候选打分;
  7. 对每个文档聚合 MaxSim 分数。

文档存储成本近似从单向量的:

O(Nd)O(Nd)

增加到 token 级表示的:

O(Nnˉd)O(N\bar{n}d)

其中:

  • NN 是 chunk 数;
  • dd 是向量维度;
  • nˉ\bar n 是每个 chunk 的平均 token 数。

因此,Late Interaction 并不是“免费获得 Cross Encoder 精度”。它用更大的索引和更复杂的候选计算,换取比单向量检索更细粒度的匹配能力。

实际实现还会对标点、停用词或特殊 token 做处理,并使用向量压缩以降低存储和内存带宽。具体策略取决于模型和索引实现,不能把所有 Late Interaction 系统都等同于某一个固定模型或固定 API。

3.4 Late Interaction 的反例和边界

MaxSim 的聚合方式具有明显假设:查询中的每个 token 都可以独立寻找文档中的最佳匹配。这可能导致“局部词语都匹配,但整体关系错误”。

例如:

  • 查询:支持在 Linux 上运行的数据库
  • 文档:不支持在 Linux 上运行;仅支持 Windows。

如果查询 token 分别与文档中的“Linux”“运行”“数据库”高度匹配,MaxSim 可能给出较高分,但它未必充分理解“不支持”这一否定关系。

再如:

  • 查询:A 收购 B 的时间
  • 文档:B 收购 A 的时间

实体词和“收购”“时间”都匹配,但主客体关系反了。

这类问题说明:

  • Late Interaction 不是语义逻辑的完整替代品;
  • 需要在训练集中加入否定、主客体交换和条件不满足的 hard negatives;
  • 对强关系、时间、数值和权限问题,可能仍需要 Cross Encoder、规则校验或结构化过滤。

4. 三种相关性计算方式的结构差异

可以把三种方式写成同一张表:

方法 文档离线表示 查询—文档交互时机 在线计算 主要优点 主要限制
双编码器 一个文档向量 最后用向量相似度 适合大规模 ANN 交互粗粒度
Late Interaction 一组文档 token 向量 编码后逐 token 交互 保留局部匹配,优于单向量 索引大,关系建模有限
Cross Encoder 通常不独立缓存最终文档表示 编码过程中联合交互 相关性判断细致 每个候选都要联合推理

这里的“优于”不能理解为绝对排序。模型训练数据、领域、分块方式和评测任务都会影响结果。一个在通用问答数据上训练的 Cross Encoder,可能不如针对企业内部术语训练的 Late Interaction 模型。

更准确的系统描述是:

  • 双编码器或 Late Interaction 负责扩大候选覆盖;
  • Cross Encoder 负责在较小候选集合中进行精确判别;
  • 生成模型只应接收经过过滤和排序的上下文。

5. 候选规模 KK:为什么不是越大越好

5.1 候选规模的定义

设第一阶段召回返回前 KK 个候选:

CK(q)=TopKdDsretrieve(q,d)C_K(q)=\operatorname{TopK}_{d\in D} s_{\text{retrieve}}(q,d)

之后重排器计算:

srerank(q,d),dCK(q)s_{\text{rerank}}(q,d),\quad d\in C_K(q)

最后选取前 kk 个上下文:

Rk(q)=TopKdCK(q)srerank(q,d)R_k(q) = \operatorname{TopK}_{d\in C_K(q)} s_{\text{rerank}}(q,d)

通常 KkK\ge k。例如,第一阶段召回 50 个 chunk,Cross Encoder 对这 50 个候选打分,再将其中 5 个交给生成模型。

5.2 增大 KK 的收益

假设相关文档在第一阶段召回排名中的位置是随机变量 RR,则候选覆盖概率为:

Recall@K=P(RK)\operatorname{Recall@K} = P(R\le K)

随着 KK 增加,Recall@K 单调不下降:

K2>K1Recall@K2Recall@K1K_2>K_1 \Rightarrow \operatorname{Recall@K_2} \ge \operatorname{Recall@K_1}

因此,当相关文档经常排在第 20 到第 50 位时,使用 K=10K=10 的重排器根本看不到它们。

一个具体例子:

第一阶段排名 查询数量 其中包含相关文档的查询数
Top 5 100 72
Top 20 100 91
Top 50 100 96

则:

Recall@5=0.72,Recall@20=0.91,Recall@50=0.96\operatorname{Recall@5}=0.72,\quad \operatorname{Recall@20}=0.91,\quad \operatorname{Recall@50}=0.96

如果最终只需要 5 个上下文,重排 Top 50 可能把位于第 20 位的相关文档提到前面。

5.3 增大 KK 的代价

对于 Cross Encoder,候选数量几乎直接增加推理工作量:

Trerank(K)KBtbatch+tqueue+ttransferT_{\text{rerank}}(K) \approx \left\lceil\frac{K}{B}\right\rceil t_{\text{batch}} + t_{\text{queue}} + t_{\text{transfer}}

其中:

  • BB 是每个推理 batch 的候选数;
  • tbatcht_{\text{batch}} 是处理一个 batch 的时间;
  • tqueuet_{\text{queue}} 是等待推理资源的时间;
  • ttransfert_{\text{transfer}} 是数据传输和序列化时间。

例如,某系统一次 batch 最多处理 8 个候选,单个 batch 的模型耗时约为 12 ms。这里只做说明性计算:

  • K=8K=8:约需 11 个 batch,模型部分约 12 ms;
  • K=32K=32:约需 44 个 batch,模型部分约 48 ms;
  • K=64K=64:约需 88 个 batch,模型部分约 96 ms。

这不是通用性能承诺,因为真实耗时还受动态 batching、padding、GPU 利用率和并发影响,但它说明了候选规模对延迟的基本方向。

候选太大还会造成第二类代价:最终上下文竞争。若直接将大量候选拼接给生成模型,则输入 token 数增加:

Lprompt=Lsystem+Lq+dRkLdL_{\text{prompt}} = L_{\text{system}} + L_q + \sum_{d\in R_k}L_d

这会增加生成模型的首 token 延迟、输入 token 成本和上下文噪声。重排阶段的 KK 和最终送入生成模型的 kk 不是同一个参数,不能因为想扩大候选召回就把所有候选都放进 prompt。

5.4 候选规模的反例:更大的 KK 可能降低最终质量

设重排器有误判概率,并且候选越多,越可能出现“高分但错误”的文档。若最终只取前 kk 个,增加 KK 会引入更多竞争项。

例如:

  • K=10K=10 时,候选中包含 1 个相关文档,重排器将其排第 1;
  • K=100K=100 时,额外出现许多术语高度相似但结论相反的文档;
  • 重排器被这些 hard negatives 干扰,把真正相关文档排到第 8;
  • 最终只取 k=5k=5,相关文档反而被丢弃。

因此,KK 的收益依赖于重排器的判别能力。理想情况下:

Recall@KnDCG@krerank 不下降\operatorname{Recall@K} \uparrow \quad\text{且}\quad \operatorname{nDCG@k}_{\text{rerank}} \text{ 不下降}

但实际系统中二者可能发生权衡。不能只看 Recall@K,也不能只看重排后的 Top 1。


6. 延迟应该按数据流拆解

RAG 的端到端延迟可以近似拆为:

Te2e=Tquery+Tfilter+Tretrieve+Trerank+Tcontext+TgenerateT_{\text{e2e}} = T_{\text{query}} + T_{\text{filter}} + T_{\text{retrieve}} + T_{\text{rerank}} + T_{\text{context}} + T_{\text{generate}}

其中:

  • TqueryT_{\text{query}}:查询改写、查询向量编码或 token 编码;
  • TfilterT_{\text{filter}}:租户、权限、时间、文档状态等过滤;
  • TretrieveT_{\text{retrieve}}:向量、关键词或混合检索;
  • TrerankT_{\text{rerank}}:Cross Encoder 或 Late Interaction 打分;
  • TcontextT_{\text{context}}:去重、截断、邻块扩展和 prompt 组装;
  • TgenerateT_{\text{generate}}:生成模型的首 token 和后续 token 延迟。

若各阶段串行,则:

Te2eiTiT_{\text{e2e}}\approx\sum_i T_i

若稠密检索、关键词检索和部分过滤可以并行,则:

Tretrievemax(Tdense,Tlexical)+TmergeT_{\text{retrieve}} \approx \max(T_{\text{dense}},T_{\text{lexical}}) + T_{\text{merge}}

但是,Cross Encoder 的重排通常必须等待候选集合形成,因此不能简单地和召回完全并行。

6.1 平均延迟不等于用户体验

生产系统通常还需要观察 p50、p95 和 p99。候选规模增加时,平均延迟可能只略有变化,但尾延迟会明显上升,原因包括:

  • 某些请求的候选 chunk 更长;
  • 动态 batch 等待时间增加;
  • GPU 推理队列拥塞;
  • 某些租户的过滤结果过多;
  • 生成模型上下文长度出现长尾;
  • 重试导致请求叠加。

如果一个服务要求 p95 小于某阈值,就不能只用平均 KK 估算;应记录每次请求实际的:

  • 召回候选数;
  • 重排候选数;
  • 每个候选 token 长度;
  • batch size;
  • 排队时间;
  • 模型推理时间;
  • 最终 prompt token 数;
  • 生成 token 数。

6.2 并发和背压

重排服务往往是共享 GPU 或 CPU 资源。设同时到达的查询数为 λ\lambda,每个查询平均需要处理 KK 个候选,单候选服务能力为 μ\mu,则候选级工作量随 KK 增加:

loadλK\text{load}\propto \lambda K

当到达速率接近服务能力时,队列等待时间会急剧增加。此时继续扩大 KK 可能形成反馈:

K推理工作量队列变长Tqueue超时和重试K\uparrow \Rightarrow \text{推理工作量}\uparrow \Rightarrow \text{队列变长} \Rightarrow T_{\text{queue}}\uparrow \Rightarrow \text{超时和重试}\uparrow

重试又进一步增加负载。

因此,重排服务需要明确:

  • 最大候选数;
  • 单请求最大输入 token;
  • batch 最大 token 数,而不只是最大样本数;
  • 队列超时;
  • 服务过载时的降级策略;
  • 生成服务是否允许继续使用未重排候选。

一个安全的降级路径通常是:超时后使用已经完成的部分结果,或者回退到第一阶段排序;不应在权限检查未完成时返回任何文档内容。


7. 一个可观测的 RAG 重排流程

下面是一个与具体框架无关的端到端伪代码。它强调状态变化和边界,而不是某个特定 API:

def answer(query, tenant_id, user_id):
    # 1. 查询处理
    normalized_query = normalize_query(query)

    # 2. 权限和租户约束必须进入检索条件
    access_scope = load_access_scope(tenant_id, user_id)

    # 3. 第一阶段召回:扩大候选覆盖
    dense_hits = dense_retrieve(
        normalized_query,
        scope=access_scope,
        top_k=100,
    )
    lexical_hits = lexical_retrieve(
        normalized_query,
        scope=access_scope,
        top_k=100,
    )

    # 4. 合并、去重和基础过滤
    candidates = merge_and_deduplicate(dense_hits, lexical_hits)
    candidates = filter_active_and_authorized(candidates, access_scope)

    # 5. 控制重排规模,避免无界增长
    candidates = candidates[:80]

    # 6. 精细重排
    reranked = cross_encoder_rerank(
        query=normalized_query,
        documents=candidates,
        max_document_tokens=400,
    )

    # 7. 组装最终上下文,而不是把所有候选都交给生成模型
    context = select_context(
        reranked,
        max_chunks=6,
        max_context_tokens=5000,
        remove_near_duplicates=True,
    )

    # 8. 生成答案,并保留来源标识
    return generate_with_citations(
        query=normalized_query,
        context=context,
    )

每一步有不同的失败含义:

  • 第一阶段召回为空:可能是索引故障、过滤过严或查询不匹配;
  • 召回有结果但重排为空:可能是候选结构、模型输入或 token 截断错误;
  • 重排成功但上下文为空:可能是去重、阈值或 token 预算配置问题;
  • 上下文正确但答案错误:应检查生成模型是否遵循来源,而不是继续盲目调大 KK

在日志中应至少保留文档 ID、版本 ID、分数、排名、过滤原因和模型版本,而不应默认记录未经脱敏的全文内容。权限边界要求尤其严格:重排器不能看到用户无权访问的文档正文,否则即使最终没有返回该文档,系统也已经发生了越权数据处理。权限过滤应尽可能在召回索引层完成,并在重排前再次校验。


8. 混合检索和重排的关系

单一向量检索对术语、编号、错误码、产品版本和精确短语可能不稳定。关键词检索则擅长词面匹配,但难以处理同义表达。因此常见架构会并行使用:

C=Merge(Cdense,Clexical)C= \operatorname{Merge} \left( C_{\text{dense}}, C_{\text{lexical}} \right)

合并后可以使用 Reciprocal Rank Fusion(倒数排名融合):

RRF(d)=lL1c+rankl(d)\operatorname{RRF}(d) = \sum_{l\in L} \frac{1}{c+\operatorname{rank}_l(d)}

其中:

  • LL 是检索列表集合;
  • rankl(d)\operatorname{rank}_l(d) 是文档 dd 在列表 ll 中的排名;
  • cc 是防止头部排名权重过大的常数。

RRF 的作用是形成较好的候选集合,不等同于 Cross Encoder 重排。一个典型的分层流程是:

关键词/向量召回融合Late Interaction 或 Cross Encoder上下文选择\text{关键词/向量召回} \rightarrow \text{融合} \rightarrow \text{Late Interaction 或 Cross Encoder} \rightarrow \text{上下文选择}

如果直接把不同检索器的原始分数相加,通常不可靠,因为向量余弦分数、BM25 分数和模型 logits 的数值尺度没有天然可比性。应先校准、归一化或使用排名融合。


9. 如何选择 Cross Encoder、Late Interaction 和候选规模

9.1 适合 Cross Encoder 的情况

Cross Encoder 更适合:

  • 候选规模已经较小;
  • 查询和文档之间需要精确关系判断;
  • 领域中存在大量相似但结论不同的文档;
  • 需要判断否定、条件、时间和数值;
  • 可以接受额外的 GPU 或 CPU 推理成本。

常见做法是先召回 KK 个候选,再只用 Cross Encoder 处理这些候选,而不是对整个语料库逐文档运行。

9.2 适合 Late Interaction 的情况

Late Interaction 更适合:

  • 语料库较大,单向量表示损失明显;
  • 希望在线保留 token 级匹配;
  • 可以承担更大的索引存储和构建成本;
  • 需要在较大的候选范围内提高局部语义匹配能力;
  • 延迟预算不能支持对大量候选运行 Cross Encoder。

它也适合作为第一阶段或中间阶段检索器,再由 Cross Encoder 对更小的候选集做最后判别。

9.3 候选规模的选择方法

不应先凭经验固定 KK,再观察系统是否变慢;应对多个 KK 做离线和在线联合评测。例如测试:

K{10,20,50,100}K\in\{10,20,50,100\}

分别记录:

  • Recall@K;
  • 重排后的 nDCG@5、MRR@5;
  • 最终上下文命中率;
  • 答案引用正确率;
  • p50、p95、p99 延迟;
  • 每次请求的推理 token 数;
  • 单请求和每租户成本。

一个简单的决策过程是:

  1. 找到 Recall@K 开始趋于饱和的位置;
  2. 检查重排后的 Top kk 是否继续改善;
  3. 检查 KK 增加是否显著恶化尾延迟;
  4. 在满足质量门槛的配置中选择成本最低者。

例如,如果 Recall@20 为 0.91,Recall@50 为 0.96,但最终答案引用正确率只提高 0.5 个百分点,而 p95 延迟增加一倍,那么 K=50K=50 未必合理。相反,如果相关文档常位于第 30 到 50 位,且重排器能稳定把它们提升到前 5,则增加 KK 可能值得。


10. 评测必须区分“找得到”和“排得准”

10.1 召回指标

对于每个查询,设人工标注的相关文档集合为 GqG_q,系统召回前 KK 个集合为 CK(q)C_K(q),则:

Recall@K=GqCK(q)Gq\operatorname{Recall@K} = \frac{|G_q\cap C_K(q)|}{|G_q|}

如果一个问题有多个可接受来源,分母不应机械地只设为一个;数据集需要定义相关性等级和可接受证据范围。

10.2 重排指标

若只关心前几个结果,可以使用 MRR:

MRR=1QqQ1rankq\operatorname{MRR} = \frac{1}{|Q|} \sum_{q\in Q} \frac{1}{\operatorname{rank}_q}

其中 rankq\operatorname{rank}_q 是第一个相关结果的排名。

若相关性有等级,例如:

  • 2:直接回答问题;
  • 1:部分有用;
  • 0:无关;

则可使用 nDCG@k。它不仅考虑相关文档是否出现,还考虑是否出现在更靠前的位置。

10.3 RAG 还需要端到端指标

检索指标不能直接等价于答案质量。一个文档被排到第 1 名,不代表生成模型一定正确使用它。端到端评测还应检查:

  • 答案是否被来源支持;
  • 引用是否指向真正支持结论的 chunk;
  • 是否混入权限外信息;
  • 是否正确处理时间和版本;
  • 多跳问题的多个必要证据是否同时进入上下文;
  • 文档冲突时是否识别冲突而非随意拼接。

特别是多跳问题中,单一“最相关 chunk”可能不足。此时上下文选择需要覆盖多个证据,而不是简单取重排 Top kk


11. 常见误解和失败诊断

11.1 “用了 Cross Encoder,召回率就会提高”

错误。Cross Encoder 只能重新排序已有候选。诊断方法是同时查看:

Recall@Kbefore rerank\operatorname{Recall@K}_{\text{before rerank}}

和:

nDCG@kafter rerank\operatorname{nDCG@k}_{\text{after rerank}}

如果前者低,问题在召回或数据;如果前者高而后者低,才主要怀疑重排模型、训练数据、输入截断或分数使用方式。

11.2 “候选越多,答案越可靠”

错误。候选增加可能提高覆盖,也可能带来更多近似错误、上下文噪声和模型误判。尤其在最终 prompt 中无差别放入大量候选时,生成模型可能:

  • 选择排名较低但措辞更强的错误段落;
  • 把不同版本的结论混在一起;
  • 受到相互矛盾的来源影响;
  • 产生看似有引用但引用并不支持结论的答案。

11.3 “Late Interaction 就等于 Cross Encoder 的低成本版本”

不准确。Late Interaction 的 token 级相似度确实比单向量更细致,但它通常缺少 Cross Encoder 在联合注意力中建模复杂关系的能力。它们的训练目标、索引方式和错误模式不同。

11.4 “只调模型,不检查 chunk”

如果一个 chunk 同时包含多个主题,任何重排器都可能遇到困难。诊断时应检查:

  • 相关答案是否跨 chunk;
  • 标题是否被丢失;
  • chunk 是否在句子中间截断;
  • 文档版本是否混合;
  • 代码、表格和自然语言是否使用了不合适的切分方式;
  • 同一父文档的邻近 chunk 是否需要一起扩展。

RAG 指南通常把检索、过滤、上下文组织视为一体;重排器不能修复已经损坏的文档边界。

11.5 “分数阈值在所有查询上都一样”

不同查询的分数分布可能不同。一个短查询、一个长查询、一个包含罕见术语的查询,其分数不一定可直接比较。固定阈值可能导致:

  • 某些查询没有任何结果;
  • 某些查询保留过多低质量结果;
  • 不同模型版本上线后阈值失效。

更可靠的做法是结合排名、分数分布、查询类型和离线校准评测。若使用模型 logits,不应把它们未经校准地解释为概率。


12. 生产系统中的版本、权限和成本

12.1 模型和索引必须成套版本化

文档 token embedding 由某个 Late Interaction 模型生成时,查询也必须使用兼容的查询编码器。更换模型后,旧文档向量通常不能直接与新查询向量混用。

应至少记录:

  • embedding 模型版本;
  • Late Interaction 模型版本;
  • Cross Encoder 版本;
  • 文档内容版本;
  • chunk 规则版本;
  • 索引构建时间;
  • 过滤字段版本;
  • 评测数据版本。

否则发生结果退化时,无法判断是模型变化、文档变化还是分块变化。

12.2 权限过滤不是重排分数

权限是硬约束,不是相关性特征。设用户可访问文档集合为 DuD_u,则检索和重排的有效空间应是:

Du={dauthorized(u,d)=1}D_u=\{d\mid \operatorname{authorized}(u,d)=1\}

而不是先在全库中取 Top KK,再希望重排器把无权文档排到后面。无权文档即使相关性最高,也不得进入用户可见结果。

权限变更还会造成缓存问题:如果缓存键只有查询文本,那么同一个查询可能把一个用户的结果错误返回给另一个用户。缓存键至少需要包含租户、用户权限范围或权限快照版本。

12.3 成本来自多个维度

重排成本不只有模型调用费用,还包括:

  • Late Interaction token 级索引的存储;
  • 索引构建和增量更新;
  • Cross Encoder 的在线算力;
  • 生成模型新增的上下文 token;
  • 超时和重试造成的额外计算;
  • 日志、评测和回放存储。

因此可以使用不同成本层次的级联:

便宜召回Late Interaction小规模 Cross Encoder生成\text{便宜召回} \rightarrow \text{Late Interaction} \rightarrow \text{小规模 Cross Encoder} \rightarrow \text{生成}

但每一级都需要用实际评测证明它带来的质量增益,不能因为结构更复杂就假设一定更好。


13. 一个可执行的调参实验设计

可以用固定评测集比较候选规模和重排器。每个查询都应有相关性标注,并保存第一阶段完整排名,而不是只保存最终结果。

实验变量例如:

  • 召回器:稠密、关键词、混合;
  • 候选数:K=10,20,50,100K=10,20,50,100
  • 重排器:无重排、Late Interaction、Cross Encoder;
  • 最终上下文数:k=3,5,8k=3,5,8
  • 最大 chunk token 数;
  • 是否加入邻近 chunk。

每个配置运行同一批查询,记录:

query_id
retriever_version
reranker_version
candidate_k
final_k
retrieval_recall_at_k
reranked_ndcg_at_5
answer_support_rate
p50_latency_ms
p95_latency_ms
input_tokens
reranker_compute_cost

评测时要特别保留“相关文档未进入候选”的案例。若只分析重排成功看到的候选,会产生幸存者偏差,把召回问题误判成重排问题。

最终配置应满足一个明确条件,例如:

Recall@Kr0\text{Recall@K}\ge r_0

AnswerSupportRatea0\text{AnswerSupportRate}\ge a_0

p95(Te2e)t0p95(T_{\text{e2e}})\le t_0

然后在满足质量和延迟约束的候选中最小化成本,而不是单独追求某一个指标。


结语

Cross Encoder、Late Interaction 和双编码器的根本差异,在于查询—文档交互发生在哪里:

  • 双编码器在各自编码后才进行单向量相似度比较;
  • Late Interaction 在独立编码后进行 token 级交互;
  • Cross Encoder 在联合 Transformer 编码过程中进行跨序列交互。

交互越细,通常越有机会提高相关性判断能力,但在线计算、索引成本或延迟也会增加。候选规模 KK 则连接了召回质量与重排成本:

KRecall@K(通常)K\uparrow \Rightarrow \operatorname{Recall@K}\uparrow\text{(通常)}

但同时:

Trerank,Tprompt,噪声和尾延迟可能上升T_{\text{rerank}}\uparrow,\quad T_{\text{prompt}}\uparrow,\quad \text{噪声和尾延迟可能上升}

所以重排系统的正确设计不是“选择最强模型”或“把候选调到最大”,而是先保证相关文档能进入候选,再用合适的交互结构改善排序,最后根据质量、p95 延迟、权限边界和单位请求成本确定候选预算。RAG 的检索质量最终取决于这条完整链路,而不是某一个重排模型的离线分数。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。