2026.08.21

给 RAG 加一层精排:效果提升确实很小,但值得做

召回 Top-20 之后再过一遍 cross-encoder。实测平均名次从 1.40 提到 1.00——幅度不大,收益也不是我一开始以为的那个。

约 3 分钟

检索增强里,"召回 + 精排"是标准的两段式。前一段负责别漏,后一段负责排准。

我给知识库加了第二段:库内用 pgvector 余弦召回 Top-20,再交给 qwen3-rerank 这个 cross-encoder 重排。这篇想如实记录它的收益有多大——比我预期的要小。

两段式为什么成立#

区别在编码方式:

  • 双塔(bi-encoder):query 和 document 各自独立编码成向量,相似度是向量点积。因为 document 向量可以离线算好,所以能对百万级库做近似最近邻。代价是 query 和 document 从未见过彼此。
  • 交叉编码(cross-encoder):把 query 和 document 拼在一起送进模型,直接输出相关性分数。能看到两者的交互,准得多。代价是每对都要跑一次前向,没法预先算。

所以合理的分工是:双塔把候选从百万压到几十,cross-encoder 在这几十个里精挑。这也决定了它的天花板——精排只能重排召回已经找到的东西。召回漏了的,精排再强也变不出来。

接入时踩的坑:两套信封#

qwen3-rerank 有个很容易踩的地方:它有两个端点,返回两种不同的信封。

// 兼容模式:结果平铺
// POST /compatible-mode/v1/rerank
// { results: [ { index: 3, relevance_score: 0.91 }, ... ] }
 
// 原生模式:结果嵌套
// POST /api/v1/services/rerank/text-rerank/text-rerank
// { output: { results: [ { index: 3, relevance_score: 0.91 }, ... ] } }

第一次接的时候照着文档写了解析,跑起来一直返回空数组——因为端点选的是 -mode 那一条,实际提供的却是 api 那一条,两者信封不同。这类问题的排查成本不低:不报错,只是结果为空。

现在的做法是解析时两种形状都认,并且显式断言 results 是数组:

const raw = body?.output?.results ?? body?.results;
if (!Array.isArray(raw)) {
  throw new Error("rerank: 未识别的响应信封");
}

实测结果#

同一批 query,比较精排前后"正确答案排在第几位":

指标精排前精排后
平均名次1.401.00

三次独立测试都是小幅正向。

我得诚实说:这个提升幅度不大。 原因也不难理解——召回 Top-20 里的候选质量本来就还行,正确答案平均已经排在第 1.4 位了,精排能做的只是把那些"排在第 2、第 3"的 cases 提到第 1。

如果召回质量本身很差(正确答案经常排在第 15、第 20),精排的绝对收益会大得多。精排的价值等于召回的错误率。

那还值不值得做#

值得,但理由和一开始想的不一样:

  1. 它把"库内排序"和"语义相关性"解耦了。pgvector 只能算向量距离,而向量距离不等于相关性。精排这一层给了后续换模型、调策略的空间。
  2. 它是可替换的。召回策略、embedding 模型、精排模型可以各自独立演进,接口只有"给候选、回分数"。
  3. 成本可控。只对 Top-20 跑,不是全库。

但如果有人问我"加了精排效果能提升多少",我会说:取决于你的召回有多不准,而不是取决于精排模型有多强。在召回已经不错的前提下,指望精排带来质变是不现实的。

这也是这次做下来最有价值的一点认知:RAG 的瓶颈通常在上游。切分策略、embedding 模型、查询改写,这些地方改一改,往往比在末端叠一个更强的精排模型管用。