Relocate RAG and diagnosis decision/E2E writeups from docs/ root into mvp/engineering so architecture, issues, and engineering narrative stay together. Update indexes and cross-links; leave docs/learning as legacy.
23 KiB
诊断 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 挤掉
讨论排序时,团队里很容易出现两种极端:
-
高估 Rerank
一提质量问题就上 Cross-Encoder、商业 Rerank、LLM listwise。
但候选池只有 3 条时,精排只能在这 3 条里换座位,召不回的内容永远排不上来。 -
低估融合
觉得“都是相似度,加一加就行”。
但向量 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 条里谁排第一”,而在:
-
K 太小
topK=3时,重排空间极小。复杂精排模型也只能在这 3 条里微调。 -
规则加分不稳定
依赖 L0 词表和字符串contains。短词、泛词、别名缺失都会让 boost 误触发或漏触发。 -
L0 filter 可能误杀
唯一 domain 时加 category filter,能降噪;一旦 L0 判错域,正确文档可能根本进不了候选。 -
去重粒度若偏文档级
同文档多个相关 chunk 可能被压成 1 条,召回了也会在后处理/投影阶段丢掉。 -
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 检索器、不同过滤条件的双路径
属于多路召回的子集
还不是完整的跨维度多路
它的主要价值是:
- 降低 L0 category 误杀
- 保留“有 filter 时更准”的收益
- 避免 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.0
先看纯 RRF,不要一上来调花活。 -
再按评测微调
- 专有名词/报错码总靠 BM25 才找得到,却总被 dense 压下去 → 提高
w_bm25(如 1.2~1.5) - BM25 噪声大、泛词乱入 → 降低
w_bm25(如 0.6~0.8) - filtered 很准但有时过窄 → 可与 unfiltered 同权,或略高一点
- 专有名词/报错码总靠 BM25 才找得到,却总被 dense 压下去 → 提高
-
经验范围
w_i常见落在0.5 ~ 2.0。
若一路是 10、另一路是 0.1,基本等于放弃多路。 -
没有回归集就不要谈“最优权重”
路权应来自离线评测,而不是长期拍脑袋。
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:先修前提
否则后面多路都会被吞:
- 去重 key 从“文档/source”改为
docId + chunkIndex(fallback 可用向量主键) - 配置拆分:
rag.retrieve-k=20
rag.return-n=5
rag.max-chunks-per-document=2
- 明确 baseScore(可用性阈值)与融合分/精排分(排序)职责分离
Phase 1:同模态双路径 + RRF
- filtered dense 与 unfiltered dense 都产出候选
- 合并去重,不再整锅替换
- RRF 融合后取 topN
- trace 记录每路 rank 与 fused rank
这是最贴很多现有系统的一步,收益通常大于继续调 boost。
Phase 2:跨维度多路
- 增加 BM25 / 关键词路
- 继续 RRF,必要时给
w_bm25微调 - 增加字段级轻精排
Phase 3:可插拔模型 Rerank
interface Reranker {
rerank(query, candidates, topN) -> rankedCandidates
}
实现可切换:
NoopRerankerRuleRerankerHttpCrossEncoderReranker
让精排成为插件,而不是写死在业务里。
8.3 和 Agent 系统相关的额外约束
诊断 Agent 场景还有几个现实约束:
-
工具预算有限
检索本身只是工具循环的一环,延迟不能无限涨。 -
证据要可引用
最终给 Agent 的应是有界 excerpt,而不是内部全量 trace。 -
投影会再裁一层
即便内部排序很细,Agent 可见字段仍可能只有 document/source/title/breadcrumb/excerpt。
因此内部要保留完整 trace,外部保持契约稳定。 -
安全发布与验真
排序再好,也不能绕过证据引用与 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. 反模式清单
下面这些做法看起来勤快,实际常把系统带偏:
-
只在 K=3 上接昂贵 rerank
候选池不够,精排没有舞台。 -
L2 和 BM25 直接加权相加
分数不可比,调参不可迁移。 -
把 L0 contains 当最终裁判
词表质量绑死线上排序。 -
文档级去重吞掉同文档多 chunk
多路召回也会在终点被自己吃掉。 -
filtered 失败就整锅替换
丢掉本可保留的好结果。 -
无评测调 w_i
今天的“最优权重”往往是过拟合某几条样例。 -
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 分和向量分校准到同一宇宙。
如果只记住三句:
- K=3 时,复杂 rerank 不是第一优先级。
- filtered + unfiltered 是值得做的同模态双路径;Dense + BM25 才是跨维度多路。
- 跨路融合优先 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:和本文讨论直接对应的实现关注点
阅读或改造现有代码时,可重点核对:
- 后处理是否把“规则 boost”命名成了 rerank,却未做候选扩展
- filtered 低质时是整锅替换,还是双路融合
- 去重 key 是 source/文档级,还是 chunk 级
topK是否同时承担召回、排序、返回三种职责- 内部
rerankTrace是否可观测,Agent 投影是否有意裁剪
这些点决定了:你写在黑板上的 RRF,能不能在系统里真正跑起来。