系列「企业级 AI Agent 实现拆解」E60 篇,Part 13 RAG 篇第九章。上一篇把两个接口落到了 pgvector 的 SQL 上。这篇回到效果:检索结果不理想时,多查询改写(MultiQuery)和重排序(Reranker)经常被并列推荐,但它们修的根本不是同一个毛病。选错了不但没用,还会让你多花一倍的钱和延迟。

先把结论摆前面,省得看完还犯迷糊:

多查询(MultiQuery) 重排序(Reranker)
修什么 该捞的没捞上来 捞上来了,但排序不对 / 混进了不该给的
指标看 Recall(召回率)↑ MRR / nDCG(排序质量)↑、negative_rate(毒药率)↓
代价 检索次数 ×N,外加一次 LLM 调用 每次查询多一次模型调用,延迟约翻倍
什么时候没用 正确答案压根不在库里 前面根本没捞到,重排也是重排一堆错的

一句话:多查询扩的是"捞多少",重排序管的是"怎么排"。 先用第 70 篇《Embedding 选型》那套评测集看你缺的是哪一样,再决定加哪个。

读完这篇你会知道

  • MultiQuery 不需要 LLM 也能跑(RewriteHandler 优先级高于 RewriteLLM),本文全部实验因此免 API Key
  • 一个最容易踩的默认值:融合函数默认只去重、不排序,实测得分 2.0 的文档排在得分 1.0 的后面
  • 默认改写上限是 5 条,超出直接截断;LLM 输出多一个空行,就会白发一次空查询
  • 三路子查询挂一路,另外两路的结果全部丢弃
  • RRF 融合怎么算,以及为什么 DeepFlux 在源码注释里专门警告"别拿 RRF 分数当相似度阈值"
  • reranker/score 名字叫重排序,实测干的却是「高分放两头、低分塞中间」——当普通排序用会丢掉第二相关的文档
  • 真正的 rerank 模型接口长什么样,以及生产上怎么用 top-K 预算和熔断控制它的成本

一、MultiQuery:把一个问题问成好几遍

用户问「三万块的费用谁签字」,库里写的是「五千元至两万元须经分管副总审批」。用词几乎不重叠,向量检索大概率捞不到。

MultiQuery 的思路很直白:先让 LLM 把问题改写成几种不同说法,每种都去检索一遍,最后把结果合并。

看它的构造配置(eino/flow/retriever/multiquery/multi_query.go):

type Config struct {
    // 1. 用 LLM 生成多个查询
    RewriteLLM      model.ChatModel
    RewriteTemplate prompt.ChatTemplate
    QueryVar        string
    LLMOutputParser func(context.Context, *schema.Message) ([]string, error)
    // 2. 自定义改写逻辑,可以不用 ChatModel。设了这个,上面的全部失效
    RewriteHandler func(ctx context.Context, query string) ([]string, error)

    MaxQueriesNum int

    OrigRetriever retriever.Retriever
    FusionFunc    func(ctx context.Context, docs [][]*schema.Document) ([]*schema.Document, error)
}

注意第二条那句注释:RewriteHandler 一旦设置,就盖过上面所有 LLM 相关配置。

这不只是一个测试用的后门。真实场景里它很有用:如果你的改写规则是确定的——比如挂一张行业同义词表(「离职」↔「辞职」↔「解除劳动合同」),或者按模板拼几个变体——那根本不需要花钱调 LLM,还省掉一次几百毫秒的延迟。

对本文的直接好处是:下面所有实验都不需要 API Key,你可以照着复现。

主流程只有三步

func (m *multiQueryRetriever) Retrieve(ctx context.Context, query string, ...) {
    queries, err := m.queryRunner.Invoke(ctx, query)   // ① 改写
    if len(queries) > m.maxQueriesNum {
        queries = queries[:m.maxQueriesNum]            //    超出直接截断
    }

    tasks := make([]*utils.RetrieveTask, len(queries))
    // ...
    utils.ConcurrentRetrieveWithCallback(ctx, tasks)   // ② 并发检索

    for i, task := range tasks {
        if task.Err != nil {
            return nil, task.Err                       //    任一路失败即全盘失败
        }
        result[i] = task.Result
    }

    fusionDocs, err := m.fusionFunc(ctx, result)       // ③ 融合
    return fusionDocs, nil
}

改写、并发检索、融合。真正决定效果的是第三步,但它恰恰是最容易被忽略的一步。


二、最坑的默认值:融合只去重,不排序

不填 FusionFunc 时,用的是这个:

var deduplicateFusion = func(ctx context.Context, docs [][]*schema.Document) ([]*schema.Document, error) {
    m := map[string]bool{}
    var ret []*schema.Document
    for i := range docs {
        for j := range docs[i] {
            if _, ok := m[docs[i][j].ID]; !ok {
                m[docs[i][j].ID] = true
                ret = append(ret, docs[i][j])
            }
        }
    }
    return ret, nil
}

从头到尾没有 sort它只做了一件事:按 ID 去重,然后按遍历顺序拼起来。

实测。六片员工手册语料,问「三万块的费用谁签字」,改写成三条(原问句 + 「审批 副总」+「报销 期限」):

【默认 deduplicateFusion(只去重)】
  子查询 3 次: "报销 期限" "三万块的费用谁签字" "审批 副总"
  结果: 1.approve(2.0000)  2.reimburse(2.0000)  3.resign(1.0000)  4.sick(2.0000)

看第 3 和第 4 名:resign 得分 1.0 排在前面,sick 得分 2.0 反而排在后面。

原因就是那段代码——输出顺序是「第一路的结果,然后第二路的新增,然后第三路的新增」。而三路是并发跑的,哪一路先返回,它的结果就先进列表。上面那行 子查询 3 次 的顺序(报销、原问句、审批)就是并发完成顺序,跟传入顺序都不一样。

这意味着:默认配置下的 MultiQuery,输出顺序是不确定的。
同一个查询跑两遍,结果排序可能不同。如果你后面接 docs[:3] 取前三喂给 LLM,那你每次喂进去的东西都不一样。

召回是变多了(单路只能拿 3 条,现在有 4 条),但排序质量被搞坏了。这就是为什么很多人加了 MultiQuery 之后感觉「有时候更准了,有时候反而更差」。


三、融合函数才是重点:RRF

修法是自己传一个 FusionFunc。业界标准做法叫 RRF(Reciprocal Rank Fusion,倒数排名融合)

它的算法一行就能说清:一篇文档在某一路排第 rank 名,就得 1/(k+rank) 分;在多路都出现就累加;最后按总分排序。

DeepFlux 里的实现(server/internal/kb/infrastructure/vector/rrf.go):

// rrfK · RRF 常数。k=60 是 Cormack 原论文与业界惯例值,平衡头部与长尾。
const rrfK = 60

for _, list := range lists {
    for rank, h := range list {
        rrfScore := 1.0 / float64(rrfK+rank+1) // rank 0-based → +1 转 1-based
        if e, ok := seen[id]; ok {
            e.rrfScore += rrfScore
        } else {
            seen[id] = &entry{hit: h, rrfScore: rrfScore}
        }
    }
}

为什么用排名而不是直接把分数加起来?因为不同检索路的分数没有可比性。向量检索的余弦相似度是 0~1,关键词检索的 BM25 可能是 0~30,直接相加等于让 BM25 单方面说了算。RRF 只看名次,天然免掉量纲问题——源码注释里管这叫「免量纲校准」。

换成 RRF 融合,同一份数据:

【RRF 融合 k=60】
  子查询 3 次: "报销 期限" "三万块的费用谁签字" "审批 副总"
  结果: 1.approve(0.0489)  2.reimburse(0.0325)  3.resign(0.0317)  4.sick(0.0161)

排序变成确定的了。approve 得 0.0489 是因为它在两路都进了榜(0.0164 + 0.0325),多路都认可的文档自动往上走——这正是我们想要的。

一个必须知道的副作用:RRF 分数不是相似度

注意上面的分数量级:0.0161 ~ 0.0489

DeepFlux 的源码注释专门为此写了一段警告:

// 兼容别名 Score 映射到可比较的相关性代理(dense 优先,否则 lexical),绝不映射 RRF —
// 避免下游把 ≈0.016-0.033 的 RRF 值当 0-1 相似度阈值过滤(P0)。

翻译过来:如果你的下游代码里有一句 if score < 0.7 { 丢弃 },那么换成 RRF 之后,所有结果都会被丢光。因为 RRF 分的理论上限就是 路数 × 1/(k+1),三路融合最高也才 0.049。

这个坑跟第 73 篇《Indexer + Retriever 源码》讲的 ScoreThreshold 是同一类:**分数这个字段,语义完全取决于是谁写进去的。**别人的阈值不能抄,换个融合算法连自己的阈值都得重定。

所以那份实现的做法是:RRF 分写进独立的 RRFScore 字段,Score 字段仍然放原始的相似度。两个含义不同的数,不要挤在同一个字段里。


四、另外三个坑,实测

① 默认最多 5 条,超了直接砍

MaxQueriesNum=0(不填 → 默认 5)     改写出 6 条 → 实际检索 5 次
MaxQueriesNum=2                     改写出 6 条 → 实际检索 2 次

queries[:m.maxQueriesNum] 是直接切片,砍掉的是后面的。如果你的改写模板把最重要的变体放在最后,它就被扔了。

顺带一提,默认那段改写 prompt 写的是 create three different versions(要三个),而 defaultMaxQueriesNum = 5。两个数对不上,不影响正确性,但说明这个上限只是个兜底闸,别指望它来控制数量——要控制数量请改 prompt

② LLM 多输出一个空行,你就多发一次空查询

默认的输出解析器是这个:

parser = func(ctx context.Context, message *schema.Message) ([]string, error) {
    return strings.Split(message.Content, "\n"), nil
}

strings.Split 不会过滤空串。LLM 输出里但凡有个空行,切出来就是 ["年假", "", "审批"]。实测:

改写结果 ["年假" "" "审批"]
实际检索 3 次: "审批" "年假" ""

那个空字符串真的被拿去检索了。 内存库无所谓,但如果后面接的是云端向量库,这就是一次实打实的付费调用——先花钱算一个空文本的 embedding,再拿它做一次全库扫描,最后返回一堆毫无意义的结果(空查询跟谁都不像,或者跟谁都一样像,取决于你的模型)。

自己传 LLMOutputParser 时,记得加一行 if strings.TrimSpace(s) == "" { continue }

③ 三路挂一路,另外两路白跑

三路里一路故障 → err=模拟后端故障: query="崩"
返回文档数=0(另外两路的结果全丢)

源码里就是 if task.Err != nil { return nil, task.Err }——不是部分成功,是全盘失败

这个设计本身没错(宁可报错也不返回残缺结果),但要知道它的后果:**接了 MultiQuery 之后,检索的失败率变成了原来的 N 倍。**单路 99.9% 可用,五路并发全成功的概率是 99.5%。

生产上如果不希望一路抖动就让整个问答挂掉,得自己写兜底——比如把 OrigRetriever 包一层,内部吞掉错误返回空列表,让融合阶段至少还有别的路的结果可用。代价是你会失去「检索出问题了」这个信号,所以包的时候记得打日志或者上报指标。


五、reranker/score:名字叫重排序,干的不是那件事

eino-ext 里有个 document/transformer/reranker/score 组件。看名字,八成人会以为它是「按分数重新排序」。

实测一下。给它 7 篇文档,分数已经从高到低排好:

════ 实验 5:reranker/score 到底做了什么 ════
  输入(已按分降序): 1.d1(0.9500)  2.d2(0.8800)  3.d3(0.7100)  4.d4(0.6000)  5.d5(0.4200)  6.d6(0.3000)  7.d7(0.1200)
  输出:             1.d1(0.9500)  2.d3(0.7100)  3.d5(0.4200)  4.d7(0.1200)  5.d6(0.3000)  6.d4(0.6000)  7.d2(0.8800)

最高分 d1 在第 1 位,第二高的 d2 跑到了最后一位,最低分 d7 在正中间。

这不是 bug。看源码注释:

// The reranker reorganizes documents based on their scores in a specific pattern:
// - Documents with higher scores are placed at both the beginning and end of the array
// - Documents with lower scores are placed in the middle
//
// This arrangement is based on research showing that LLMs exhibit better performance
// when relevant information appears at the beginning or end of the input context,
// known as the "primacy and recency effect" (https://arxiv.org/abs/2307.03172)

实现就是排序后交替往两头填:

for i, d := range copied {
    if i%2 == 0 {
        ret[i/2] = d              // 偶数位 → 从前往后放
    } else {
        ret[len(ret)-1-i/2] = d   // 奇数位 → 从后往前放
    }
}

它解决的是另一个问题:LLM 读长上下文时,中间的内容容易被忽略(那篇论文叫 “Lost in the Middle”)。所以它把最相关的放在开头和结尾,最不相关的埋在中间。

用对了是加分项,用错了是事故:

如果你把它当普通排序器,然后照常 docs[:3] 取前三——
你拿到的是 d1(0.95)、d3(0.71)、d5(0.42),
第二相关的 d2(0.88) 被你亲手扔掉了。

判断标准很简单:**这个组件的输出是要整份塞给 LLM 的,不是用来截断的。**要截断请在它之前截。

它也不调用任何 rerank 模型——不发网络请求,不算相关性,只是重新摆放顺序。真正意义上的「重排序」是下一节那个。


六、真正的 Reranker 长什么样

真 reranker 是一个独立的模型。它跟 embedding 模型的根本区别是:

  • embedding:把 query 和 doc 分别编码成向量,再算距离。doc 的向量可以预先算好存起来,查询时只算 query——所以快。
  • reranker:把 query 和 doc 拼在一起送进模型,直接输出一个相关性分数。没法预计算,每对都要现算——所以慢,但准得多。

eino-ext 里没有现成的 rerank 模型组件,DeepFlux 是自己写的 HTTP 实现,接口极简:

func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []string) ([]float32, error)

给它 query 和一批文档正文,返回等长的分数数组。请求体也很朴素:

type dsParams struct {
    ReturnDocuments bool `json:"return_documents"` // false:只回 index+score,省带宽
}

return_documents: false 这个细节值得学:文档正文是你自己传上去的,你手里有,没必要让服务端再回传一遍。一次重排 40 篇文档,省下的是 40 篇正文的下行流量。

生产上真正要操心的是成本

reranker 按「query-doc 对」计费和计时,候选越多越贵。所以实现里有两个闸:

**① top-K 预算。**测试用例的名字就说明了一切:

TestRerankHits_Top40Budget · QUALITY 只重排前 RerankK(40) 条,其余保持 hybrid 无 rerank 分。

先用便宜的混合检索捞 100 条,只把前 40 条送去重排,剩下的保持原分。因为排在 80 名开外的文档,重排后能翻身进前 10 的概率极低——为那点概率付 60 篇的钱不划算。

② 熔断。CircuitOpen():rerank 服务挂了或超时,直接跳过重排,用混合检索的原始排序返回。**结果差一点,总比整个问答挂掉强。**这是典型的降级设计——reranker 属于「锦上添花」,不该成为单点故障。

它到底值不值:一组真实数据

第 70 篇那篇里引过 DeepFlux 的实测,这里正好是它的用武之地。同一份语料 35 道题、同一个 embedding 模型,只差一个重排序:

配置 recall@20 mrr@10 ndcg@10 negative_rate p50 延迟
无 reranker 1.000 0.871 0.955 11.4% 262ms
+ gte-rerank-v2 1.000 0.857 0.952 8.6% 640ms
达标线 ≥0.80 ≥0.70 ≥0.70 ≤0.10

三个指标里有两个变差了,延迟涨了一倍多。但它把 negative_rate 从 11.4% 压到 8.6%——而那是四项里唯一没达标的。

注意 recall@20 两行都是 1.000,完全没动。这不是巧合,是必然:

重排序不会让你多捞到任何一篇文档。
它只在你已经捞到的那批里面重新排位。捞的范围是 embedding 和融合决定的,重排器碰不到。

这也就解释了本文开头那张表:recall 不够,加重排序是没用的。 反过来,如果 recall 已经满分(像这组数据),那再怎么折腾多查询也不会有收益,该动的是排序质量。


七、所以到底该加哪个

按这个顺序判断,别凭感觉:

第一步,先看 Recall。 用第 70 篇那套评测集,把 TopK 放大到 20 跑一遍。

  • Recall@20 明显不满(比如 0.7) → 正确答案压根没进候选池。这时候加重排序完全无效,它排的是一堆错的。该做的是:

    1. 先回头看切片(第 68 篇《切片策略》那组数据:同一份文档同一个模型,切法从 0/4 变到 4/4,还不花钱)
    2. 再考虑 MultiQuery,并且一定要配 RRF 融合
    3. 或者上混合检索——关键词腿负责精确匹配型号、编号、金额,这类词向量检索天生弱
  • Recall@20 已经很高,但 MRR / nDCG 低 → 东西都捞上来了,就是排序不对。这才是 reranker 的主场。

  • Recall 高、排序也还行,但 negative_rate 超标 → 混进了「相关但不该给」的内容。reranker 是最直接的解法,上面那组数据就是这个情况。

第二步,算清代价。 MultiQuery 是「检索次数 ×N + 一次 LLM 调用」,reranker 是「延迟翻倍」。两个都加就是两份成本,而且同时加会让你分不清是哪个起了作用——真要都上,一次只加一个,每次跑一遍评测集。

最后一句提醒:这两个都属于「检索环节的补救」。如果评测集里出现所有配置下都排在末位的题——像第 70 篇那道「三万块的费用谁签字」,需要判断 30000 落在哪个金额区间——那多查询和重排序都救不了,因为它们都不做数值推理。那种需求要的是结构化查询或者工具调用,不是更好的检索。


小结

  • 两个东西修不同的病:多查询扩召回(Recall),重排序改排序(MRR / nDCG / negative_rate)。实测重排序前后 recall@20 一动不动,因为它根本不多捞文档
  • MultiQuery 的默认融合只去重、不排序:实测得分 1.0 的文档排在得分 2.0 前面,且顺序随并发完成顺序变化,同一查询两次结果可能不同
  • 想用 MultiQuery 就自己传 FusionFunc,标准做法是 RRF:1/(60+rank) 累加,只看名次不看分数,天生免量纲
  • RRF 分数量级只有 0.016~0.049,不是相似度。下游有 score >= 0.7 这类阈值的话会被全部过滤光——分数字段的语义取决于谁写的
  • RewriteHandler 优先于 RewriteLLM,同义词表这类确定规则不必花钱调 LLM
  • 默认上限 5 条、超出砍尾巴strings.Split 不过滤空串,LLM 多个空行就白发一次付费查询
  • 一路子查询失败 = 整体失败,五路并发把可用性从 99.9% 拉到 99.5%
  • reranker/score 不是排序器,是按 “Lost in the Middle” 把高分摆到首尾。当排序器用再 [:3] 截断,会丢掉第二相关的文档
  • 真 reranker 要控成本:只重排前 40 条(后面的翻身概率太低),并且必须能熔断降级

下一篇进到 Part 13 的收尾:把第 66 篇到这一篇的东西串成一条完整链路——文档进来、切片、向量化、存进 pgvector、检索、重排、喂给 LLM,端到端跑通一个能用的 RAG,并且把每一步该配什么值、怎么验证,列成一张可以照着抄的表。


代码状态说明

第一~五节:五个实验全部在 eino v0.9.13 下真机运行,输出原样粘贴。查询改写走 RewriteHandler、检索走内存假库(按字面命中计数打分),因此不需要任何 API Key,可自行复现。实验 5 调用的是 eino-extreranker/score 真实组件(本地 replace 引入),不是我复刻的算法。语料是我为演示虚构的六条员工手册条款。

第三、六节:RRF 实现、Rerank 接口、top-40 预算、熔断,均引自 DeepFlux 仓库既有代码(internal/kb/infrastructure/vector/rrf.gointernal/kb/infrastructure/reranker/http.gointernal/kb/application/query/rerank_test.go),不是我为本文写的。第六节那组 35 题对照数据同样是仓库既有归档(docs/sales/evidence/runs/),我只做引用和解读。

没有实测的部分:真实 rerank 模型(gte-rerank-v2)和 LLM 查询改写我都没有可用凭据,没有亲自调用。上表数据来自 DeepFlux 归档,源码行为来自阅读。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐