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:
@@ -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,能不能在系统里真正跑起来。
|
||||
Reference in New Issue
Block a user