Files
SuperBizAgent-java/mvp/engineering/rag/Milvus-Hybrid接入清单.md
T
zhuyongxin 584639fa2a docs(mvp): move engineering notes under mvp/engineering
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.
2026-07-29 10:49:45 +08:00

932 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Milvus Hybrid Search 接入对照清单
**日期**:2026-07-27
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
**关联文档**:
- `RAG排序-多路召回与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)
```
```mermaid
flowchart TB
subgraph gaps["写作时的缺口"]
G1[无 sparse/BM25 schema]
G2[写入只有 dense]
G3[单路 similaritySearch]
G4[后处理伪融合]
G5[source 级去重吞 chunk]
end
subgraph principles["接入原则"]
P1[端口抽象 · 不堆旧 SDK]
P2[融合下沉向量库 RRF]
P3[应用层:filter/dedup/return-n/投影]
P4[先地基后 hybrid 分里程碑]
end
gaps --> principles
```
## 实现状态(2026-07-27,后续已完成)
| 里程碑 | 状态 | 说明 |
|---|---|---|
| 交付 1 chunk 身份/去重/SearchPort | **已完成并归档** | `2026-07-27-rag-chunk-evidence-identity-dedup` |
| 交付 2a 应用层 multi-path+RRF | **已完成并归档** | `2026-07-27-rag-hybrid-search-rrf`(已被 2b 取代为生产路径) |
| 交付 2b 真 BM25 hybrid + 废弃 SDK | **已完成并归档** | `2026-07-27-rag-bm25-hybrid-drop-sdk` |
```mermaid
flowchart LR
D1[交付1<br/>chunk 身份/去重] --> D2a[交付2a<br/>app RRF]
D2a --> D2b[交付2b<br/>真 BM25 hybrid]
D2b --> NOW[生产:V2 store + hybrid mode]
```
**当前生产知识路径:**
```text
VectorIndexService / VectorSearchService
-> MilvusHybridKnowledgeStore (MilvusClientV2 only)
collection: milvus.collection (default biz)
mode: retrieval.search.mode = dense | hybrid
hybrid: dense ANN + BM25 sparse ANN + RRFRanker
```
```mermaid
flowchart TB
subgraph write["写入"]
UP[upload / init / rebuild] --> VIS[VectorIndexService]
VIS --> STORE[MilvusHybridKnowledgeStore]
end
subgraph read["检索"]
LK[lookup_knowledge] --> VSS[VectorSearchService]
VSS -->|dense| SD[searchDense]
VSS -->|hybrid| SH[searchHybrid + RRF]
SD --> STORE
SH --> STORE
end
STORE --> COL[(Milvus collection biz<br/>dense + BM25 schema)]
```
**运维必做:** 全量重灌知识库到 hybrid schema collection(配置名以 `milvus.collection` 为准,常见 `biz`);旧纯 dense collection 不能直接当 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 投影
```
```mermaid
flowchart TB
HIT[检索命中] --> ID[候选身份<br/>docId / chunkIndex / evidenceKey]
ID --> DEDUP[chunk 去重 · 每文档上限]
DEDUP --> FUSE[排序融合 · 库内 RRF]
FUSE --> RET[return-n]
RET --> PROJ[Agent 投影]
ID -.->|交付1 地基| D1[chunk identity]
DEDUP -.-> D1
FUSE -.->|交付2| D2[hybrid]
```
若拆开且顺序错误:
| 只做一项 | 后果 |
|---|---|
| 只做 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
```
```mermaid
flowchart TB
subgraph write_port["写入边界"]
W[KnowledgeWritePort<br/>upsert / deleteByDocId]
end
subgraph search_port["检索边界"]
S[KnowledgeSearchPort<br/>mode DENSE / HYBRID]
end
W --> VDB[(Vector DB<br/>dense + BM25 sparse<br/>metadata filter)]
S --> VDB
UP[upload/init] --> W
LK[lookup_knowledge] --> S
S --> RET[DocumentRetriever]
RET --> POST[PostProcess<br/>dedup / cap / threshold / pack]
POST --> PROJ[LookupResult / Projector]
PROJ --> AG[Agent]
```
说明:
- **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 派生)
```
```mermaid
flowchart LR
CHUNK[DocumentChunk] --> EMB[dense embed]
CHUNK --> ST[search_text<br/>title+path+content]
CHUNK --> META[docId/chunkIndex<br/>category/kb_scope]
EMB --> ROW[upsert row]
ST --> ROW
META --> ROW
ROW --> FN[BM25 Function<br/>search_text → sparse]
ROW --> COL[(collection)]
FN --> COL
```
### 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
}
```
```mermaid
flowchart TB
REQ[SearchRequest<br/>query · retrieveK · mode<br/>filter · rrfK] --> PORT[KnowledgeSearchPort]
PORT -->|DENSE| D[dense ANN only]
PORT -->|HYBRID| H[dense + BM25 + RRF]
D --> HIT[SearchHit 列表<br/>id/docId/chunk · content · ranks]
H --> HIT
HIT --> APP[后处理 / 投影]
```
### 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 阈值?
```