2026.08.21
给 RAG 加一层精排:效果提升确实很小,但值得做
召回 Top-20 之后再过一遍 cross-encoder。实测平均名次从 1.40 提到 1.00——幅度不大,收益也不是我一开始以为的那个。
检索增强里,"召回 + 精排"是标准的两段式。前一段负责别漏,后一段负责排准。
我给知识库加了第二段:库内用 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.40 | 1.00 |
三次独立测试都是小幅正向。
我得诚实说:这个提升幅度不大。 原因也不难理解——召回 Top-20 里的候选质量本来就还行,正确答案平均已经排在第 1.4 位了,精排能做的只是把那些"排在第 2、第 3"的 cases 提到第 1。
如果召回质量本身很差(正确答案经常排在第 15、第 20),精排的绝对收益会大得多。精排的价值等于召回的错误率。
那还值不值得做#
值得,但理由和一开始想的不一样:
- 它把"库内排序"和"语义相关性"解耦了。pgvector 只能算向量距离,而向量距离不等于相关性。精排这一层给了后续换模型、调策略的空间。
- 它是可替换的。召回策略、embedding 模型、精排模型可以各自独立演进,接口只有"给候选、回分数"。
- 成本可控。只对 Top-20 跑,不是全库。
但如果有人问我"加了精排效果能提升多少",我会说:取决于你的召回有多不准,而不是取决于精排模型有多强。在召回已经不错的前提下,指望精排带来质变是不现实的。
这也是这次做下来最有价值的一点认知:RAG 的瓶颈通常在上游。切分策略、embedding 模型、查询改写,这些地方改一改,往往比在末端叠一个更强的精排模型管用。