feat(rag): chunk evidence identity, dedup, and search port

Preserve same-document multi-chunk evidence with evidenceKey identity,
per-document caps, retrieve-k/return-n split, and a dense KnowledgeSearchPort.
Archives Delivery 1 OpenSpec change as the foundation for hybrid retrieval.
This commit is contained in:
zhuyongxin
2026-07-27 18:26:15 +08:00
parent 99d4f6f216
commit ac1f831903
34 changed files with 2880 additions and 298 deletions
@@ -0,0 +1,809 @@
# Milvus Hybrid Search 接入对照清单
**日期**:2026-07-27
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
**关联文档**:
- `docs/rag-ranking-multipath-retrieval-and-rrf.md`(排序与多路召回判断框架)
- 本文后续实现讨论以本节 **「交付拆分:分块去重 + Hybrid 同规划」** 为基线
---
## 1. 结论先说
可以接,而且和前面讨论的多路召回 / RRF 高度一致。
但当前项目 **还不具备 hybrid 运行条件**,缺的不是“再调一次 search”,而是:
```text
1. schema 只有 dense,没有 sparse/BM25 字段
2. 写入只产 dense embedding
3. 检索抽象仍以单路 similaritySearch 为中心
4. 后处理仍承担了过多“伪融合”职责
5. 证据去重粒度偏文档/source 级,同文档多 chunk 会被吞掉
```
**接入原则:**
```text
- 不继续加厚旧 SDK search 分支
- 以“检索端口 + 写入端口”抽象为准
- hybrid 融合尽量下沉到向量库(RRFRanker)
- 应用层保留:filter 策略、chunk 去重、return-n、阈值、投影
- 分块去重与 hybrid 同规划、分里程碑交付(先共用地基,再开 hybrid)
```
---
## 1.1 交付拆分:分块去重 + Hybrid 同规划
> 实现讨论基线。后续排期、拆 PR、评审范围,默认按本节两个交付理解。
### 判断
**可以一起做,而且应该绑在同一条改造主线上**;
但不要理解成「一个 PR 把 hybrid 全做完」。
更准确的表述:
```text
同一条演进线,两层交付:
交付 1:共用地基(证据身份 + chunk 去重 + 检索裁剪 + 端口雏形)
交付 2:hybrid(schema/写入/查询/RRF + 阈值校准)
```
### 为什么必须同规划
两边改的是同一条链上的相邻环节:
```text
检索命中
-> 候选身份(docId / chunkIndex / evidenceKey) ← hybrid 要,去重也要
-> 去重 / 单文档 chunk 上限 ← 分块去重
-> 排序融合(现在规则 / 以后库内 RRF) ← hybrid
-> return-n / Agent 投影
```
若拆开且顺序错误:
| 只做一项 | 后果 |
|---|---|
| 只做 hybrid,不做 chunk 去重 | 多路召回更多同文档片段,仍被 source 级去重吞掉,**hybrid 收益被吃掉** |
| 只做去重,完全不管候选/端口结构 | 能立刻改善,但接 hybrid 时往往还要再改一遍 DTO 与映射 |
因此:
> **分块去重不是 hybrid 的可选项,而是 hybrid 生效的前提。**
> 设计上当一件事;代码上分两个可独立验证的里程碑。
### 必须放进同一批(交付 1 公共地基)
这些强烈建议同一波完成,作为后续实现讨论的最小必选范围:
| 项 | 原因 |
|---|---|
| 候选补 `docId` / `chunkIndex` / `evidenceKey` | 去重 key 与 hybrid hit 身份统一 |
| 后处理按 `docId#chunkIndex`(或 vector id fallback)去重 | 修复「同文档多 chunk 被吞」 |
| `maxChunksPerDocument` | 放开多 chunk 后防止单文档刷屏 |
| `retrieve-k` / `return-n` 分离 | hybrid 扩召回时必需;现在 K=3 也不该三者混用 |
| Projector 去重语义对齐 | 后处理放出的多 chunk,不能在投影阶段再按 `source` 砍成 1 条 |
| `SearchHit` / `RetrievedEvidenceCandidate` 字段对齐 | 避免 hybrid 再引入第三套结果结构 |
| (建议)`KnowledgeSearchPort` 雏形 | 检索调用面先稳定,后续只换实现 |
可称为:
```text
「检索结果身份与裁剪契约」
```
**不上 hybrid 也有独立价值**,并且为交付 2 铺路。
### 不要硬塞进交付 1 的同一 PR
可同规划、建议第二波(交付 2):
| 项 | 原因 |
|---|---|
| 新 collection + sparse/BM25 schema | 数据迁移/重灌,风险独立 |
| 全量重索引 | 耗时长,需单独验证 |
| 打开 `search.mode=hybrid` | 依赖 sparse 数据已就绪 |
| fused score 阈值重标定 | 要 hybrid 跑起来后有样本 |
| 删除旧 SDK 读路径 | 最后做,降低回滚成本 |
否则单个交付会同时碰:业务排序逻辑 + 数据迁移 + 基础设施,评审、回滚、评测都困难。
### 交付 1:chunk 级证据身份 + 去重 + 检索裁剪
**主题:** 让同一次检索内,同文档多个相关 chunk 能作为独立证据存活,并为 hybrid 统一 hit 模型。
**范围(实现讨论默认包含):**
```text
1. RetrievedEvidenceCandidate / EvidenceBlock
- 补 docId、chunkIndex、evidenceKey
2. KnowledgeDocumentRetriever
- 从 metadata 抽取 docId/chunkIndex
- evidenceKey 规则:
docId + "#chunk-" + chunkIndex
fallback: "vector:" + id
fallback: "rank:" + originalRank
3. KnowledgeEvidencePostProcessor
- 去重 key = evidenceKey(不再 source/title 优先)
- maxChunksPerDocument(建议默认 2)
- 同 key 才 merge;merge 不覆盖更高分 content
4. RagResultProjector
- 按 evidence 身份去重(chunk 级 document_id 或显式 chunk 身份)
- 禁止再仅用 source 当“每文档一条”的唯一键
5. 配置
- rag.retrieve-k
- rag.return-n
- rag.max-chunks-per-document
- 逐步弱化/废弃单一 rag.top-k 身兼多职
6. (建议同批)KnowledgeSearchPort / SearchRequest / SearchHit 雏形
- 即使底层暂时仍是 dense-only,调用面先稳定
7. 单测
- 同 doc 两 chunk 都保留
- 同 doc+chunk 真重复只留一条
- 超 maxChunksPerDocument 裁掉低分
- projector 不再误杀同 source 不同 chunk
```
**明确不包含:**
```text
- sparse/BM25 schema
- 全量重灌
- hybridSearch 开关
- 旧 SDK 删除
```
**完成定义(交付 1 Done):**
```text
[ ] 同文档多相关 chunk 可同时出现在内部 evidenceBlocks
[ ] Agent 投影后仍能看到多于 1 条同文档片段(未超预算时)
[ ] retrieve-k / return-n 可配置且行为可测
[ ] 候选身份字段稳定,足够支撑后续 hybrid hit 映射
[ ] 不依赖旧 SDK 新增逻辑
```
### 交付 2:Hybrid 写入 + 查询
**主题:** 在交付 1 的身份/裁剪契约稳定后,打开 dense + sparse/BM25 与库内 RRF。
**范围:**
```text
1. 新 collection schema(dense + sparse/BM25 + 必要标量字段)
2. 写入 dense + sparse,doc_id 级删除与重灌
3. KnowledgeSearchPort 实现 hybrid 模式
4. ranker = RRF(默认)/ Weighted(可配路权)
5. category filter 策略:
- 唯一 domain 时 filtered hybrid
- 低质时 unfiltered 兜底(或双路径轻量合并)
6. 分数语义区分 fused/dense/sparse,重标定 found/relevance
7. 回归评测与延迟对比
8. 冻结并最终删除旧 SDK 读路径
```
**完成定义(交付 2 Done):**
```text
[ ] 可配置 dense | hybrid 切换
[ ] hybrid 默认 RRF,术语类与语义类回归不回退
[ ] filter 误杀有兜底
[ ] 应用层仍按 chunk 身份去重,hybrid 多命中不会被 source 级逻辑误伤
[ ] 新逻辑不再依赖旧 SDK search
```
### 节奏与评审方式
```text
规划:一件事(检索质量主线)
设计评审:按交付 1 + 交付 2 两章看
开发:
先合并交付 1(可独立上线/验证)
再做交付 2(数据迁移 + hybrid 开关)
验收:
交付 1 用“同文档多 chunk”用例
交付 2 用“术语/语义/filter 兜底/延迟”用例
```
### 反模式(实现讨论时直接否决)
```text
❌ 一个大 PR:去重 + schema 重灌 + hybrid 开关 + 删 SDK
❌ 先上 hybrid、后补 chunk 去重
❌ 交付 1 仍按 source 去重,只把 hybrid 分数接进来
❌ 为 hybrid 新建第三套与 candidate/EvidenceBlock 并行的结果模型长期共存
❌ 在旧 SDK search 实现里继续堆 hybrid 细节作为长期方案
```
---
## 2. 现状对照
| 层级 | 当前实现 | Hybrid 需要 |
|---|---|---|
| Collection | `id / vector / content / metadata` | 至少再有 sparse/BM25 文本检索能力 |
| 写入 | `VectorIndexService` 只写 dense | 同步维护 dense + sparse/BM25 |
| 检索门面 | `VectorSearchService`:`sdk \| spring \| auto` | 单端口:`search(query, options)`,内部可 hybrid |
| 旧 SDK 路径 | `MilvusServiceClient.search` | **废弃,不再作为主实现** |
| Spring AI 路径 | `VectorStore.similaritySearch` | 可作过渡 dense 读路径,但 hybrid 能力要单独确认/扩展 |
| 后处理 | 规则 boost + source 去重 | 融合交给库;后处理做裁剪/等级/打包 |
| Agent 投影 | `RagResultProjector` | 基本不动 |
当前关键文件:
```text
写入:
VectorIndexService
DocumentChunkService
VectorEmbeddingService
MilvusClientFactory # schema/index 创建(旧)
读取:
VectorSearchService # 门面,含 sdk/spring 路由
KnowledgeDocumentRetriever
KnowledgeEvidencePostProcessor
LookupKnowledgeTool
配置:
retrieval.vector-store.mode
retrieval.kb-scope
rag.top-k
```
---
## 3. 目标架构(不绑旧 SDK)
```text
┌─────────────────────────┐
upload/init │ KnowledgeWritePort │
chunk + embed -> │ - upsertChunks() │
│ - deleteByDocId() │
└───────────┬─────────────┘
│
▼
Vector DB
dense + sparse/BM25
metadata filters
▲
┌───────────┴─────────────┐
lookup_knowledge │ KnowledgeSearchPort │
query + options-> │ - search() │
│ - mode: DENSE/HYBRID │
└───────────┬─────────────┘
│
▼
KnowledgeDocumentRetriever
│
▼
PostProcess(dedup/chunk cap/threshold/pack)
│
▼
LookupResult / Projector
```
说明:
- **Port** 是应用边界,实现可换成 Spring AI、Milvus 新客户端、或其他封装。
- 旧 `MilvusServiceClient` 检索实现可以暂时留着,但 **新功能不要往里堆**。
- Hybrid 是 `KnowledgeSearchPort` 的一种 mode,不是再开一套平行 tool。
---
## 4. Schema 改造清单
### 4.1 建议逻辑模型
```text
id string PK # chunk 级唯一 id
doc_id string # 文档 id(从 metadata 提升为一等字段更稳)
chunk_index int
content text/varchar # 原始 chunk 正文(给 BM25 / 返回)
title string nullable
breadcrumb string nullable
category string nullable
kb_scope string nullable
dense_vector float vector # embedding(title/path/content)
sparse_vector sparse vector # BM25 或 sparse embedding
metadata json # 兼容扩展字段
```
### 4.2 和现状差异
| 字段 | 现状 | 建议 |
|---|---|---|
| `vector` | 有 | 可改名 `dense_vector`,或保留别名兼容 |
| `content` | 有,仅存储/返回 | 同时作为 BM25 输入文本 |
| `sparse_vector` | 无 | **新增,hybrid 必需** |
| `docId/chunkIndex` | 塞在 JSON metadata | 建议提升为可过滤/可排序字段 |
| `category/kb_scope` | metadata JSON | 建议提升,filter 更稳 |
### 4.3 索引
```text
dense_vector -> 向量索引(COSINE/IP/L2,与 embedding 一致)
sparse_vector -> 稀疏倒排 / BM25 索引
category/kb_scope/doc_id -> 标量过滤索引(如需要)
```
### 4.4 迁移策略
不要幻想“只改 search 方法”:
1. **新建 collection 或新版本 collection**(推荐)
2. 全量重灌知识库(dense + sparse)
3. 双写一段时间(可选)
4. 切换读路径到 hybrid
5. 下线旧 collection / 旧 SDK 读路径
就地改老 collection 风险高:已有数据无 sparse,历史 metadata 形态也不统一。
---
## 5. 写入路径改造清单
### 5.1 需要动的职责
| 类/模块 | 现在 | 改造 |
|---|---|---|
| `DocumentChunkService` | 产出 chunk 正文/title/breadcrumb | 基本可复用 |
| `VectorEmbeddingService` | 只做 dense embed | 保留;sparse/BM25 另算或交给库 |
| `VectorIndexService` | 组装 metadata + insert dense | 升级为 write port 实现:dense+sparse 一并 upsert |
| 删除逻辑 | 按 `metadata.docId` / `_source` 删 | 统一按 `doc_id` 删,避免路径不一致 |
### 5.2 写入时每条 chunk 必须具备
```text
- dense_vector: embed(buildEmbeddingText(chunk))
- sparse 输入: 建议用“可检索文本”
title + breadcrumb + content
而不是只丢 raw content
- doc_id / chunk_index / category / kb_scope
- 稳定 chunk id(doc_id + chunk_index 派生)
```
### 5.3 注意
- embedding 文本可以继续拼 `Title/Path/Content`
- **返回给 Agent 的 content 仍应是原文 chunk**,不要返回 embedding 拼接串
- BM25 文本建议包含 title/breadcrumb,否则专有名词在标题里时字面路会弱
---
## 6. 检索路径改造清单
### 6.1 新检索端口(建议)
不要继续扩:
```text
searchSimilarDocuments(query, topK, category)
```
建议收敛成:
```text
SearchRequest {
query: string
retrieveK: int # 例如 20
returnN: int # 例如 5,可在后处理裁
mode: DENSE | HYBRID
categoryFilter?: string
kbScope?: string
ranker: RRF | WEIGHTED
rrfK: int # 默认 60
weights?: {dense, sparse}
}
SearchHit {
id, docId, chunkIndex
content, title, breadcrumb
source, category
scores: {
fused?, denseRank?, sparseRank?, raw?...
}
metadata
}
```
### 6.2 `VectorSearchService` 怎么演进
短期:
```text
保留门面类名也可
但内部:
- 不再把 sdk 当长期分支
- 增加 hybridSearch(...) 能力
- mode 配置改为:
dense | hybrid
(spring 仅作 dense 兼容实现)
```
中期:
```text
VectorSearchService 实现 KnowledgeSearchPort
旧 sdk 分支删除或仅 test/fallback 开关默认关
```
### 6.3 Hybrid 查询语义
```text
路 A: dense(query_embedding) limit=retrieveK
路 B: bm25/sparse(query_text) limit=retrieveK
可选过滤: category / kb_scope
融合: RRFRanker(k=60) 或 WeightedRanker
输出: top retrieveK/returnN
```
对应我们之前的公式:
```text
RRF_w(d) = Σ w_i / (k + rank_i(d))
```
- 用 RRF:先不调权重
- 用 Weighted:调的是 **dense/sparse 路权**,不是 keyword contains 加分
### 6.4 filtered + unfiltered 还要不要?
还要,但定位变了:
| 能力 | 放哪 |
|---|---|
| dense + bm25 融合 | **库内 hybrid** |
| category filter 开/关 | 应用策略层,可变成两次 hybrid 或 filter 参数 |
| chunk 去重 / 每文档上限 | 应用后处理 |
| found / relevanceLevel | 应用后处理 |
推荐策略:
```text
if 唯一 domain:
hybrid(query, filter=category) # 主路
若低质量:
hybrid(query, filter=null) # 兜底
或并行两条 hybrid 再做一次轻量合并
else:
hybrid(query, filter=null)
```
注意:这里的“两条”是 **filter 策略双路径**,不是再手写一套 dense/bm25 融合。
---
## 7. 和现有 pipeline 的衔接(按类)
### 7.1 基本不动
| 类 | 原因 |
|---|---|
| `LookupKnowledgeTool` | 继续编排 transform → retrieve → post → pack |
| `KnowledgeQueryTransformer` | 仍产 categoryFilter / hints |
| `KnowledgeContextPacker` | 仍做字符预算 |
| `LookupResultAssembler` | 仍组装内部结果 |
| `RagToolAdapter` / `RagResultProjector` | Agent 契约保持稳定 |
### 7.2 要改
| 类 | 改什么 |
|---|---|
| `KnowledgeDocumentRetriever` | 调新 search port;透传 retrieveK/mode;把 docId/chunkIndex 提成候选一等字段 |
| `KnowledgeEvidencePostProcessor` | 弱化“跨路融合”职责;保留 dedup、chunk cap、阈值、轻精排 |
| `VectorSearchService` | 成为 hybrid 入口,去掉对旧 sdk 的依赖增长 |
| `VectorIndexService` | 写入 dense+sparse,统一 doc_id 删除 |
| schema 工厂/初始化 | 新 collection 定义与索引 |
### 7.3 后处理职责重新划分
**交给 Milvus hybrid:**
- dense/sparse 多路召回
- RRF / weighted 融合
- 基础 topK
**留给应用层:**
```text
1. chunk 级去重(docId#chunkIndex)
2. maxChunksPerDocument
3. return-n 裁剪
4. relevanceLevel / isLowQuality
5. 可选轻规则精排(title/breadcrumb 命中)
6. context pack 与 Agent 投影
```
**应降级或删除的:**
```text
把 keyword contains 大额加分当主排序器
在应用层重复实现一套 dense+bm25 分数硬加
```
L0 仍只做:
```text
- 是否启用 category filter
- 轻量精排特征
- trace 解释
```
---
## 8. 配置建议(示意)
```properties
# 检索模式:dense | hybrid
retrieval.search.mode=hybrid
# 旧 sdk 读路径默认关闭(后续删除)
retrieval.legacy-sdk.enabled=false
# 召回/返回分离
rag.retrieve-k=20
rag.return-n=5
rag.max-chunks-per-document=2
# 融合
retrieval.hybrid.ranker=rrf
retrieval.hybrid.rrf-k=60
# 若用 weighted:
# retrieval.hybrid.ranker=weighted
# retrieval.hybrid.weight.dense=1.0
# retrieval.hybrid.weight.sparse=0.8
# filter 策略
retrieval.filter.retry-unfiltered-on-low-quality=true
retrieval.normalization.reference-threshold=0.5
```
说明:
- `rag.top-k` 应逐步废弃,避免“召回/返回/展示”一个参数打天下
- 阈值字段若 hybrid 后分数语义变化,需要重新校准,不能照搬旧 L2 经验值
---
## 9. 分数与阈值:hybrid 后要重标定
当前后处理默认假设:
```text
score ≈ 兼容 L2 距离
normalizeL2 后得到 0~1
```
hybrid 后常见变化:
| 来源 | 语义 |
|---|---|
| dense raw | L2 / cosine |
| sparse/BM25 raw | 另一套 |
| fused RRF | 名次融合分,不是相似度概率 |
因此:
1. `SearchHit` 要区分 `fusedScore` / `denseScore` / `sparseScore`
2. `isLowQuality` 不要直接拿 RRF 分当旧 L2 用
3. 过渡期可:
- 用“是否有命中 + 规则完整性”判断 found
- 或只对 dense 分做阈值,RRF 只负责排序
4. 重新用 15~30 条回归 query 标定
---
## 10. 分阶段落地(推荐)
> 与 **§1.1 交付 1 / 交付 2** 对齐。Phase 编号用于执行拆解;对外沟通优先用两个交付里程碑。
### 交付 1 对应 Phase
#### Phase 0:应用层前提(交付 1 核心)
```text
[ ] chunk 级去重(不要 source 级吞 chunk)
[ ] 候选暴露 docId / chunkIndex / evidenceKey
[ ] retrieve-k / return-n 分离
[ ] maxChunksPerDocument
[ ] Projector 按证据身份去重(不再 source 唯一)
[ ] 明确 legacy-sdk 读路径仅兼容、默认关或冻结
[ ] 单测:同文档多 chunk / 真重复 / 单文档上限
```
#### Phase 1:检索端口收敛(交付 1 建议同批或紧随)
```text
[ ] 定义 KnowledgeSearchPort / SearchRequest / SearchHit
[ ] VectorSearchService 适配该端口(先 dense-only 也可)
[ ] KnowledgeDocumentRetriever 只依赖端口
[ ] 单测用 fake search port,不再绑 SDK 细节
```
**交付 1 出口:** 不上 hybrid 也可合并;hybrid 所需 hit 身份与裁剪契约已稳定。
### 交付 2 对应 Phase
#### Phase 2:写入与 schema 支持 sparse/BM25
```text
[ ] 新 collection schema
[ ] 写入 dense + sparse/BM25 文本
[ ] doc_id 级删除与重灌
[ ] 知识库全量重建脚本/任务
```
#### Phase 3:打开 hybrid 读路径
```text
[ ] search.mode=hybrid
[ ] ranker=rrf
[ ] category filter 策略接入
[ ] low-quality 时 unfiltered 兜底
[ ] trace 记录 dense/sparse/fused 信息(内部)
[ ] 确认 hybrid 多命中仍走 chunk 级去重,不被 source 误伤
```
#### Phase 4:瘦身后处理 + 下线旧路径
```text
[ ] 规则 boost 降为轻精排或可关
[ ] 删除/隔离旧 SDK search 实现
[ ] 校准 found/relevance 阈值
[ ] 回归评测与延迟对比
```
**交付 2 出口:** dense|hybrid 可切换;RRF 默认可用;旧 SDK 检索不再被新逻辑依赖。
---
## 11. 类级改造对照表
| 类 | 优先级 | 动作 | 是否依赖旧 SDK |
|---|---|---|---|
| `KnowledgeDocumentRetriever` | P0 | 接新端口,透传 hybrid 选项,补 chunk 身份 | 否 |
| `KnowledgeEvidencePostProcessor` | P0 | 去重改 chunk 级;融合职责外移 | 否 |
| `LookupKnowledgeTool` | P1 | 使用 retrieve-k/return-n;保留 filter 降级策略 | 否 |
| `VectorSearchService` | P0 | 增加 hybrid;冻结/移除 sdk 增长 | 实现可无 SDK |
| `VectorIndexService` | P0 | dense+sparse 写入,doc_id 删除 | 实现可无 SDK |
| `MilvusClientFactory` | P2 | 仅迁移期维护;新 schema 建议新模块 | 旧 |
| `VectorEmbeddingService` | P1 | 继续 dense;不塞 hybrid 逻辑 | 否 |
| `RagResultProjector` | P2 | 若 document_id 变 chunk 级,同步语义 | 否 |
| Spring AI `VectorStore` | P2 | 可继续承载 dense;hybrid 需单独能力层 | 否 |
---
## 12. 测试清单
### 单元
```text
[ ] RRF 融合结果顺序(可用 fixture,不连库)
[ ] chunk 去重:同 doc 不同 chunk 都保留
[ ] 同 doc 超过 maxChunksPerDocument 被裁
[ ] category filter 低质时走 unfiltered
[ ] SearchHit 字段映射:docId/chunkIndex/content
```
### 集成 / 回归
```text
[ ] 专有名词/错误码 query:hybrid 应优于 pure dense
[ ] 换说法语义 query:hybrid 不低于 pure dense
[ ] 错误 domain filter:unfiltered 兜底仍能找回
[ ] 长文档多 chunk:返回不少于 2 个相关片段(若存在)
[ ] 延迟:hybrid P95 可接受
[ ] 重灌后旧 doc 删除干净,无幽灵 chunk
```
### 兼容
```text
[ ] mode=dense 仍可用(回滚开关)
[ ] Agent 契约字段不破(evidence/document_id/excerpt)
[ ] tool_invocation / trace 仍有 selectedAttempt 与基本检索信息
```
---
## 13. 明确不做的事
1. **继续在旧 SDK `search` 上叠 hybrid 细节当长期方案**
2. **应用层把 dense raw 分和 BM25 raw 分直接相加**
3. **用 L0 contains 大额加分替代库内 RRF**
4. **只改查询、不重灌 sparse 数据**
5. **hybrid 后仍拿旧 L2 阈值硬套 fused score**
6. **让 Agent 直接依赖内部 fused/raw score 字段**(除非契约明确升级)
---
## 14. 和前序讨论的对齐
| 讨论结论 | 在本清单中的落点 |
|---|---|
| K=3 不必先上复杂 rerank | `retrieve-k=20, return-n=5` |
| filtered + unfiltered 有价值 | hybrid 之上的 filter 策略双路径 |
| BM25 是跨维度召回 | schema sparse/BM25 + hybrid 路 |
| 跨路优先 RRF | 库内 `RRFRanker` |
| `w_i` 是路权 | `WeightedRanker` / 配置 weight.dense/sparse |
| L0 只做导航 | 仅影响 filter 与轻精排,不负责主融合 |
| 旧 SDK 后续废弃 | 新开发只走 search/write port,不绑 SDK |
---
## 15. 最小可交付定义(MVP)
MVP 拆成两个可独立验收的里程碑(与 §1.1 一致)。
### MVP-1:分块去重与身份契约(交付 1)
```text
1. 候选/证据具备 docId、chunkIndex、evidenceKey
2. 后处理与投影均按 chunk 身份去重
3. maxChunksPerDocument 生效
4. retrieve-k / return-n 分离且可测
5. 同文档多相关 chunk 在未超预算时可同时到达 Agent
6. 不新增对旧 SDK 的依赖
```
### MVP-2:Hybrid 接入(交付 2)
```text
1. 新 collection 可写入 dense + BM25/sparse
2. 知识库可全量重建
3. lookup_knowledge 可通过配置切换 dense/hybrid
4. hybrid 默认 RRF 融合
5. 应用层 chunk 去重与 return-n 在 hybrid 下仍正确
6. 旧 SDK 检索不再被新逻辑依赖
7. 至少一套回归 query 证明:
- 术语类 query 不回退
- 语义类 query 不回退
- filter 误杀有兜底
```
只有 MVP-1 完成,才建议开始 MVP-2 的数据迁移与开关切换。
---
## 16. 建议的下一步实现顺序(动手时)
实现讨论与排期默认按此顺序:
```text
1. 交付 1 / MVP-1
- chunk 身份
- chunk 去重 + maxChunksPerDocument
- retrieve-k / return-n
- Projector 对齐
- SearchPort 雏形(建议)
2. 交付 2 / MVP-2
- schema + 重灌
- hybrid RRF 读路径
- filter 兜底
- 阈值校准
- 下线旧 SDK 读路径
```
**不要跳过交付 1 直接做 hybrid。**
交付 1 不依赖旧 SDK,不阻塞后续 hybrid,且单独合并就有质量收益。
---
## 17. 后续实现讨论检查清单
开会或开 PR 前,用下面问题对齐范围:
```text
[ ] 本次是交付 1、交付 2,还是仅其中子项?
[ ] 是否改动了证据身份字段(docId/chunkIndex/evidenceKey)?
[ ] 去重 key 是否仍存在 source 级路径?
[ ] retrieve-k 与 return-n 是否仍混用 top-k?
[ ] 是否把 schema 重灌/hybrid 开关误塞进交付 1?
[ ] 是否有新增旧 SDK 依赖?
[ ] 单测是否覆盖“同文档多 chunk”?
[ ] 若已 hybrid:fused score 是否被误当成旧 L2 阈值?
```
@@ -0,0 +1,782 @@
# 诊断 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 链路的讨论,整理一套可落地的判断框架:
```text
1. 候选太少时,复杂 rerank 价值有限
2. 多路召回解决“漏”,精排解决“噪”
3. 跨路不要硬加原始分,优先 RRF 用名次投票
4. L0 / 关键词适合做提示,不适合当最终裁判
5. 权重 w_i 是“路权”,不是文档原始分
```
目标不是证明某一种模型永远最优,而是回答工程上更常见的问题:
> 现在的 K、现有的 L0/L1、现有的规则加分,下一步到底该扩召回、该融合,还是该上精排?
---
## 2. 现状解剖:有“重排”,不等于有“强 Rerank”
### 2.1 一条典型主链路
以模块化 `lookup_knowledge` 为例,主路径大致是:
```text
Agent query
-> KnowledgeQueryTransformer # L0:domain / keyword hint,可选 category filter
-> KnowledgeDocumentRetriever # L1:向量 topK
-> KnowledgeEvidencePostProcessor # 归一化 + 规则 boost + 排序 + 去重
-> KnowledgeContextPacker # 字符预算打包
-> LookupResultAssembler
-> RagResultProjector # 投影成 Agent 可见契约
```
其中“重排”发生在后处理阶段,名字也常叫 rerank,但实现通常是:
```text
baseScore = 向量距离归一化后的相似度
finalScore = baseScore
+ domain_match
+ entity_match
+ keyword_match
+ source_type_prior
再按 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 匹配。可以先用下面这张表做决策:
| 召回规模 K | 更适合做什么 | 不太值得先做什么 |
|---|---|---|
| 3 ~ 5 | 修去重、轻规则、双路径 dense 融合 | Cross-Encoder / 商业 Rerank |
| 15 ~ 30 | 多路召回 + RRF + 轻精排 | 只继续调 keyword boost |
| 50+ | 强 rerank 才划算 | 无评测地堆模型 |
背后的原因很简单:
```text
Rerank 的价值来自:
候选很多、噪声大 → 精排把好的顶到前面
如果好文档根本不在候选里:
再贵的 rerank 也救不回来
```
因此工程上应把两个参数拆开:
```text
retrieve-k # 召回阶段多捞一些,例如 20
return-n # 最终给 Agent 的条数,例如 3~5
```
而不是始终:
```text
topK = 3,召回、排序、返回都是 3
```
**先让该进来的进来,再谈谁排前面。**
---
## 4. 多路召回:先分清“同模态双路径”和“跨维度多路”
“多路召回”不是只有 BM25 + 向量这一种。可以分层理解。
### 4.1 同模态双路径:filtered + unfiltered
很多系统已经有类似逻辑:
```text
若 L0 给出唯一 category:
先 filtered 向量检索
若结果差,再 unfiltered 重试
```
这能工作,但常见实现是 **串行整锅替换**:
- retry 成功后,直接丢掉第一次 filtered 的全部结果
- 没有“两边好结果都保留,再统一排序”
更稳的做法是把它升级为并行/双路融合:
```text
路 A:dense + category filter # 求准
路 B:dense + 无 filter # 求全
去重合并后一起排序
```
#### 为什么值得做?
因为两路解决的是不同失败模式:
| 路径 | 优点 | 风险 |
|---|---|---|
| filtered | 更贴当前域,噪声少 | filter 错了会漏召回 |
| unfiltered | 召回面宽,容错强 | 容易掺进其他域文档 |
典型场景:
- L0 误判成 `mysql`,真正文档在 `redis`
→ 只走 filtered 会空或很差
→ unfiltered 能救回
- L0 判对了 `mysql`
→ filtered 更干净
→ unfiltered 可能带噪声
所以:
> filtered 求准,unfiltered 求全;融合比二选一更稳。
#### 它算多路吗?
算,但要说清楚边界:
```text
这是同一 dense 检索器、不同过滤条件的双路径
属于多路召回的子集
还不是完整的跨维度多路
```
它的主要价值是:
1. 降低 L0 category 误杀
2. 保留“有 filter 时更准”的收益
3. 避免 retry 整锅替换导致好结果被误删
即便暂时不上 BM25,只做这一步,也常常比继续调 keyword boost 更有效。
### 4.2 跨维度多路:Dense + BM25
更完整的多路,通常来自不同相关性维度:
| 路 | 擅长 | 不擅长 |
|---|---|---|
| Dense(向量) | 语义相近、换说法、同义表达 | 罕见专有名词可能漂 |
| BM25 / 关键词 | 错误码、类名、配置键、告警名、精确术语 | 换一种说法就容易漏 |
例子:
```text
用户说:连接池打满了
文档写:HikariCP pending threads high
→ dense 更容易搭上
用户说:SQLSTATE 08001
文档标题就含 08001
→ BM25 / 字面匹配往往更稳
```
因此:
```text
candidates = dense ∪ bm25
```
不是“BM25 替代向量”,而是“补向量漏掉的字面命中”。
### 4.3 L0 能不能当一路召回?
可以,但要降权、控边界。
L0(文档 frontmatter 关键词 / domain hint)适合:
- 决定要不要启用 filtered 路
- 提供精排时的弱特征
- 写出可解释 trace
不适合:
- 关键词命中就直接当高置信事实证据
- 用 L0 粗分主导最终排序
一句话:
> L0 是导航,不是裁判长。
---
## 5. 融合为什么难:不是不会加权,是分数不可比
多路召回之后,第一反应常常是:
```text
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 为什么不稳定
若最终分主要靠:
```text
query.contains(keyword) || keyword.contains(query)
```
再叠加固定加分,会出现:
- 短词误命中
- 泛词普遍加分,区分度下降
- 词表一改,线上排序整体漂移
- 同义词没写进 frontmatter 就完全没帮助
所以:
> 现在的“权重”如果本质是关键词启发式加分,稳定性天然有限。
这不代表规则无用,而是应把它放对层:路内微调或精排特征,而不是跨路主融合器。
---
## 6. RRF:跨路融合时,优先用名次投票
### 6.1 核心思想
RRF(Reciprocal Rank Fusion)的关键思想是:
> 不管各路原始分是什么尺度,只看每条候选在各路里的名次,再投票。
基础公式:
```text
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`
- 多路都靠前的候选,融合分自然更高
- 只在一路偶然靠前的候选,不会单靠绝对分尺度“爆掉”
### 6.2 为什么适合 RAG 多路融合
RRF 特别适合下面这种现实约束:
```text
dense 用 L2
bm25 用 BM25
filtered / unfiltered 虽同度量,但候选集合不同
暂时没有可靠的分数校准器
```
它把问题从:
```text
如何把不可比的分数对齐?
```
简化成:
```text
各路是否都认为它靠前?
```
### 6.3 加权 RRF:w_i 是路权,不是文档分
加权形式:
```text
RRF_w(d) = Σ w_i / (k + rank_i(d))
```
这里的 `w_i` 非常容易被误解。
#### 正确理解
```text
w_i = 第 i 整路的话语权
```
例如:
```text
w_dense = 1.0
w_bm25 = 1.5 # 想让 BM25 更重要,就提高这一路的 w
w_filtered_dense = 1.0
```
含义是:
- BM25 这一路投出的“名次票”更值钱
- 并不是把某个文档的 BM25 原始分 12.7 直接拿来和向量分相加
#### 错误理解
```text
先算 keyword boost 得到一个大杂烩分
再把这个分塞进 RRF
```
或:
```text
RRF = f(L2原始分, BM25原始分, keyword加分)
```
这都不是加权 RRF。
### 6.4 分层心智模型
建议始终按三层理解分数角色:
```text
第 1 层:路内排序
dense 路用向量分排序
bm25 路用 BM25 分排序
各用各的分,互不直接相加
第 2 层:跨路融合
只吃各路 rank
普通 RRF 或加权 RRF(w_i 为路权)
第 3 层:精排(可选)
对融合后的 topM 再打分
这里才适合规则特征或 Cross-Encoder
```
一句话记住:
> 原始分只负责路内排名;RRF 只负责跨路投票;w_i 只调节哪一路更值钱;精排才做最终挑剔。
### 6.5 手算例子
设:
```text
k = 60
w_dense = 1.0
w_bm25 = 1.5
```
候选 A:
- dense 第 1 名
- bm25 第 5 名
```text
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 名
```text
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 后,可以用更稳的字段级规则做二次排序:
```text
title 命中 > breadcrumb 命中 > body 覆盖
精确术语 > 泛词
runbook / case 来源轻微加分
与已选证据过相似则降权(MMR 思路)
```
注意:这里的规则特征,最好作用于 **融合后的候选精排**,而不是重新发明一套跨路原始分加法。
### 7.2 模型精排
当 `retrieve-k` 到 15~30,且评测证明融合后噪声仍高时,再考虑:
```text
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 建议的目标形态
```text
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
```
### 8.2 分阶段推进
#### Phase 0:先修前提
否则后面多路都会被吞:
1. 去重 key 从“文档/source”改为 `docId + chunkIndex`(fallback 可用向量主键)
2. 配置拆分:
```properties
rag.retrieve-k=20
rag.return-n=5
rag.max-chunks-per-document=2
```
3. 明确 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
```text
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 排序问题时,按这个顺序问:
```text
Q1. 正确答案是否经常连候选池都进不来?
是 → 先扩召回 / 多路,不要先上复杂 rerank
Q2. 是否存在 filter 误杀?
是 → filtered + unfiltered 双路径融合
Q3. 是否大量依赖专有名词、错误码、配置键?
是 → 加 BM25 / 关键词路
Q4. 多路分数是否不可比?
是 → RRF,而不是原始分硬加
Q5. 融合后 topM 仍噪声大,且 K 已经够大?
是 → 再上规则精排或模型 rerank
Q6. 有没有固定回归集?
没有 → 先建评测,再谈“最优权重”
```
对应到一句话策略:
> **先扩召回,再用名次融合,最后才模型精排。**
---
## 12. 结语
RAG 排序讨论很容易变成模型名词竞赛。但在诊断 Agent 这种真实系统里,更常见的瓶颈是:
- 候选太少
- 过滤过猛
- 分数不可比
- 去重过粗
- 启发式加分承担了不该承担的最终裁决
RRF 的价值,不只是一个公式,而是一种工程态度:
```text
承认各路分数不可比
让每路先做好自己的排序
再用名次投票决定谁更值得进入下游
```
加权 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:最小配置示例(示意)
```properties
# 召回与返回分离
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,能不能在系统里真正跑起来。