Files
SuperBizAgent-java/docs/RAG排序-多路召回与RRF.md
T
zhuyongxin 7ae9707a3b feat(harness,rag): dual LLM audit fields, run conclusion, and hybrid quality
Persist provider reasoning and assistant text separately on agent_reasoning_audit
(DeepSeekAssistantMessage path), extract diagnosis_run.conclusion, enrich RAG
tool audit (step_id/query/qualityScore), gate empty mysql tools, drop devtools,
and align MVP docs after live E2E verification.
2026-07-28 19:43:13 +08:00

23 KiB
Raw Blame History

诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF

日期:2026-07-27
范围:知识检索排序、多路召回、分数融合、Rerank 选型
读者:需要在 Agent 系统里落地 RAG,而不是只做 Demo 问答的工程同学
关联实现:Lookup_knowledge 模块化链路(L0 hint → L1 向量 → 规则后处理 → 投影)


1. 引言:RAG 排序常被高估,也常被低估

在诊断 Agent 里,知识库检索很少是“搜一下、答一句”那么简单。一次 lookup_knowledge 往往要在有限工具预算里,尽快给出可引用的证据片段。这时排序质量会直接影响:

  • Agent 是否看得到正确 runbook / 排查步骤
  • 是否被错误 domain 的文档带偏
  • 是否在本就很少的 topK 里,把唯一有用的 chunk 挤掉

讨论排序时,团队里很容易出现两种极端:

  1. 高估 Rerank
    一提质量问题就上 Cross-Encoder、商业 Rerank、LLM listwise。
    但候选池只有 3 条时,精排只能在这 3 条里换座位,召不回的内容永远排不上来。

  2. 低估融合
    觉得“都是相似度,加一加就行”。
    但向量 L2、BM25、关键词加分根本不在同一尺度上,硬加权会把系统调成玄学。

本文基于一次针对真实诊断 Agent RAG 链路的讨论,整理一套可落地的判断框架:

1. 候选太少时,复杂 rerank 价值有限
2. 多路召回解决“漏”,精排解决“噪”
3. 跨路不要硬加原始分,优先 RRF 用名次投票
4. L0 / 关键词适合做提示,不适合当最终裁判
5. 权重 w_i 是“路权”,不是文档原始分

目标不是证明某一种模型永远最优,而是回答工程上更常见的问题:

现在的 K、现有的 L0/L1、现有的规则加分,下一步到底该扩召回、该融合,还是该上精排?

flowchart TB
    P[排序/质量问题] --> A{候选池 K 多大?}
    A -->|K 很小 3~5| B[先扩召回 / 修去重 / 轻规则]
    A -->|K 中等 15~30| C[多路 + RRF + 可选轻精排]
    A -->|K 很大 50+| D[强 rerank 才划算]

    P --> E{跨路分数?}
    E -->|原始分硬加| F[尺度不同 · 易玄学]
    E -->|RRF 名次投票| G[推荐]

2. 现状解剖:有“重排”,不等于有“强 Rerank”

2.1 一条典型主链路

以模块化 lookup_knowledge 为例,主路径大致是:

Agent query
  -> KnowledgeQueryTransformer      # L0:domain / keyword hint,可选 category filter
  -> KnowledgeDocumentRetriever     # L1:向量 topK
  -> KnowledgeEvidencePostProcessor # 归一化 + 规则 boost + 排序 + 去重
  -> KnowledgeContextPacker         # 字符预算打包
  -> LookupResultAssembler
  -> RagResultProjector             # 投影成 Agent 可见契约
flowchart TB
    AQ[Agent query] --> L0[KnowledgeQueryTransformer<br/>L0 hint / category filter]
    L0 --> L1[KnowledgeDocumentRetriever<br/>L1 向量 topK]
    L1 --> POST[KnowledgeEvidencePostProcessor<br/>归一化 + 规则 boost + 去重]
    POST --> PACK[KnowledgeContextPacker]
    PACK --> ASM[LookupResultAssembler]
    ASM --> PROJ[RagResultProjector]
    PROJ --> AGENT[Agent 可见结果]

其中“重排”发生在后处理阶段,名字也常叫 rerank,但实现通常是:

baseScore = 向量距离归一化后的相似度
finalScore = baseScore
           + domain_match
           + entity_match
           + keyword_match
           + source_type_prior
再按 finalScore 降序
flowchart LR
    BASE[baseScore<br/>向量相似度] --> SUM[finalScore]
    D[+ domain]
    E[+ entity]
    K[+ keyword]
    S[+ source_type]
    D --> SUM
    E --> SUM
    K --> SUM
    S --> SUM
    SUM --> SORT[按 finalScore 降序]

同时会留下 rerankTrace(base/final score、boost reasons),便于内部审计。

2.2 这套做法解决了什么

它不是毫无意义:

  • 在小候选池里,能把更像目标域、更像 runbook 的结果往前推
  • 有可解释的 boost 原因,方便 trace
  • 实现成本低,不引入额外模型服务

如果只是 Demo,或语料很小、query 很规范,这种轻规则重排往往够用。

2.3 它没解决什么

真正的问题通常不在“3 条里谁排第一”,而在:

  1. K 太小
    topK=3 时,重排空间极小。复杂精排模型也只能在这 3 条里微调。

  2. 规则加分不稳定
    依赖 L0 词表和字符串 contains。短词、泛词、别名缺失都会让 boost 误触发或漏触发。

  3. L0 filter 可能误杀
    唯一 domain 时加 category filter,能降噪;一旦 L0 判错域,正确文档可能根本进不了候选。

  4. 去重粒度若偏文档级
    同文档多个相关 chunk 可能被压成 1 条,召回了也会在后处理/投影阶段丢掉。

  5. Agent 侧看不到排序细节
    投影层常只保留 excerpt 与少量元数据,score / hitReasons / retrievalTrace 被裁掉。这对安全边界合理,但对模型“判断有多相关”不友好。

一句话概括现状:

不是没有排序,而是在太小的候选池里,用不够稳的启发式做微调。


3. 关键判断:候选池大小决定策略上限

排序策略必须和 K 匹配。可以先用下面这张表做决策:

flowchart TB
    K{retrieve-k / 候选规模}
    K -->|3~5| S1[修去重 · 轻规则<br/>双路径 dense 融合]
    K -->|15~30| S2[多路召回 + RRF<br/>可选轻精排]
    K -->|50+| S3[强 Cross-Encoder / 托管 Rerank]

    S1 -.->|先别上| X1[商业 Rerank]
    S2 -.->|收益有限| X2[只继续调 keyword boost]
    S3 -.->|避免| X3[无评测堆模型]
召回规模 K 更适合做什么 不太值得先做什么
3 ~ 5 修去重、轻规则、双路径 dense 融合 Cross-Encoder / 商业 Rerank
15 ~ 30 多路召回 + RRF + 轻精排 只继续调 keyword boost
50+ 强 rerank 才划算 无评测地堆模型

背后的原因很简单:

Rerank 的价值来自:
  候选很多、噪声大 → 精排把好的顶到前面

如果好文档根本不在候选里:
  再贵的 rerank 也救不回来

因此工程上应把两个参数拆开:

retrieve-k  # 召回阶段多捞一些,例如 20
return-n    # 最终给 Agent 的条数,例如 3~5

而不是始终:

topK = 3,召回、排序、返回都是 3

先让该进来的进来,再谈谁排前面。


4. 多路召回:先分清“同模态双路径”和“跨维度多路”

“多路召回”不是只有 BM25 + 向量这一种。可以分层理解。

4.1 同模态双路径:filtered + unfiltered

很多系统已经有类似逻辑:

若 L0 给出唯一 category:
  先 filtered 向量检索
  若结果差,再 unfiltered 重试
flowchart TB
    Q[query + L0 category?] --> F[filtered dense]
    F --> BAD{结果差/空?}
    BAD -->|是| U[unfiltered retry]
    BAD -->|否| USE[采用 filtered]
    U --> REP[常见:整锅替换 filtered]

这能工作,但常见实现是 串行整锅替换:

  • retry 成功后,直接丢掉第一次 filtered 的全部结果
  • 没有“两边好结果都保留,再统一排序”

更稳的做法是把它升级为并行/双路融合:

路 A:dense + category filter   # 求准
路 B:dense + 无 filter         # 求全
去重合并后一起排序
flowchart LR
    Q[query] --> A[路A filtered dense]
    Q --> B[路B unfiltered dense]
    A --> M[去重合并]
    B --> M
    M --> RRF[RRF / 统一排序]
    RRF --> OUT[topN]

为什么值得做?

因为两路解决的是不同失败模式:

路径 优点 风险
filtered 更贴当前域,噪声少 filter 错了会漏召回
unfiltered 召回面宽,容错强 容易掺进其他域文档

典型场景:

  • L0 误判成 mysql,真正文档在 redis
    → 只走 filtered 会空或很差
    → unfiltered 能救回
  • L0 判对了 mysql
    → filtered 更干净
    → unfiltered 可能带噪声

所以:

filtered 求准,unfiltered 求全;融合比二选一更稳。

它算多路吗?

算,但要说清楚边界:

这是同一 dense 检索器、不同过滤条件的双路径
属于多路召回的子集
还不是完整的跨维度多路

它的主要价值是:

  1. 降低 L0 category 误杀
  2. 保留“有 filter 时更准”的收益
  3. 避免 retry 整锅替换导致好结果被误删

即便暂时不上 BM25,只做这一步,也常常比继续调 keyword boost 更有效。

4.2 跨维度多路:Dense + BM25

更完整的多路,通常来自不同相关性维度:

路 擅长 不擅长
Dense(向量) 语义相近、换说法、同义表达 罕见专有名词可能漂
BM25 / 关键词 错误码、类名、配置键、告警名、精确术语 换一种说法就容易漏

例子:

用户说:连接池打满了
文档写:HikariCP pending threads high
→ dense 更容易搭上

用户说:SQLSTATE 08001
文档标题就含 08001
→ BM25 / 字面匹配往往更稳

因此:

candidates = dense ∪ bm25

不是“BM25 替代向量”,而是“补向量漏掉的字面命中”。

4.3 L0 能不能当一路召回?

可以,但要降权、控边界。

L0(文档 frontmatter 关键词 / domain hint)适合:

  • 决定要不要启用 filtered 路
  • 提供精排时的弱特征
  • 写出可解释 trace

不适合:

  • 关键词命中就直接当高置信事实证据
  • 用 L0 粗分主导最终排序

一句话:

L0 是导航,不是裁判长。


5. 融合为什么难:不是不会加权,是分数不可比

多路召回之后,第一反应常常是:

final = 0.7 * dense_score + 0.3 * bm25_score

这在课堂上好讲,在工程上很脆。

5.1 原始分为什么不能直接加

不同路的分数:

  • Dense:L2 距离或 cosine similarity,分布随 embedding 模型和语料变化
  • BM25:另一套量纲,数值大小和 dense 完全不可比
  • 规则 boost:+0.15 / +0.20 这种启发式加分,更像人工偏好,不是校准概率

把它们直接线性相加,等于默认“0.1 的向量分提升”和“2.0 的 BM25 提升”可以交换。这个默认通常不成立。

5.2 规则 keyword boost 为什么不稳定

若最终分主要靠:

query.contains(keyword) || keyword.contains(query)

再叠加固定加分,会出现:

  • 短词误命中
  • 泛词普遍加分,区分度下降
  • 词表一改,线上排序整体漂移
  • 同义词没写进 frontmatter 就完全没帮助

所以:

现在的“权重”如果本质是关键词启发式加分,稳定性天然有限。

这不代表规则无用,而是应把它放对层:路内微调或精排特征,而不是跨路主融合器。


6. RRF:跨路融合时,优先用名次投票

6.1 核心思想

RRF(Reciprocal Rank Fusion)的关键思想是:

不管各路原始分是什么尺度,只看每条候选在各路里的名次,再投票。

基础公式:

RRF(d) = Σ  1 / (k + rank_i(d))

其中:

符号 含义
d 某个候选文档或 chunk
i 第 i 路召回
rank_i(d) d 在第 i 路中的名次(从 1 开始);未出现则该路贡献为 0
k 常数,常用 60,用来缓和头部名次过强

直觉:

  • 某路第 1 名:贡献约 1/61
  • 某路第 2 名:贡献约 1/62
  • 多路都靠前的候选,融合分自然更高
  • 只在一路偶然靠前的候选,不会单靠绝对分尺度“爆掉”
flowchart TB
    subgraph paths["各路有序结果"]
        P1[dense ranks]
        P2[BM25 ranks]
        P3[filtered ranks · 可选]
    end

    P1 --> RRF["RRF(d) = Σ 1/(k + rank_i)"]
    P2 --> RRF
    P3 --> RRF
    RRF --> OUT[融合序 · 不依赖原始分尺度]

    L2[L2 原分] -.->|不直接相加| X[避免]
    BM[BM25 原分] -.-> X

6.2 为什么适合 RAG 多路融合

RRF 特别适合下面这种现实约束:

dense 用 L2
bm25 用 BM25
filtered / unfiltered 虽同度量,但候选集合不同
暂时没有可靠的分数校准器

它把问题从:

如何把不可比的分数对齐?

简化成:

各路是否都认为它靠前?

6.3 加权 RRF:w_i 是路权,不是文档分

加权形式:

RRF_w(d) = Σ  w_i / (k + rank_i(d))

这里的 w_i 非常容易被误解。

正确理解

w_i = 第 i 整路的话语权

例如:

w_dense = 1.0
w_bm25  = 1.5   # 想让 BM25 更重要,就提高这一路的 w
w_filtered_dense = 1.0

含义是:

  • BM25 这一路投出的“名次票”更值钱
  • 并不是把某个文档的 BM25 原始分 12.7 直接拿来和向量分相加

错误理解

先算 keyword boost 得到一个大杂烩分
再把这个分塞进 RRF

或:

RRF = f(L2原始分, BM25原始分, keyword加分)

这都不是加权 RRF。

6.4 分层心智模型

建议始终按三层理解分数角色:

第 1 层:路内排序
  dense 路用向量分排序
  bm25 路用 BM25 分排序
  各用各的分,互不直接相加

第 2 层:跨路融合
  只吃各路 rank
  普通 RRF 或加权 RRF(w_i 为路权)

第 3 层:精排(可选)
  对融合后的 topM 再打分
  这里才适合规则特征或 Cross-Encoder

一句话记住:

原始分只负责路内排名;RRF 只负责跨路投票;w_i 只调节哪一路更值钱;精排才做最终挑剔。

6.5 手算例子

设:

k = 60
w_dense = 1.0
w_bm25  = 1.5

候选 A:

  • dense 第 1 名
  • bm25 第 5 名
RRF(A)
= 1.0/(60+1) + 1.5/(60+5)
= 1/61 + 1.5/65
≈ 0.01639 + 0.02308
≈ 0.03947

候选 B:

  • dense 第 4 名
  • bm25 第 1 名
RRF(B)
= 1.0/(60+4) + 1.5/(60+1)
= 1/64 + 1.5/61
≈ 0.01563 + 0.02459
≈ 0.04022

在这个设定下 B 更高,因为你主动提高了 BM25 路权,BM25 头名更吃香。

如果把 w_bm25 降到 0.5,同样名次下 dense 会重新占主导。
这就是路权的意义:调的是整路话语权,不是某个文档的原始分公式。

6.6 w_i 怎么设

实操建议:

  1. 起步全设 1.0
    先看纯 RRF,不要一上来调花活。

  2. 再按评测微调

    • 专有名词/报错码总靠 BM25 才找得到,却总被 dense 压下去 → 提高 w_bm25(如 1.2~1.5)
    • BM25 噪声大、泛词乱入 → 降低 w_bm25(如 0.6~0.8)
    • filtered 很准但有时过窄 → 可与 unfiltered 同权,或略高一点
  3. 经验范围
    w_i 常见落在 0.5 ~ 2.0。
    若一路是 10、另一路是 0.1,基本等于放弃多路。

  4. 没有回归集就不要谈“最优权重”
    路权应来自离线评测,而不是长期拍脑袋。


7. 精排放在哪里:有了候选池,才配谈 Rerank

7.1 轻规则精排

在 RRF 融合出 top 10~15 后,可以用更稳的字段级规则做二次排序:

title 命中 > breadcrumb 命中 > body 覆盖
精确术语 > 泛词
runbook / case 来源轻微加分
与已选证据过相似则降权(MMR 思路)

注意:这里的规则特征,最好作用于 融合后的候选精排,而不是重新发明一套跨路原始分加法。

7.2 模型精排

当 retrieve-k 到 15~30,且评测证明融合后噪声仍高时,再考虑:

RRF topM
  -> Cross-Encoder / 托管 Rerank API
  -> 取 topN 给 Agent

可选路线:

  • 开源 reranker(如 bge-reranker 一类)
  • 云厂商 / Cohere 等托管 rerank
  • LLM listwise(贵且不稳,一般不当首选)

7.3 什么时候不要上模型 rerank

  • 仍在 K=3 主路径上
  • 还没修 chunk 级去重
  • 还没有固定 query 回归集
  • 延迟和工具预算已经很紧

否则花的是精排成本,换不到召回质量。


8. 工程落地:比“换模型”更重要的顺序

8.1 建议的目标形态

Query
  ├─ dense unfiltered          top 20
  ├─ dense filtered            top 10   # 有唯一 domain 时
  └─ bm25 / keyword            top 10   # 第二阶段
        │
        ▼
  chunk 级去重(docId#chunkIndex)
        │
        ▼
  RRF / 加权 RRF 融合
        │
        ▼
  轻规则或模型精排,取 top 5
        │
        ▼
  每文档 chunk 上限 + context pack
        │
        ▼
  Agent projection
flowchart TB
    Q[Query] --> U[dense unfiltered top20]
    Q --> F[dense filtered top10]
    Q --> B[bm25/keyword top10]
    U --> DEDUP[chunk 级去重<br/>docId#chunkIndex]
    F --> DEDUP
    B --> DEDUP
    DEDUP --> RRF[RRF / 加权 RRF]
    RRF --> RR[轻规则或模型精排 top5]
    RR --> CAP[每文档 chunk 上限 + pack]
    CAP --> AG[Agent projection]

8.2 分阶段推进

flowchart LR
    P0[Phase0<br/>chunk 去重<br/>retrieve-k/return-n] --> P1[Phase1<br/>filtered+unfiltered RRF]
    P1 --> P2[Phase2<br/>BM25 跨维度]
    P2 --> P3[Phase3<br/>可插拔精排]

Phase 0:先修前提

否则后面多路都会被吞:

  1. 去重 key 从“文档/source”改为 docId + chunkIndex(fallback 可用向量主键)
  2. 配置拆分:
rag.retrieve-k=20
rag.return-n=5
rag.max-chunks-per-document=2
  1. 明确 baseScore(可用性阈值)与融合分/精排分(排序)职责分离

Phase 1:同模态双路径 + RRF

  1. filtered dense 与 unfiltered dense 都产出候选
  2. 合并去重,不再整锅替换
  3. RRF 融合后取 topN
  4. trace 记录每路 rank 与 fused rank

这是最贴很多现有系统的一步,收益通常大于继续调 boost。

Phase 2:跨维度多路

  1. 增加 BM25 / 关键词路
  2. 继续 RRF,必要时给 w_bm25 微调
  3. 增加字段级轻精排

Phase 3:可插拔模型 Rerank

interface Reranker {
  rerank(query, candidates, topN) -> rankedCandidates
}

实现可切换:

  • NoopReranker
  • RuleReranker
  • HttpCrossEncoderReranker

让精排成为插件,而不是写死在业务里。

8.3 和 Agent 系统相关的额外约束

诊断 Agent 场景还有几个现实约束:

  1. 工具预算有限
    检索本身只是工具循环的一环,延迟不能无限涨。

  2. 证据要可引用
    最终给 Agent 的应是有界 excerpt,而不是内部全量 trace。

  3. 投影会再裁一层
    即便内部排序很细,Agent 可见字段仍可能只有 document/source/title/breadcrumb/excerpt。
    因此内部要保留完整 trace,外部保持契约稳定。

  4. 安全发布与验真
    排序再好,也不能绕过证据引用与 guard;RAG 优化的是“更可能拿到对的证据”,不是“让模型自由发挥”。


9. 评测:没有回归集,权重都是感觉

多路和 RRF 最怕“上线凭体感”。最少准备 15~30 条固定 query,覆盖:

  • 标准故障词
  • 口语化换说法
  • 专有名词 / 错误码
  • 容易误判 domain 的问题
  • 同文档多 chunk 才完整的流程题
  • 负例:知识库本就没有答案

关注指标:

指标 看什么
Recall@5 该出现的文档/chunk 是否进前 5
nDCG@5 或人工 0/1/2 排序是否把更相关的放前面
filter 误杀率 唯一 domain 是否经常害人
同文档多 chunk 保留率 去重是否过粗
P95 延迟 多路是否打爆预算
无证据正确率 不该有答案时是否老实说没有

路权 w_i 的调整,应建立在这些数上,而不是单次手工 query。


10. 反模式清单

下面这些做法看起来勤快,实际常把系统带偏:

  1. 只在 K=3 上接昂贵 rerank
    候选池不够,精排没有舞台。

  2. L2 和 BM25 直接加权相加
    分数不可比,调参不可迁移。

  3. 把 L0 contains 当最终裁判
    词表质量绑死线上排序。

  4. 文档级去重吞掉同文档多 chunk
    多路召回也会在终点被自己吃掉。

  5. filtered 失败就整锅替换
    丢掉本可保留的好结果。

  6. 无评测调 w_i
    今天的“最优权重”往往是过拟合某几条样例。

  7. L0 命中全文直接当高置信 evidence
    导航信号被抬成事实,诊断场景尤其危险。


11. 可直接拿走的决策框架

遇到 RAG 排序问题时,按这个顺序问:

Q1. 正确答案是否经常连候选池都进不来?
    是 → 先扩召回 / 多路,不要先上复杂 rerank

Q2. 是否存在 filter 误杀?
    是 → filtered + unfiltered 双路径融合

Q3. 是否大量依赖专有名词、错误码、配置键?
    是 → 加 BM25 / 关键词路

Q4. 多路分数是否不可比?
    是 → RRF,而不是原始分硬加

Q5. 融合后 topM 仍噪声大,且 K 已经够大?
    是 → 再上规则精排或模型 rerank

Q6. 有没有固定回归集?
    没有 → 先建评测,再谈“最优权重”

对应到一句话策略:

先扩召回,再用名次融合,最后才模型精排。


12. 结语

RAG 排序讨论很容易变成模型名词竞赛。但在诊断 Agent 这种真实系统里,更常见的瓶颈是:

  • 候选太少
  • 过滤过猛
  • 分数不可比
  • 去重过粗
  • 启发式加分承担了不该承担的最终裁决

RRF 的价值,不只是一个公式,而是一种工程态度:

承认各路分数不可比
让每路先做好自己的排序
再用名次投票决定谁更值得进入下游

加权 RRF 也并不神秘:w_i 只是给整路调音量。
想让 BM25 更有话语权,就提高 w_bm25;它不会、也不应该要求你先把 BM25 分和向量分校准到同一宇宙。

如果只记住三句:

  1. K=3 时,复杂 rerank 不是第一优先级。
  2. filtered + unfiltered 是值得做的同模态双路径;Dense + BM25 才是跨维度多路。
  3. 跨路融合优先 RRF;L0 做导航,精排做挑剔,原始分不要跨路硬加。

按这个脉络演进,通常比“继续把 keyword boost 调大一点”更接近稳定、可解释、可评测的 RAG 排序系统。


附录 A:术语对照

术语 含义
L0 基于文档元数据/关键词的 query understanding 或弱召回
L1 向量语义召回
retrieve-k 召回阶段候选数
return-n 最终返回给 Agent 的证据数
baseScore 向量相似度等主相关性分,常用于可用性阈值
finalScore / 精排分 用于排序的综合分
RRF 基于名次的多路融合
w_i 第 i 路在 RRF 中的路权
Rerank 对已有候选做精排,不负责凭空召回新文档

附录 B:最小配置示例(示意)

# 召回与返回分离
rag.retrieve-k=20
rag.return-n=5
rag.max-chunks-per-document=2

# RRF
rag.fusion.method=rrf
rag.fusion.rrf-k=60
rag.fusion.w-dense=1.0
rag.fusion.w-dense-filtered=1.0
rag.fusion.w-bm25=0.8

# 精排(先规则,后可插模型)
rag.rerank.mode=rule   # rule | model | off

以上配置名是示意,重点在职责拆分,不在具体键名。

附录 C:和本文讨论直接对应的实现关注点

阅读或改造现有代码时,可重点核对:

  1. 后处理是否把“规则 boost”命名成了 rerank,却未做候选扩展
  2. filtered 低质时是整锅替换,还是双路融合
  3. 去重 key 是 source/文档级,还是 chunk 级
  4. topK 是否同时承担召回、排序、返回三种职责
  5. 内部 rerankTrace 是否可观测,Agent 投影是否有意裁剪

这些点决定了:你写在黑板上的 RRF,能不能在系统里真正跑起来。