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.
This commit is contained in:
zhuyongxin
2026-07-29 10:49:45 +08:00
parent bdac35567c
commit 584639fa2a
18 changed files with 155 additions and 163 deletions
+56 -101
View File
@@ -1,120 +1,75 @@
# 文档索引
## 📂 目录结构
**更新日期**:2026-07-29
```
## 目录结构
```text
docs/
├── README.md # 项目文档总览
├── INDEX.md # 本索引文件
│
├── learning/ # 📚 学习笔记(个人学习理解)
│ ├── 00-项目学习路径.md
│ ├── 01~08-*.md # 按学习顺序编号
│ └── README.md
│
├── analysis/ # 🔍 分析笔记(代码/问题分析)
│ ├── essence-report-*.md
│ ├── explore-report.md
│ ├── chunking-issues-analysis.md
│ └── 功能分析报告.md
│
├── reports/ # 📝 临时报告(修复/验证报告)
│ ├── 修复报告-*.md
│ ├── 验证报告-*.md
│ └── 日志配置完成总结.md
│
└── guides/ # 📖 指南文档
└── 日志配置与分析指南.md
├── INDEX.md # 本索引
├── learning/ # 早期学习笔记(可能过时)
├── analysis/ # 早期代码/问题分析
├── reports/ # 历史修复/验证报告
└── guides/ # 操作指南
mvp/ # 现行 MVP 文档(主入口)
├── architecture/ # 现行架构:系统现在怎么跑
├── engineering/ # 工程纪要:问题 / 决策 / E2E
├── issues/ # 未完成事项
├── tables/ # 表结构
├── demo/ # Demo
└── eval/ # 诊断评测材料
```
**⚠️ 注意:MVP 架构设计文档已移至项目根目录 `../mvp/`**
查看 [mvp/README.md](../mvp/README.md) 了解 MVP 架构、数据库设计、实施计划等。
**现行架构、工程纪要、表结构、Issue 均以 [mvp/README.md](../mvp/README.md) 为准。**
本目录 `learning/` / `analysis/` / `reports/` 偏早期学习与历史记录,**可能与当前实现不一致**。
---
## 🚀 快速导航
## 快速导航
### 我是新人/学习者
1. [项目学习路径](learning/00-项目学习路径.md) - 从这里开始
2. [learning/README.md](learning/README.md) - 学习笔记索引
3. 按编号顺序阅读 `learning/` 目录下的文档
### 我是开发者(优先)
### 我是开发者
👉 **MVP 架构设计文档已移至 `../mvp/`**
1. [mvp/README.md](../mvp/README.md) — MVP 总入口
2. [mvp/architecture/](../mvp/architecture/) — 现行架构
3. [mvp/engineering/](../mvp/engineering/) — 工程纪要(RAG / 诊断 E2E)
4. [mvp/issues/](../mvp/issues/) — 活跃 Issue
请查看 [mvp/README.md](../mvp/README.md) 了解:
- MVP 架构设计
- 数据库设计和表结构
- 实施规划(Phase 1/2/3)
- 会话管理设计
### RAG 工程纪要(已迁入 mvp)
### 我要查看分析报告
1. [分析笔记目录](analysis/) - 代码分析和问题分析
2. [临时报告目录](reports/) - 修复和验证报告
1. [RAG 排序:多路召回与 RRF](../mvp/engineering/rag/RAG排序-多路召回与RRF.md)
2. [Hybrid 之后的 qualityScore 与后处理](../mvp/engineering/rag/RAG-Hybrid质量分与后处理.md)
3. [Agent 如何读 relevance_level](../mvp/engineering/rag/RAG-Agent如何读relevance_level.md)
4. [RAG 离线评测:基线设计](../mvp/engineering/rag/RAG离线评测-基线设计.md)
5. [Milvus hybrid 接入清单](../mvp/engineering/rag/Milvus-Hybrid接入清单.md)
6. [RAG 审计补丁 E2E](../mvp/engineering/rag/RAG审计补丁-stepid-query-E2E验收.md)
7. 架构对照:[RAG 知识检索架构](../mvp/architecture/RAG知识检索架构.md)、[RAG 检索可观测性与审计](../mvp/architecture/RAG检索可观测性与审计.md)
### RAG 设计讨论(docs 根目录)
1. [RAG 排序:多路召回与 RRF](RAG排序-多路召回与RRF.md) - K、融合、L0 边界
2. [Hybrid 之后的 qualityScore 与后处理](RAG-Hybrid质量分与后处理.md) - 上一代问题、L2 伪装、统一归一化
3. [Agent 如何读 relevance_level](RAG-Agent如何读relevance_level.md) - 粗相关度标签的含义与误读
4. [RAG 离线评测:讨论、设计与落地](RAG离线评测-基线设计.md) - Golden/Fixture、流程图、hybrid 对齐与闸门修复
5. [Milvus hybrid 接入清单](Milvus-Hybrid接入清单.md)
6. [RAG Trace / 审计(架构)](../mvp/architecture/RAG检索可观测性与审计.md) - 请求内 trace、tool_invocation、Trace API
### 诊断全流程(已迁入 mvp)
### 诊断全流程(E2E 导读)
1. [一次诊断到底发生了什么](一次诊断全流程-E2E导读.md) - SUCCESS 全流程:阶段拆解、token、timeline、字段词典
2. [RAG 审计补丁 E2E:step_id + query](RAG审计补丁-stepid-query-E2E验收.md) - 审计字段 live 验收(含业务 FALLBACK 样本)
1. [一次诊断到底发生了什么](../mvp/engineering/diagnosis/一次诊断全流程-E2E导读.md)
### 我是新人 / 想看早期学习笔记
1. [项目学习路径](learning/00-项目学习路径.md)
2. [learning/README.md](learning/README.md)
3. 注意:内容可能过时,实现以 `mvp/architecture` 为准
### 分析 / 历史报告 / 指南
- [analysis/](analysis/) — 早期分析
- [reports/](reports/) — 历史修复与验证报告
- [guides/日志配置与分析指南.md](guides/日志配置与分析指南.md)
---
## 📚 学习笔记 (learning/)
## 文档维护
按学习顺序编号,建议按顺序阅读:
1. [00-项目学习路径](learning/00-项目学习路径.md)
2. [01-AI-Ops-核心设计-Essence报告](learning/01-AI-Ops-核心设计-Essence报告.md)
3. [02-outputKey-深度解析](learning/02-outputKey-深度解析.md)
4. [03-核心疑问解答](learning/03-核心疑问解答.md)
5. [04-RAG-分块策略-Essence报告](learning/04-RAG-分块策略-Essence报告.md)
6. [05-文件上传自动索引-Essence报告](learning/05-文件上传自动索引-Essence报告.md)
7. [06-RAG查询流程-Essence报告](learning/06-RAG查询流程-Essence报告.md)
8. [07-Tool定义方式对比与优化](learning/07-Tool定义方式对比与优化.md)
9. [08-MethodToolCallback-vs-ToolCallingManager深度分析](learning/08-MethodToolCallback-vs-ToolCallingManager深度分析.md)
---
## 🔍 分析笔记 (analysis/)
代码分析和问题分析文档:
- [essence-report-rag.md](analysis/essence-report-rag.md)
- [essence-report-rag-chunking.md](analysis/essence-report-rag-chunking.md)
- [explore-report.md](analysis/explore-report.md)
- [chunking-issues-analysis.md](analysis/chunking-issues-analysis.md)
- [功能分析报告.md](analysis/功能分析报告.md)
---
## 📝 临时报告 (reports/)
修复报告和验证报告:
- [修复报告-多轮对话时间查询缓存问题](reports/修复报告-多轮对话时间查询缓存问题.md)
- [验证报告-时间查询问题](reports/验证报告-时间查询问题.md)
- [日志配置完成总结](reports/日志配置完成总结.md)
---
## 📖 指南文档 (guides/)
- [日志配置与分析指南](guides/日志配置与分析指南.md)
---
## 🔄 文档维护
- **学习笔记** 放在 `learning/` 目录,按编号顺序命名
- **分析笔记** 放在 `analysis/` 目录
- **临时报告** 放在 `reports/` 目录
- **指南文档** 放在 `guides/` 目录
- **MVP 架构设计** 已移至项目根目录 `../mvp/`(包含架构、数据库、实施计划)
| 类型 | 位置 |
|---|---|
| 现行架构 | `mvp/architecture/` |
| 工程纪要(问题/决策/E2E) | `mvp/engineering/` |
| Issue | `mvp/issues/` |
| 表结构 | `mvp/tables/` |
| 早期学习 | `docs/learning/`(归档向,不充当现行规范) |
| 操作指南 | `docs/guides/` |
-931
View File
@@ -1,931 +0,0 @@
# Milvus Hybrid Search 接入对照清单
**日期**:2026-07-27
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
**关联文档**:
- `docs/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 阈值?
```
-396
View File
@@ -1,396 +0,0 @@
# Agent 如何读 `relevance_level`:它是什么、不是什么
**日期**:2026-07-28
**范围**:`lookup_knowledge` 投影给 Agent 的粗粒度相关度标签
**读者**:要在 Diagnosis Agent / 工具契约里正确使用知识库结果的工程与提示词同学
**关联**:
- ACI 契约:`RagToolResult.relevance_level` / `RagRelevanceLevel`
- 计算:`KnowledgeEvidencePostProcessor` → `qualityScore` 阈值
- 质量统一:`docs/RAG-Hybrid质量分与后处理.md`
- 多路与 RRF:`docs/RAG排序-多路召回与RRF.md`
- **运行时 Trace / 审计**:`mvp/architecture/RAG检索可观测性与审计.md`
---
## 1. 一句话定义
**`relevance_level` 是对「这一次 `lookup_knowledge` 调用整体有多相关」的粗档标签,不是某一条 evidence 的分数,也不是 0~1 的相似度。**
Agent 真正写诊断、做引用时,仍应以 `evidence[]` 里的 excerpt 为准;`relevance_level` 只帮助判断:**这批评据大概有多硬、还要不要再查。**
```mermaid
flowchart LR
subgraph tool["lookup_knowledge 结果"]
ES[evidence_status]
EV[evidence excerpt]
RL[relevance_level]
TR[truncated]
end
ES --> DEC{Agent 决策}
EV --> DEC
RL --> DEC
TR --> DEC
DEC --> A1[写结论 / 引用]
DEC --> A2[再查 / 换工具]
DEC --> A3[证据不足降级]
```
---
## 2. 它出现在哪里
成功(或有结果)的知识库工具投影里,典型形状:
```json
{
"evidence_status": "EVIDENCE_FOUND",
"tool_call_id": "call-…",
"query": "用户/Agent 的检索句",
"evidence": [
{
"document_id": "doc#chunk-0",
"source": "…",
"title": "…",
"breadcrumb": "…",
"excerpt": "…"
}
],
"returned_count": 3,
"relevance_level": "PRECISE",
"truncated": false
}
```
要点:
- JSON 字段名是 **`relevance_level`**(snake_case)
- Java 枚举:`RagRelevanceLevel`(`PRECISE` / `HIGHLY_RELEVANT` / `REFERENCE`)
- 无可用证据或质量不够时,字段常为 **null / 省略**(`NON_NULL`)
- Agent **看不到** raw L2、RRF 分、`retrievalTrace`、`rerankTrace`(ACI 有意裁掉)
```mermaid
flowchart TB
subgraph internal["内部 LookupResult(审计/调试)"]
QS[qualityScore / topSimilarity]
RT[retrievalTrace / rerankTrace]
CP[contextPack 全文]
SC[raw score / scoreLabel]
end
subgraph project["RagResultProjector"]
P[裁剪与规范化]
end
subgraph agent["Agent 可见 RagToolResult"]
A1[evidence_status]
A2[tool_call_id / query]
A3[evidence excerpt 列表]
A4[relevance_level 可选]
A5[returned_count / truncated]
end
internal --> P --> agent
```
---
## 3. 枚举值怎么理解
| 值 | 产品语义 | Agent 侧更合理的用法 |
|----|----------|----------------------|
| **PRECISE** | 整体很贴:当前 top 证据质量高,继续同题检索不太可能更准 | 优先依据 `evidence[]` 组织结论;避免无意义的重复 `lookup_knowledge` |
| **HIGHLY_RELEVANT** | 高度相关(枚举保留) | 与 PRECISE 类似,略保守表述即可 |
| **REFERENCE** | 可作参考,但不到「已精准命中」 | 可引用,但结论留余地;缺维度时换 query 再查或叠日志/指标 |
| **null / 不出现** | 没有可报的粗相关档(无证据或 top 质量偏低) | **不要**当成知识库已证实;按证据不足处理 |
### 和 `evidence_status` 的分工
| 字段 | 回答的问题 |
|------|------------|
| `evidence_status` | 这次有没有合法、可引用的证据(如 `EVIDENCE_FOUND` / `NO_EVIDENCE`) |
| `relevance_level` | **有证据时**,整体有多贴(粗档) |
| `evidence[]` | 具体可以引用哪些片段 |
没有证据时,不应指望靠 `relevance_level`「升级」出结论;契约上也不会用 level 把空结果扮成有证据。
```mermaid
flowchart TB
ES{evidence_status}
ES -->|NO_EVIDENCE| N1[不要当知识库已证实]
ES -->|EVIDENCE_FOUND| RL{relevance_level}
RL -->|PRECISE| U1[优先引用 excerpt · 少重复检索]
RL -->|REFERENCE| U2[可引用 · 结论留余地]
RL -->|null / 缺省| U3[有块但质量偏低 · 慎用强结论]
RL -->|HIGHLY_RELEVANT| U4[与 PRECISE 类似 · 略保守]
```
---
## 4. 它是怎么算出来的(实现口径)
### 4.1 只看「本轮第一名」的 qualityScore
后处理在完成排序、去重、截断之后:
```text
取 originalRank 最优(排序后第一条)的 qualityScore
≥ highly-relevant-threshold (默认 0.75) → PRECISE
≥ reference-threshold (默认 0.5) → REFERENCE
否则 / 无可用证据 → null
```
配置(`application.yml`):
```yaml
retrieval:
normalization:
max-l2-distance: 2.0
highly-relevant-threshold: 0.75
reference-threshold: 0.5
```
因此:
- level 描述的是 **整次调用的 top 质量**,不是每条 evidence 各打一档
- 列表里第 2、第 3 条即使偏弱,只要 top1 够高,整次仍可能是 `PRECISE`
```mermaid
flowchart TB
RET[检索有序候选] --> POST[后处理:保 originalRank · 去重 · 截断]
POST --> TOP[取排序后第一条 qualityScore]
TOP --> T1{≥ 0.75?}
T1 -->|是| PRECISE[PRECISE]
T1 -->|否| T2{≥ 0.5?}
T2 -->|是| REF[REFERENCE]
T2 -->|否| NULL[null / 不报档]
```
### 4.2 qualityScore 从哪来(和检索 mode 绑定)
统一经 `RetrievalScoreNormalizer.toQualityScore`:
| `retrieval.search.mode` | top qualityScore 含义 |
|-------------------------|------------------------|
| **dense** | top1 的 L2 归一化:约 `1 - L2 / maxL2Distance` |
| **hybrid** | **优先**同 id 的 `denseDistance`(绝对 L2 质量,供闸门/level);无 dense 邻域时 **rank 回退** |
```mermaid
flowchart LR
subgraph dense_mode["mode=dense"]
L2[score = L2] --> QD[quality = 1 - L2/max]
end
subgraph hybrid_mode["mode=hybrid"]
RRF[RRF 序 = originalRank] --> SORT[列表顺序]
DD[denseDistance 可选] --> QH{有 dense?}
QH -->|是| QL[quality = L2 归一化]
QH -->|否| QR[quality = rank 映射]
end
QD --> LV[relevance_level]
QL --> LV
QR --> LV
```
读 level 时注意:
> **dense 下的 PRECISE ≈「向量足够近」**
> **hybrid 下的 PRECISE ≈「top1 的绝对/回退 quality 跨过了 0.75」**;排序仍跟 RRF,不是「又变回只信 L2 排序」。
不要把 hybrid 的 level 读成与 dense **完全同一把尺子**,但也不要再假设「rank1 永远 PRECISE」(在附带 denseDistance 后,远邻 decoy 可以很低分并触发 filter fallback)。
### 4.3 关于 HIGHLY_RELEVANT
枚举和旧文档里仍有三档。历史上大致是:
```text
高分 + L0 hint 支撑 → PRECISE
高分但无 hint → HIGHLY_RELEVANT
中等分 → REFERENCE
```
质量分统一之后,当前实现是:**≥ 0.75 直接 PRECISE**,不再要求 L0 contains 才能精准。
因此运行时 **很少再单独产出 HIGHLY_RELEVANT**;读旧 trace / 旧快照时仍可能见到。
---
## 5. Agent 应该怎么读(建议协议)
### 5.1 推荐读法
```text
1. 先看 evidence_status
2. 再读 evidence[] 的 excerpt(唯一可引用正文)
3. 用 relevance_level 调节「敢多敢少」与「要不要再查」
```
```mermaid
flowchart TB
START[收到 RagToolResult] --> S1{evidence_status}
S1 -->|无证据| FAIL[不编造 · 换工具或安全降级]
S1 -->|有证据| S2[精读 evidence excerpt]
S2 --> S3{relevance_level}
S3 -->|PRECISE| C1[结论可较硬 · 少重复同 query 检索]
S3 -->|REFERENCE| C2[结论留余地 · 可换问法或叠日志指标]
S3 -->|缺省| C3[慎用强结论 · 优先补查]
C1 --> CITE[引用必须落在 excerpt]
C2 --> CITE
C3 --> CITE
```
| 组合 | 建议行为 |
|------|----------|
| FOUND + PRECISE | 以 excerpt 为主写结论;少重复同 query 检索 |
| FOUND + REFERENCE | 可引用,表述保守;缺关键事实则改写 query 或换工具 |
| FOUND 但 level 空 | 有块但质量闸门偏低:慎用强结论,优先补查 |
| NO_EVIDENCE | 不编造知识库依据;走其他证据工具或安全降级 |
### 5.2 明确不要这样读
1. **不要当逐条相关度**
没有 `evidence[i].relevance_level`;不能说「第 2 条是 REFERENCE」。
2. **不要当连续分数**
没有 0.83;只有粗档。不要在推理里假装有精确分。
3. **不要在 hybrid 下当成绝对语义相似度**
序数 quality 下 PRECISE 很常见,表示「本轮第一够格」,不等于「全局语义必近」。
4. **不要代替 excerpt 引用**
level 不能当证据正文;Gatekeeper / EvidenceGuard 认的是可核对片段与引用约束。
5. **不要和 Harness 验真混为一谈**
level 是检索侧粗标;工具生命周期、`evidence_status`、守卫校验是另一层。
6. **不要用它驱动跨 mode 对比**
同一 query 切 dense/hybrid 时,比命中集合与排名;别只比「是不是都 PRECISE」。
---
## 6. Agent 看不见、但会影响 level 的内部量
便于排查「为什么突然全是 PRECISE / 总是 null」:
| 内部量 | 作用 | Agent 是否可见 |
|--------|------|----------------|
| `qualityScore` / `topSimilarity` | 定 level、低质 retry | 否 |
| `originalRank` | 排序权威;hybrid quality 输入 | 否 |
| `score` + `scoreLabel` | dense=L2 / hybrid=融合侧 | 否 |
| L0 domain/keyword | 现仅 hitReasons 解释,不改序、不抬 level | 否(reasons 也可能被投影裁掉) |
| `completenessHint` | 内部完整度文案 | 通常否 |
| category filter + unfiltered retry | 低质时可能换一批 evidence 再定 level | 过程 trace 否 |
投影原则(ACI):模型只要能理解与引用结果;**不给 raw score、阈值、trace。**
```mermaid
flowchart TB
subgraph pipe["检索管道内部"]
STORE[Milvus hybrid/dense]
NORM[toQualityScore]
POST[PostProcessor]
STORE --> NORM --> POST
end
POST --> LV[relevance_level]
POST --> EB[evidenceBlocks]
POST --> TR[traces · 通常不投影]
EB --> PROJ[RagResultProjector]
LV --> PROJ
PROJ --> AGENT[Agent Observation]
```
---
## 7. 和相邻概念的边界
```text
evidence_status 有没有证据
relevance_level 有的话有多贴(粗)
evidence[].excerpt 贴在哪一段文字上(细、可引用)
truncated 列表是否被预算截断(可能还有更好的没展示)
information_gain 等 诊断环路里「这轮工具对任务有没有增益」(另一契约)
```
```mermaid
flowchart TB
subgraph fields["同一次工具结果里的分工"]
ES[evidence_status<br/>有没有]
RL[relevance_level<br/>有多贴·粗]
EX[excerpt<br/>说什么·细]
TC[truncated<br/>是否被截断]
end
ES --> USE[Agent 使用]
RL --> USE
EX --> USE
TC --> USE
USE --> NOTE[结论锚在 excerpt<br/>level 只调力度]
```
`truncated=true` 时:即使 `PRECISE`,也只说明 **已返回子集里的 top 很强**,不保证库内没有更相关却被截掉的块。
---
## 8. 提示词 / 产品文案可用的短说明
可直接给模型或文档的精简版:
```text
relevance_level 是本次知识库检索的整体相关度粗标:
- PRECISE:当前证据整体很贴,优先引用 evidence 写结论,避免无意义重复检索
- REFERENCE:仅供参考,结论需留余地,必要时换问法或改用其他工具
- 缺省:不要把本次结果当作高置信知识库证实
务必以 evidence 中的 excerpt 为唯一引用依据;不要编造未出现的文档内容。
在 hybrid 检索下,PRECISE 更多表示「本轮排序第一档」,不是精确相似度分数。
```
---
## 9. 常见误读示例
| 误读 | 更正 |
|------|------|
| 「PRECISE 所以三条 evidence 都精准」 | 只保证 top 质量跨线;其余条只是同批返回 |
| 「没有 relevance_level 就是工具失败」 | 更可能是无证据或质量偏低;看 `evidence_status` |
| 「hybrid 全是 PRECISE 说明召回完美」 | 可能只是 rank1→quality=1.0 的档位特性 |
| 「REFERENCE 的 excerpt 不能引用」 | 可以引用,但结论强度应下调 |
| 「level 高就可以跳过 excerpt」 | 不可;引用与验真仍看正文 |
---
## 10. 结语
`relevance_level` 是检索链路送给 Agent 的 **粗粒度驾驶辅助**:
- 告诉模型这批评据大概硬不硬
- **不**替代 excerpt,**不**暴露打分细节,**不**等于逐条标注
在 dense 模式下,它更接近「向量有多近」;
在 hybrid 主路径下,它更接近「本轮融合第一名是否跨过质量门槛」。
读的时候记住三句即可:
```text
1. 先 status,再 excerpt,最后才看 level
2. level 管「敢多敢少」,excerpt 管「说了什么」
3. hybrid 的 PRECISE ≠ 绝对语义满分
```
---
## 附录:代码与规格锚点
| 项 | 位置 |
|----|------|
| Agent 结果契约 | `RagToolResult` / `RagRelevanceLevel` |
| 投影 | `RagResultProjector` |
| 等级计算 | `KnowledgeEvidencePostProcessor.computeRelevance` |
| 质量分 | `RetrievalScoreNormalizer` |
| 规格 | `openspec/specs/aci-evidence-tool-contracts`、`rag-retrieval-quality-score` |
| 架构 | `mvp/architecture/RAG知识检索架构.md` §9 |
-592
View File
@@ -1,592 +0,0 @@
# 混合检索上线之后:为什么还要统一 qualityScore,以及上一代后处理错在哪
**日期**:2026-07-28
**范围**:hybrid 检索后的分数语义、后处理排序、相关度闸门、scoreLabel 约定
**读者**:已经(或准备)上 dense+BM25+RRF,却发现「召回变了、质量判断还拧着」的工程同学
**关联实现**:
- `lookup_knowledge` 模块化链路
- `MilvusHybridKnowledgeStore`(dense / hybrid)
- `RetrievalScoreNormalizer` / `KnowledgeEvidencePostProcessor`
- OpenSpec / devflow:`rag-quality-score-unify`
- 前置讨论:`docs/RAG排序-多路召回与RRF.md`
- 架构:`mvp/architecture/RAG知识检索架构.md` §6
---
## 1. 引言:融合排好了序,不等于质量链路闭环了
上一篇文章(《诊断 Agent 场景下的 RAG 排序:从 K=3 规则加分,到多路召回与 RRF》)回答的是:
> 候选太少时别急着上精排;跨路不要硬加原始分;优先 RRF;L0 只做导航。
那一轮讨论之后,工程上陆续落地了:
1. **chunk 级证据身份与去重**(`docId#chunkIndex`,同文档多片段可并存)
2. **真 hybrid**:Milvus 服务端 dense ANN + BM25 sparse + `hybridSearch` + `RRFRanker`
3. **单一知识后端**(`MilvusClientV2`),去掉 sdk/spring 多路由主路径
4. **mode 开关**:`hybrid` 线上主路径,`dense` 同库对照评测
主缺口从「假 hybrid / 粗去重」变成了另一件事:
```text
库内:RRF 已经决定谁先谁后
应用:后处理仍假装每条 score 都是 L2
再用 L0 关键词 contains 加分改序
```
于是出现一种很拧的现象:
- 检索层已经是 **混合检索的世界**
- 质量层还活在 **单路 dense + 规则 boost 的世界**
本文记录的,就是这次对「拧」的拆解、拍板与落地口径:
**统一 qualityScore,废止 hybrid 的 L2 伪装,去掉关键词 boost 改序。**
目标不是再推一套更复杂的模型,而是回答:
> hybrid 上线之后,排序权威和质量闸门到底听谁的?后处理还该不该拿关键词打分?
```mermaid
flowchart TB
subgraph retrieval["检索层 · 已 hybrid"]
Q[query] --> D[dense ANN]
Q --> B[BM25 sparse]
D --> RRF[hybridSearch + RRF]
B --> RRF
RRF --> ORD[RRF 序]
end
subgraph post_old["后处理 · 仍 L2 世界"]
ORD --> FAKE[伪装 / 回填 L2]
FAKE --> BOOST[关键词 boost 改序]
BOOST --> GATE[阈值 / level / retry]
end
post_old --> PAIN[排序与闸门拧巴]
```
---
## 2. 上一代(hybrid 刚落地时)到底长什么样
### 2.1 检索侧:已经是真混合
`mode=hybrid` 时大致是:
```text
query
├─ dense ANN(query embedding) → vector / L2
└─ BM25 sparse(EmbeddedText) → sparse_vector / BM25
│
▼
Milvus hybridSearch + RRFRanker(k)
│
▼
融合后的 hit 列表(RRF 序)
```
```mermaid
flowchart LR
Q[query] --> EMB[embedding]
Q --> TXT[raw text]
EMB --> DA[dense ANN<br/>vector / L2]
TXT --> BA[BM25 ANN<br/>sparse]
DA --> HS[Milvus hybridSearch]
BA --> HS
HS --> RR[RRFRanker]
RR --> HITS[有序 hits]
```
这比应用层 sparse-lite / 伪 hybrid 前进了一大步:词面与语义在**库内**融合,chunk 身份也不会在后处理被文档级折叠吞掉。
### 2.2 分数侧:仍在「骗」后处理
后处理历史契约默认:
```text
score ≈ L2 距离(越小越好)
baseScore = 1 - clamp(L2) / maxL2Distance # 越大越好
再 + domain/entity/keyword boost
按 finalScore 重排
用 baseScore 定 relevance_level / 是否低质 retry
```
为了迁就这套契约,hybrid 路径做了补丁:
```text
hybrid 融合结果
-> 再跑一路 dense
-> 按 id 把 L2 回填到 score,label 改成 l2_distance
-> 仅 BM25 命中、dense 没命中:
score = maxL2Distance
label = bm25_only_no_dense
```
```mermaid
flowchart TB
H[hybrid RRF 结果] --> P[并行 dense 探测]
P --> M{同 id 有 L2?}
M -->|是| O1[score=L2 · label=l2_distance]
M -->|否| O2[score=maxL2 · label=bm25_only]
O1 --> N[normalizeL2 + boost 重排]
O2 --> N
N --> BAD[BM25-only 好证据被当成最差]
```
意图是好的:让 `normalizeL2` 和 0.75/0.5 阈值「还能用」。
副作用也很清楚:
| 现象 | 后果 |
|------|------|
| RRF 决定顺序,L2 决定「好不好」 | 两套真理,互相打架 |
| BM25-only 好证据被标成最远 L2 | quality≈0,像低质,甚至触发 unfiltered retry |
| `bm25_only_no_dense` 像第三种 label | 概念膨胀:mode 其实只有 dense/hybrid |
| 多打一路 dense 只为回填 | 延迟与复杂度,换来的是语义自洽的假象 |
一句话:
> **不是拿 RRF 分错误地套了 L2 公式,而是排序信 RRF,打分/闸门仍假装大家都是 L2。**
### 2.3 后处理侧:关键词 boost 改主序
典型逻辑:
```text
baseScore = normalizeL2(score)
finalScore = baseScore
+ domain_match (+0.15)
+ entity_match (+0.20)
+ keyword_match (+0.10)
+ source_type (+0.05)
按 finalScore 降序
```
在 **还没有库内 BM25** 时,这套东西多少能补一点词面。
在 **已经 hybrid** 之后,问题变成:
1. **双重计分**
BM25 已经在 RRF 里投过票;后处理再用 contains 加分,等于词面再抬一次。
2. **contains 比 BM25 更糙**
无 IDF、无文档长度、短词子串误命中——正好制造「词频/词面高、相关度低却排前面」。
3. **冲掉 RRF 序**
花了 hybrid 买到的融合序,被 L0 词表二次改写。
4. **PRECISE 还绑 hint**
高质量还要 `hasHintSupport`(同样是 contains),把导航层信号抬成等级门槛。
结合上一篇文章的判断——**L0 / 关键词适合做提示,不适合当最终裁判**——hybrid 上线后,后处理 boost 改序已经从「可接受的轻启发式」滑向「明确的设计债」。
---
## 3. 问题清单:chunk 去重 + hybrid 之后,还剩什么
可以分成四层(本次主要收口前两层):
### 3.1 正确性 / 契约(本次主战场)
1. hybrid **没有**独立的融合分归一化,只有 L2 兼容补丁
2. 后处理关键词打分不合理,会抬升词面热、语义冷的片段
3. `scoreLabel` 语义混乱:`l2_distance` / `rrf_fused` / `bm25_only_*` 混用
4. `mode=dense` 与 hybrid 内部 dense 子路概念易混(mode 是整次查询算法,不是「第三套库」)
### 3.2 质量上限(未在本 change 做完)
- 无固定 RAG 评测报表驱动阈值标定
- 无邻块扩展、真 query rewrite、cross-encoder 精排
- 中文 analyzer / 分词策略未产品化钉死
### 3.3 工程债(部分清理、部分保留)
- 应用层 `LexicalRanker` / 自研 `RrfFusion` 可能仍像「还有 app-layer hybrid」
- Spring AI starter 仍可作 sidecar,但不是知识主路径(starter 至 2.0.0 仍无 BM25 hybrid)
- 写入先删后插,非强 upsert;Milvus / MySQL / L0 三方一致性靠流程
### 3.4 运维边界
- hybrid 依赖 BM25 Function + sparse index
- 全量 rebuild 受 embedding 与写入延迟约束
- `totalVectors` 一类统计可能仍不可信
本次 change(`rag-quality-score-unify`)**有意只收口 3.1**:
让 hybrid 的排序权威与质量闸门重新对齐,而不是同时上精排模型。
---
## 4. 关键澄清:L2 是什么,它是不是「后处理」本身
讨论中容易把「L2」和「后处理打分流程」混成一个词。需要拆开:
### 4.1 L2 是度量
**L2 = 欧氏距离**,dense ANN 常用 metric:
- 越小越相似
- 单位向量场景下可用 `maxL2Distance≈2` 做上界
- 归一化相似度:`1 - clamp(L2) / maxL2`
### 4.2 后处理是流水线
后处理消费的是「约定好的 score」,历史上**假定**它是 L2,于是:
```text
score(L2) → normalizeL2 → baseScore → (+boost) → finalScore → 排序/等级
```
```mermaid
flowchart LR
subgraph metric["度量层"]
L2[L2 距离]
end
subgraph pipe["后处理流水线"]
N[normalize]
B[可选 boost]
S[排序 / 截断]
G[等级 / 闸门]
N --> B --> S --> G
end
L2 -.->|历史上假定输入是 L2| N
RRF[RRF 融合分] -.->|量纲不同 · 不能直接套| N
```
所以:
- L2 ≠ 后处理
- L2 = dense 路径的自然距离
- 后处理 = 把某种 score 变成 quality / 等级 / 截断结果的流程
hybrid 的问题是:**流程还在,输入契约已经不再总是 L2。**
### 4.3 category 降级也不是 L2 存在的唯一理由
filtered → unfiltered retry 用的是:
```text
isLowQuality = 无可用证据 或 topSimilarity < referenceThreshold
```
`topSimilarity` 来自归一化后的质量分。
unfiltered 只是**再检一次**,尺子本来就该是统一 quality,而不是「专为降级准备的 L2」。
```mermaid
flowchart TB
F[带 category 的检索] --> Q{isLowQuality?}
Q -->|是| U[unfiltered retry]
Q -->|否| K[采用本次结果]
U --> M[合并/替换为 retry 结果]
K --> OUT[后处理出口]
M --> OUT
```
---
## 5. 设计拍板:统一成什么
### 5.1 两层概念,不要混
| 层 | 只有什么 | 不是什么 |
|----|----------|----------|
| **检索 mode** | `dense` \| `hybrid` | 不是三套库 |
| **一级 scoreLabel** | `dense` \| `hybrid` | 不是 `bm25_only` 第三种模式 |
- `mode`:整次查询怎么跑(配置 `retrieval.search.mode`)
- `scoreLabel`:这条 hit 的 `score` 怎么解释
```mermaid
flowchart TB
CFG[retrieval.search.mode] --> M1[dense 整次只跑 ANN]
CFG --> M2[hybrid 整次 dense+BM25+RRF]
M1 --> L1[scoreLabel=dense]
M2 --> L2[scoreLabel=hybrid]
L2 -.->|不是| L3[bm25_only 第三种 mode]
```
`bm25_only_no_dense` **不是第三种检索**,只是旧链路里「这条 hybrid 命中没有 dense L2 可回填」的补丁标签。统一后应降级为历史别名(canonicalize → `hybrid`),不再一级发射。
### 5.2 检索回来带什么
建议最小契约:
```text
rank (originalRank) // 1 最好;hybrid = RRF 序;dense = ANN 序
score // 引擎主分;量纲由 label 解释
scoreLabel // dense | hybrid
rawScore? // 可选调试
denseDistance? // hybrid 可选:同 id 的 L2,仅供闸门
```
| label | score 含义 |
|-------|------------|
| `dense` | L2 距离(越小越好) |
| `hybrid` | 引擎融合分可放 raw/score;**排序不看其量纲** |
```mermaid
flowchart LR
subgraph emit["Store 发射"]
LAB[scoreLabel<br/>dense | hybrid]
SCR[score / rawScore]
RNK[列表序 → originalRank]
DD[denseDistance? 仅 hybrid]
end
LAB --> NORM
SCR --> NORM
RNK --> SORT
DD --> NORM
NORM[toQualityScore] --> QS[qualityScore]
SORT[按 rank 排序] --> LIST[evidence 顺序]
QS --> GATE[level / isLowQuality]
```
### 5.3 唯一归一化点
```text
qualityScore = toQualityScore(label, score, rank, batchSize, maxL2, denseDistance?)
// 输出统一:[0,1],越大越好
```
分支只允许出现在这里:
```text
dense → 1 - clamp(L2)/maxL2
hybrid → 优先 denseDistance 的 L2 归一化(绝对质量 / 闸门)
无 dense 时 rank 线性回退
```
```mermaid
flowchart TB
IN[label + score + rank + denseDistance?] --> C{canonicalize label}
C -->|dense| L2[l2ToQuality score]
C -->|hybrid| H{denseDistance?}
H -->|有| L2H[l2ToQuality denseDistance]
H -->|无| RK[rankToQuality]
L2 --> OUT[qualityScore 0..1]
L2H --> OUT
RK --> OUT
```
**演进说明:** 切片 1 曾用纯 rank 做 hybrid quality;eval 发现 rank1 恒高会杀死 L0 filter fallback。
现行约定:**排序仍纯 RRF;闸门可用 denseDistance 绝对质量**,且 **不得** 再把主分/label 伪装成 L2。
### 5.4 后处理:统一流程,不要按 label 再分叉业务
```text
candidates
→ 每条 toQualityScore(...) ← 唯一认 label 的地方
→ qualityScore + originalRank
→ 统一:按 rank 排序 / 去重 / 每文档 chunk 上限 / return-n
→ 统一:relevance_level、isLowQuality(只看 qualityScore)
```
```mermaid
flowchart TB
CAND[candidates] --> QS[toQualityScore 每条]
QS --> SORT[sort by originalRank ASC]
SORT --> DEDUP[evidenceKey 去重]
DEDUP --> CAP[max-chunks / return-n]
CAP --> REL[relevance_level]
CAP --> LOW[isLowQuality → filter retry]
CAP --> OUT[EvidenceBlocks]
```
可以记成:
> **Label 只活在进后处理之前的适配器里;后处理是 label-agnostic 的。**
> **排序听 rank;闸门听 qualityScore(hybrid 可含 denseDistance)。**
### 5.5 后处理还改不改?——要改,而且和归一化同一刀
后处理合理职责是 **裁剪与装配**,不是第二套检索:
| 保留 | 去掉或降级 |
|------|------------|
| evidenceKey 去重 | domain/entity/keyword **加分改序** |
| max-chunks-per-document | contains 当相关度代理 |
| return-n | PRECISE 强制 hint support |
| excerpt 截断、EvidenceBlock | |
| 统一 qualityScore 闸门 | |
L0 仍可:
- 导航:category filter(失败 unfiltered retry)
- 解释:`hitReasons` 记 `l0_keyword_overlap` 等(**零分值**)
词面该不该高:交给 **BM25 子路 + RRF**。
语义该不该近:交给 **dense 子路**(融合时已参与;dense-only mode 对照时单独看)。
### 5.6 mode=dense 还要不要
要,但定位清楚:
| 模式 | 定位 |
|------|------|
| **hybrid** | 线上主路径 / 默认 |
| **dense** | 同库对照、评测、排障——看「去掉 BM25+RRF 后差在哪」 |
注意:
- hybrid **入库**数据完全适用于 dense 查询(每条都写了 `vector`)
- hybrid **内部**仍有 dense 子路——那是融合的一部分,≠ `mode=dense`
- 对照时固定 `retrieve-k` / `return-n` / filter / query 集,只切 mode
- 优先比命中集合与排名;`relevance_level` 在 hybrid 下是序数 quality,慎作跨 mode 绝对值对比
---
## 6. 目标数据流(落地后)
```text
VectorSearchService (mode=dense|hybrid)
-> hits{ originalRank, score, scoreLabel=dense|hybrid, rawScore?, denseDistance? }
-> KnowledgeDocumentRetriever / SearchPort
-> KnowledgeEvidencePostProcessor
qualityScore = RetrievalScoreNormalizer.toQualityScore(...)
sort by originalRank ASC
evidenceKey dedup / max-chunks / return-n
relevance_level & topSimilarity from qualityScore
L0 overlap → hitReasons only
-> ContextPack / Assembler / Projector
```
```mermaid
flowchart TB
Q[query] --> VSS[VectorSearchService]
VSS -->|mode=dense| SD[searchDense]
VSS -->|mode=hybrid| SH[searchHybrid + 可选 denseDistance]
SD --> PORT[KnowledgeSearchPort / Adapter]
SH --> PORT
PORT --> POST[KnowledgeEvidencePostProcessor]
POST --> PACK[ContextPacker]
POST --> ASM[LookupResultAssembler]
ASM --> PROJ[RagResultProjector]
PROJ --> AGENT[Agent 可见契约]
```
与上一代对比:
| 环节 | 上一代 | 现在 |
|------|--------|------|
| hybrid score | 常被 L2 覆盖 | 保持融合侧;label=`hybrid` |
| BM25-only | maxL2 + `bm25_only_*` | 普通 hybrid hit,quality 看 rank |
| 归一化 | 一律当 L2 | 按 label 唯一转换 |
| 排序 | finalScore(含 boost) | originalRank |
| L0 关键词 | +分改序 | 仅解释 |
| 质量闸门 | baseScore(L2 兼容) | qualityScore |
---
## 7. 行为变化:必须说清楚的协议调整
这是**有意的行为变化**(对内检索质量语义;Agent ACI 字段名可不变):
1. hybrid 下证据顺序更贴近 **RRF**,不再被 contains 抬到前面
2. 「词面很准、dense 略远」的命中,不再被默认打成低质占位
3. `relevance_level` / category unfiltered retry 的触发分布可能变化
4. hybrid 的 quality 是**本轮序数分**,跨 query 绝对值不可比;阈值可能需后续标定
5. dense 对照模式:质量仍走 L2 归一化,行为更接近旧 dense 主路径
未改:
- Agent 可见字段结构(evidence 列表、relevance 枚举名等)
- Milvus hybrid schema / 不必为本次 rebuild
- `mode=dense` 开关本身
---
## 8. 和上一篇文章的衔接:阶段进度
对照 `RAG排序-多路召回与RRF.md` 的推进顺序:
| 阶段 | 内容 | 状态(截至 2026-07-28) |
|------|------|-------------------------|
| Phase 0 | chunk 去重、retrieve-k/return-n、身份 | **已落地** |
| Phase 1~2 | 多路 + RRF;真 BM25 hybrid | **已落地**(库内 hybrid,非 app-layer 伪融合) |
| 分数职责分离 | 排序 vs 可用性/质量闸门 | **本次收口**(qualityScore 统一) |
| 去掉 L0 当裁判 | 关键词不改主序 | **本次收口** |
| Phase 3 | 可插拔模型 Rerank | **未做**(候选池与评测闭环仍优先) |
| 邻块 / query rewrite | 上下文与问句改写 | **未做** |
因此,本次文章不是推翻上一篇,而是补上上一篇写到「融合之后」却还没写完的半截:
> 融合解决「谁进来、谁先排」;
> 归一化与后处理决定「算不算够好、会不会被规则再次打乱」。
---
## 9. 实现锚点(便于对照代码)
| 组件 | 职责 |
|------|------|
| `RetrievalScoreLabels` | `dense` / `hybrid` + 旧别名 canonicalize |
| `RetrievalScoreNormalizer` | 唯一 `toQualityScore` |
| `MilvusHybridKnowledgeStore` | 发射 label;hybrid 不 L2 覆盖 |
| `VectorSearchService` | mode 路由;SearchResult 契约注释 |
| `KnowledgeEvidencePostProcessor` | rank 保序、去 boost 改序、quality 闸门 |
| 架构 §6.0 | mode 用途:hybrid 主路径 / dense 对照 |
验证(单测,非 live E2E):
- normalizer:L2 边界、rank 单调、别名
- post-process:rank 不被 keyword 打乱;caps/return-n
- lookup tool:保序;context pack 元数据仍在
已知未验证:真实 Milvus 联调对照、阈值标定。
---
## 10. 实践清单:以后别再踩的坑
1. **不要**为了复用旧 `normalizeL2`,把 hybrid 结果伪装成 L2。
2. **不要**在已经 BM25 hybrid 之后,再用 L0 contains 大额加分改主序。
3. **不要**把 `bm25_only` 当成第三种检索模式。
4. **不要**把 hybrid 内部的 dense 子路,和 `mode=dense` 整次查询混为一谈。
5. **要**让 label 差异停在适配器;后处理只认 qualityScore + rank。
6. **要**用 dense mode 做召回对照,而不是第二套长期线上策略。
7. **要**接受:hybrid 序数 quality 与绝对阈值之间,需要观测后再调,而不是再发明一层伪装。
8. **下一步再考虑**精排模型——在契约掰直、有固定 query 回归集之后。
---
## 11. 结语
混合检索落地,解决的是「漏」和「跨路硬加分」里很大一块。
但若后处理仍活在 L2 + 关键词 boost 的旧世界,hybrid 买到的 RRF 序和质量信号会被悄悄改写,甚至惩罚「只在 BM25 路很强」的好证据。
这次收口的核心就三句:
```text
1. 一级 label 只有 dense / hybrid
2. 唯一 toQualityScore;后处理统一、保 rank
3. L0 关键词可以解释,不可以再当排序裁判
```
它不是 RAG 的终点,而是 hybrid 从「能跑」变成「分数语义自洽」的必要一步。
在此之后,评测闭环、阈值标定、邻块与精排,才有干净的基线可谈。
---
## 附录 A:术语
| 术语 | 含义 |
|------|------|
| L2 | 欧氏距离;dense ANN 常用;越小越相似 |
| RRF | Reciprocal Rank Fusion;用名次融合多路,不融合原始分 |
| scoreLabel | 一级分数语义:`dense` \| `hybrid` |
| qualityScore | 归一化后的 0~1 质量分(越大越好),供等级与低质闸门 |
| originalRank | 检索返回名次;后处理排序权威 |
| mode=dense | 整次只跑 dense ANN(对照) |
| mode=hybrid | dense+BM25+RRF(主路径) |
| L0 | query hint / 可选 category filter;不作事实证据、不改主序 |
## 附录 B:相关材料
| 材料 | 路径 |
|------|------|
| 多路与 RRF 讨论 | `docs/RAG排序-多路召回与RRF.md` |
| 当前架构 | `mvp/architecture/RAG知识检索架构.md` |
| OpenSpec 归档 | `openspec/changes/archive/2026-07-28-rag-quality-score-unify/` |
| 主规格 | `openspec/specs/rag-retrieval-quality-score/spec.md` |
| devflow | `devflow/projects/2026-07-28-rag-quality-score-unify/` |
@@ -1,163 +0,0 @@
# RAG 审计补丁 E2E:`step_id` + query(含业务 FALLBACK 样本)
**日期**:2026-07-28
**状态**:审计字段 live 验收记录
**关联主文档**:[一次诊断到底发生了什么(SUCCESS 全流程)](一次诊断全流程-E2E导读.md)
> 主文档只保留 **SUCCESS 完整诊断** 与 **现行审计能力说明**。
> 本页单独记录:改造后的一次 live 验收——**审计字段 PASS**,业务因 Milvus 空结果走了 **FALLBACK**。
---
## 1. 样本身份
| 项 | 值 |
|----|----|
| `session_id` | `e2e-audit-20260728171858` |
| `run_id` | `0be605f6-e036-40d3-b364-11b73672241f` |
| 入口 | `POST /api/chat`(与 SUCCESS 样例同构的 RAG-only 约束题) |
| 冷启动 | 含 `AgentStepAuditTracker` 等补丁后的 `mvn spring-boot:run` |
| 业务结局 | `release_outcome=FALLBACK`,`content_type=SAFE_FALLBACK` |
| 工具 | 1× `lookup_knowledge` |
本地产物(若仍在):`target/e2e-audit-sse.txt`、`target/e2e-audit-session.txt`、`target/e2e-audit-run.txt`。
---
## 2. 验收目标 vs 非目标
| 目标 | 是否本页重点 |
|------|----------------|
| `tool_invocation.step_id` = 发出 tool_call 的 `agent_step.id` | **是** |
| `input_params` 含安全 `query` 预览 | **是** |
| Trace / timeline 带回 `step_id` | **是** |
| 业务必须 SUCCESS | **否**(本 run 为 FALLBACK,归因环境) |
---
## 3. 审计结果:PASS
### 3.1 `agent_step`
| id | step_index | has_tool_call | 说明 |
|----|------------|---------------|------|
| **962** | 0 | 1 | 发出 `lookup_knowledge` |
| 963 | 1 | 0 | 无证据后的收尾轮 |
### 3.2 `tool_invocation`(id=860)
| 字段 | 值 |
|------|-----|
| `step_id` | **962**(= step0) |
| `tool_name` | `lookup_knowledge` |
| `success` | 1(工具跑完;无证据也算执行成功) |
| `search_mode` | hybrid |
| `evidence_status` | `NO_EVIDENCE` |
| `candidate_count` | 0 |
| `relevance_level` | null |
**`input_params` 实值:**
```json
{
"query": "MySQL connection pool exhausted HikariCP diagnosis",
"step_id": 962,
"tool_call_id": "call_00_LireJzbiFEsbZ9ZVvgYp8330",
"request_bytes": 62
}
```
### 3.3 Trace API
- `toolInvocations[0].stepId = 962`
- `inputParams.query` 有值
- timeline `TOOL_INVOCATION.details.step_id = 962`
### 3.4 对照表
| 检查项 | 预期 | 实际 | 判定 |
|--------|------|------|------|
| `tool_invocation.step_id` | = agent_step.id | 962 | **PASS** |
| `input_params.query` | 有预览 | 有 | **PASS** |
| `input_params.step_id` | 与列一致 | 962 | **PASS** |
| Trace `stepId` | 非空 | 962 | **PASS** |
| timeline `step_id` | 非空 | 962 | **PASS** |
| `search_mode` | hybrid | hybrid | **PASS** |
| 业务 outcome | (非本页 KPI) | FALLBACK | 见 §4 |
```mermaid
flowchart LR
S0[agent_step 962<br/>r1 tool_call] --> TI[tool_invocation 860<br/>step_id=962]
TI --> IP[input_params.query]
TI --> TR[Trace / timeline]
TI --> RAG[hybrid candidate_count=0]
RAG --> FB[FALLBACK<br/>NO_EVIDENCE]
```
---
## 4. 业务 FALLBACK 原因(与审计无关)
| 现象 | 说明 |
|------|------|
| 日志 | `found=false`,`evidenceBlocks=0` |
| audit | `attempts[].usable=false`,`candidate_count=0` |
| warm 检索 | 同期 `GET /api/search/similar` 亦失败 |
| 日志噪音 | Milvus channel 未正确 shutdown 等提示(环境/客户端生命周期) |
**结论**:hybrid **路径进了**,但当次 **0 候选** → 无证据可写报告 → `SAFE_FALLBACK` / `INSUFFICIENT_EVIDENCE`。
这不否定 `step_id` / `query` 落库;完整 SUCCESS 业务故事见主文档。
```mermaid
flowchart TB
Q[同一 RAG-only 题] --> LK[lookup_knowledge]
LK --> AUD[审计: step_id + query PASS]
LK --> HIT{有候选?}
HIT -->|SUCCESS 主文档 run| OK[PRECISE → DIAGNOSIS_REPORT]
HIT -->|本页 run| NO[0 候选 → FALLBACK]
```
---
## 5. 实现索引(补丁代码)
| 组件 | 职责 |
|------|------|
| `AgentStepAuditTracker` | run 级 bind/current/clear `agent_step.id` |
| `HarnessAgentAuditHook` | `beforeModel` 落 step 后 `bind` |
| `ToolBoundary.auditSafely` | 读 tracker,带上 `stepId` + `requestJson` |
| `JpaToolInvocationAuditSink` | 写 `step_id` 列;安全展开 `input_params` |
| `TraceAuditEvents.toolInvocation` | timeline details 的 `step_id` |
| `JpaChatRunStore.finish` | `clear(runId)` |
**`input_params` 安全规则摘要**:
- 始终:`tool_call_id`、`request_bytes`;有则:`step_id`
- 顶层 string/number/boolean/纯字符串数组;文本 ≤160 字符
- 键名含 password/token/secret/apikey 等 → 不落库
更完整的字段词典与 SUCCESS 阶段拆解见主文档 §3.4 / §3.4.1。
---
## 6. 复盘 SQL
```bash
python scripts/query_mysql.py "SELECT id, step_id, tool_name, relevance_level, CAST(input_params AS CHAR) AS inputp, LEFT(CAST(retrieval_details AS CHAR), 500) AS details FROM tool_invocation WHERE run_id = '0be605f6-e036-40d3-b364-11b73672241f'"
python scripts/query_mysql.py "SELECT id, step_index, agent_name, has_tool_call, token_count FROM agent_step WHERE run_id = '0be605f6-e036-40d3-b364-11b73672241f' ORDER BY step_index"
python scripts/query_mysql.py "SELECT run_id, status, release_outcome, tool_call_count, total_token_count FROM diagnosis_run WHERE run_id = '0be605f6-e036-40d3-b364-11b73672241f'"
```
```http
GET /api/diagnosis/e2e-audit-20260728171858/trace?runId=0be605f6-e036-40d3-b364-11b73672241f
```
---
## 7. 修订记录
| 日期 | 说明 |
|------|------|
| 2026-07-28 | 从主 walkthrough 拆出:专门记录 audit 验收 run(FALLBACK 业务 + step_id/query PASS) |
-888
View File
@@ -1,888 +0,0 @@
# 诊断 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、现有的规则加分,下一步到底该扩召回、该融合,还是该上精排?
```mermaid
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` 为例,主路径大致是:
```text
Agent query
-> KnowledgeQueryTransformer # L0:domain / keyword hint,可选 category filter
-> KnowledgeDocumentRetriever # L1:向量 topK
-> KnowledgeEvidencePostProcessor # 归一化 + 规则 boost + 排序 + 去重
-> KnowledgeContextPacker # 字符预算打包
-> LookupResultAssembler
-> RagResultProjector # 投影成 Agent 可见契约
```
```mermaid
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,但实现通常是:
```text
baseScore = 向量距离归一化后的相似度
finalScore = baseScore
+ domain_match
+ entity_match
+ keyword_match
+ source_type_prior
再按 finalScore 降序
```
```mermaid
flowchart LR
BASE[baseScore<br/>向量相似度] --> SUM[finalScore]
D[+ domain]
E[+ entity]
K[+ keyword]
S[+ source_type]
D --> SUM
E --> SUM
K --> SUM
S --> SUM
SUM --> SORT[按 finalScore 降序]
```
同时会留下 `rerankTrace`(base/final score、boost reasons),便于内部审计。
### 2.2 这套做法解决了什么
它不是毫无意义:
- 在小候选池里,能把更像目标域、更像 runbook 的结果往前推
- 有可解释的 boost 原因,方便 trace
- 实现成本低,不引入额外模型服务
如果只是 Demo,或语料很小、query 很规范,这种轻规则重排往往够用。
### 2.3 它没解决什么
真正的问题通常不在“3 条里谁排第一”,而在:
1. **K 太小**
`topK=3` 时,重排空间极小。复杂精排模型也只能在这 3 条里微调。
2. **规则加分不稳定**
依赖 L0 词表和字符串 `contains`。短词、泛词、别名缺失都会让 boost 误触发或漏触发。
3. **L0 filter 可能误杀**
唯一 domain 时加 category filter,能降噪;一旦 L0 判错域,正确文档可能根本进不了候选。
4. **去重粒度若偏文档级**
同文档多个相关 chunk 可能被压成 1 条,召回了也会在后处理/投影阶段丢掉。
5. **Agent 侧看不到排序细节**
投影层常只保留 excerpt 与少量元数据,score / hitReasons / retrievalTrace 被裁掉。这对安全边界合理,但对模型“判断有多相关”不友好。
一句话概括现状:
> 不是没有排序,而是在太小的候选池里,用不够稳的启发式做微调。
---
## 3. 关键判断:候选池大小决定策略上限
排序策略必须和 K 匹配。可以先用下面这张表做决策:
```mermaid
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 才划算 | 无评测地堆模型 |
背后的原因很简单:
```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 重试
```
```mermaid
flowchart TB
Q[query + L0 category?] --> F[filtered dense]
F --> BAD{结果差/空?}
BAD -->|是| U[unfiltered retry]
BAD -->|否| USE[采用 filtered]
U --> REP[常见:整锅替换 filtered]
```
这能工作,但常见实现是 **串行整锅替换**:
- retry 成功后,直接丢掉第一次 filtered 的全部结果
- 没有“两边好结果都保留,再统一排序”
更稳的做法是把它升级为并行/双路融合:
```text
路 A:dense + category filter # 求准
路 B:dense + 无 filter # 求全
去重合并后一起排序
```
```mermaid
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 求全;融合比二选一更稳。
#### 它算多路吗?
算,但要说清楚边界:
```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`
- 多路都靠前的候选,融合分自然更高
- 只在一路偶然靠前的候选,不会单靠绝对分尺度“爆掉”
```mermaid
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 特别适合下面这种现实约束:
```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
```
```mermaid
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 分阶段推进
```mermaid
flowchart LR
P0[Phase0<br/>chunk 去重<br/>retrieve-k/return-n] --> P1[Phase1<br/>filtered+unfiltered RRF]
P1 --> P2[Phase2<br/>BM25 跨维度]
P2 --> P3[Phase3<br/>可插拔精排]
```
#### Phase 0:先修前提
否则后面多路都会被吞:
1. 去重 key 从“文档/source”改为 `docId + chunkIndex`(fallback 可用向量主键)
2. 配置拆分:
```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,能不能在系统里真正跑起来。
-583
View File
@@ -1,583 +0,0 @@
# RAG 离线评测:讨论、设计与落地
**日期**:2026-07-28
**范围**:`eval/rag-retrieval` 离线 baseline、fixture 生成、与 hybrid/quality 主路径对齐
**读者**:要维护或扩展知识库回归评测的工程同学
**关联实现 / 变更**:
- 目录:`eval/rag-retrieval/`
- 脚本:`scripts/eval_rag_retrieval.py`、`generate_rag_lookup_snapshots.ps1`、`prepare_rag_eval_seed.ps1`
- OpenSpec / devflow:`rag-eval-hybrid-baseline`(已归档)
- 前置:`docs/RAG-Hybrid质量分与后处理.md`、`docs/RAG-Agent如何读relevance_level.md`
---
## 1. 为什么要单独谈评测
hybrid、chunk 去重、qualityScore 统一之后,工程上仍缺一块:
> **改检索之后,用什么可重复的信号判断「变好了还是变坏了」?**
完整诊断 E2E(多工具 + 最终回答)太重、太噪。需要一层**只盯 `lookup_knowledge` 召回与管道行为**的回归。
本文汇总讨论中形成的:
1. 离线评测是什么、不是什么
2. Golden / Fixture 怎么设计、比什么
3. 项目现状是否符合定义
4. 改造复杂度与 sm-flow 落地(含 apply 中发现的闸门问题)
5. 指标、报告、纪律
---
## 2. 离线评测:概念边界
### 2.1 离线 vs 在线
| 说法 | 含义 |
|------|------|
| **在线** | 真跑检索:embedding、Milvus、完整 `lookup_knowledge` |
| **离线** | **不再访问检索栈**;用事先冻住的结果快照,和标准答案比对 |
```mermaid
flowchart TB
subgraph online["在线(贵、真、偶发)"]
S[Seed 语料] --> G[真 lookup / 检索管道]
G --> F[写入 Fixtures]
end
subgraph offline["离线(便宜、稳、可 CI)"]
C[Golden cases] --> E[比对脚本]
F2[已提交的 Fixtures] --> E
E --> R[报告 / baseline / diff]
end
F -.->|提交入库| F2
```
可以记成:
```text
在线:考试现场答题(环境会变)
离线:用标准答卷复印件批改(环境冻结)
```
### 2.2 离线适合 / 不适合回答的问题
**适合:**
- 契约有没有破(结构、关键字段、行为路径)
- 在「同一份检索结果」假设下,期望文档/关键词/attempt 是否仍满足
- 相对上一版 baseline 的回归 diff
**不适合单独承担:**
- hybrid 是否比 dense 更好 → 需要**同一时期**在线双跑
- 阈值 0.75 是否合适 → 需要在线统计 level 分布
- 换 embedding 后召回如何 → 必须重刷 fixture 或 live
- Agent 最终诊断对不对 → 诊断 E2E
```mermaid
flowchart LR
Q1[契约 / 期望回归] --> OFF[离线 baseline]
Q2[当前召回是否正确] --> ON[在线生成 fixture 或 live]
Q3[dense vs hybrid 增益] --> CMP[同期双 mode 对照]
Q4[诊断是否正确] --> E2E[诊断 harness]
```
---
## 3. 三块积木
```mermaid
flowchart TB
subgraph golden_box["① Golden(薄)"]
GQ[query]
GE[期望:doc / keyword / attempt / …]
end
subgraph fixture_box["② Fixture(冻)"]
FM[meta: caseId, searchMode, kbScope, time]
FL[lookupResult 结构化输出]
end
subgraph judge_box["③ 裁判(规则)"]
A[按 golden 字段断言]
M[衍生 Hit / rank / 通过率]
REP[json + md + 可选 diff]
end
golden_box -->|caseId 对齐| judge_box
fixture_box --> judge_box
```
| 积木 | 是什么 | 不是什么 |
|------|--------|----------|
| **Golden** | 问什么 + **应该**怎样 | 不是整包线上成功 JSON 原样当期望 |
| **Fixture** | 某次跑完**实际**怎样 | 不是每次离线评测都要重跑检索 |
| **裁判** | 关键字段比对 + 指标 + 报告 | 不是两个大 JSON deep equal |
时间线:
```text
① 设计 Golden
② (改检索 / 换库 / 换 mode 时)在线跑 → 写/更新 Fixtures
③ 日常:Fixtures × Golden → 报告(多数时候只做这一步)
```
---
## 4. 为什么不全量字段对比
讨论中的直觉「分数不固定」是对的,但原因不止这一条:
| 原因 | 说明 |
|------|------|
| 分数不稳定 | L2 / RRF / embedding 会漂 |
| 实现细节会变 | trace 结构、reason 文案、时间戳、tool_call_id |
| 截断与预算会变 | excerpt 长度、packedText |
| 目标是「对不对」 | 不是字节级一致 |
```mermaid
flowchart LR
F[Fixture JSON] --> K[只抽取关键字段]
G[Golden 期望] --> K
K --> P{断言}
P -->|通过| OK[Pass + 指标]
P -->|失败| FAIL[Fail + 原因列表]
F -.->|不做| FULL[全量 deep equal]
```
**全量对比**偶尔可用于「紧挨着两次生成器输出的工程 diff」,那不是 golden 质量标准。
---
## 5. Golden / Fixture 字段设计
### 5.1 Golden:一行长什么样(分层)
```mermaid
flowchart TB
ID[身份: caseId, scenario/tags, notes]
IN[输入: query]
P0[P0 命中: expectedDocIds/Sources, expectedKeywords]
P1[P1 行为/展示: attempt, fallback, status, breadcrumb]
P2[P2 细粒度: evidenceKey, minCount, firstRankMax]
P3[P3 观察: relevanceLevel — 慎作硬门禁]
NEG[负例: mustNotDocIds/Sources]
ID --> IN --> P0 --> P1
P1 --> P2
P1 --> NEG
P1 --> P3
```
| 优先级 | 比什么 | 作用 |
|--------|--------|------|
| P0 | docId / source、keywords | 召回对不对、段是否有用 |
| P1 | selectedAttempt、fallback、evidenceStatus | 路径有没有坏 |
| P1 | mustNot* | 硬负例 / decoy |
| P2 | chunk / evidenceKey、条数、首条相关 rank | 身份与排序 |
| P3 | relevance_level | 观察用;hybrid 下易松 |
**原则:** 期望对齐「用户/Agent 可感知的对错」,少锁实现细节。
### 5.2 Fixture:最少保留什么
```mermaid
flowchart TB
subgraph fix["fixture"]
META["meta<br/>caseId, query, retrievedAt<br/>searchMode, kbScope?"]
RES["result / lookupResult<br/>有序 evidence[]<br/>attempt / fallback / status<br/>contextPack? / traces?"]
DBG["debug 可选<br/>rawScore, denseDistance…"]
end
META --> RES
RES --> DBG
```
每条 evidence 最少:
```text
docId 或可对齐的 source
excerpt / content(关键词断言需要)
顺序 = rank(数组下标即可)
evidenceKey / chunkIndex(多 chunk case 需要)
title / breadcrumb(按需)
```
**故意不锁:** score 全文、完整 trace、tool_call_id、packedText 全文(除非单独立项)。
### 5.3 Golden → Fixture 取值对照
```mermaid
flowchart LR
subgraph g["Golden"]
g1[expectedDocIds]
g2[expectedKeywords]
g3[expectedSelectedAttempt]
g4[expectedFallbackReason]
g5[mustNotDocIds]
end
subgraph f["Fixture"]
f1[evidence[].docId/source]
f2[evidence[].excerpt 拼接]
f3[retrievalTrace.selectedAttempt]
f4[retrievalTrace.fallbackReason]
f5[evidence 全表扫描]
end
g1 --> f1
g2 --> f2
g3 --> f3
g4 --> f4
g5 --> f5
```
---
## 6. 指标与报告
### 6.1 两层指标
```mermaid
flowchart TB
subgraph gate["门禁主信号"]
PASS[逐 case Pass/Fail]
RATE[通过率 / 按 tag 通过率]
end
subgraph quality["质量刻度(报告展示)"]
HIT[Hit@n / hitLevel strong·medium·weak·miss]
RANK[first relevant rank / MRR]
KW[keyword coverage]
FB[fallback rate]
LV[level 直方图 — 观察]
end
PASS --> RATE
HIT --> RANK
```
| 指标 | 含义 |
|------|------|
| **Pass/Fail** | golden 声明的 expected* 是否全部满足 |
| **hitLevel** | strong / medium / weak / miss(项目已有) |
| **Recall@K** | strong+medium 算命中 |
| **firstExpectedRank** | 第一条期望文档的排名 |
| **fallback / attempt** | 行为路径 |
| **level 分布** | 宜观察,hybrid 下慎作硬门禁 |
### 6.2 报告长什么样
```mermaid
flowchart LR
EVAL[离线评测] --> J[baseline.json<br/>机器可读]
EVAL --> M[baseline.md<br/>人读表格]
EVAL --> D[baseline-diff.*<br/>相对上一版]
J --> CI[CI / 脚本解析]
M --> HUM[人看失败原因]
D --> REV[改代码还是改期望]
```
工程上的「得出结果」=:
1. **门禁**:must-pass 是否全绿
2. **诊断**:谁红、红在哪类断言
3. **趋势**:相对旧 baseline 变好还是变差
---
## 7. 项目现状审计(改造前)
讨论结论:**模型符合定义,内容偏旧(约 70%)**。
```mermaid
flowchart TB
subgraph ok["已符合"]
A1[golden × fixture × key-field]
A2[seed + kb_scope=rag-eval]
A3[Hit 分层 + recall + baseline diff]
A4[离线不连库]
end
subgraph gap["缺口"]
B1[生成器仍传 vector-store.mode=spring]
B2[fixture 无 searchMode/kbScope]
B3[无 dense/hybrid 双目录对照]
B4[无 mustNot / chunk 硬期望]
B5[快照停在 boost 改序时代]
end
ok --> gap
```
| 维度 | 符合度 |
|------|--------|
| 三件套架构 | 高 |
| 关键字段比对 | 高 |
| 报告 / diff | 高 |
| 与 hybrid 主路径同步 | 低(改造前) |
| chunk / 负例 / mode 矩阵 | 弱或无 |
---
## 8. 改造策略与复杂度
### 8.1 两刀切分
```mermaid
flowchart TB
K1["第一刀(已落地)<br/>search.mode 接线<br/>fixture meta<br/>README<br/>重刷 hybrid baseline"]
K2["第二刀(未做)<br/>fixtures/hybrid vs dense<br/>对照表<br/>golden tags/mustNot"]
K1 --> DONE[可门禁当前主路径]
K2 --> CMP[可回答 hybrid 增益]
```
**复杂度判断:中低。** 不必重写框架;成本在联调环境与 baseline 纪律,不在算法。
| 工作 | 复杂度 |
|------|--------|
| 改生成参数 / meta / README | 低 |
| seed + 重刷 fixture | 中低(看环境) |
| 双目录对照 | 中低(第二刀) |
| 换框架 / LLM judge | 高(不建议现在) |
### 8.2 sm-flow 落地范围(已确认)
- **仅第一刀**
- **接线必交**;fixture 刷新尽力(本次环境可用,已刷绿)
Change:`rag-eval-hybrid-baseline`(已归档)。
---
## 9. 落地后的主链路(当前)
### 9.1 日常离线
```mermaid
flowchart LR
GC[golden-cases.json] --> PY[eval_rag_retrieval.py]
FX[fixtures/*.json] --> PY
PY --> BR[reports/baseline.*]
```
```bash
python scripts/eval_rag_retrieval.py
```
### 9.2 改检索后的完整环
```mermaid
sequenceDiagram
participant Eng as 工程师
participant Seed as prepare_rag_eval_seed
participant Gen as generate_rag_lookup_snapshots
participant Tool as LookupKnowledgeTool
participant Off as eval_rag_retrieval.py
participant Git as 仓库 baseline
Eng->>Seed: 导入 seed-docs (kb_scope=rag-eval)
Seed-->>Eng: MySQL/L0/Milvus 就绪
Eng->>Gen: -SearchMode hybrid
Gen->>Tool: 每条 golden.query
Tool-->>Gen: LookupResult
Gen->>Gen: 写 fixture + searchMode/kbScope
Eng->>Off: fixtures × golden
Off-->>Eng: pass/fail + 指标
Eng->>Git: 意图变更则更新 baseline(带 diff 原因)
```
### 9.3 生成器配置(改造后)
| 参数 | 默认 | 含义 |
|------|------|------|
| `SearchMode` | `hybrid` | `retrieval.search.mode` |
| `KbScope` | `rag-eval` | 评测语料隔离 |
| (已删除) | — | `vector-store.mode=spring\|sdk` |
```powershell
.\scripts\prepare_rag_eval_seed.ps1
.\scripts\generate_rag_lookup_snapshots.ps1 # hybrid
.\scripts\generate_rag_lookup_snapshots.ps1 -SearchMode dense -Fixtures ... -SkipEval # 对照用
```
---
## 10. Apply 中发现的关键问题:filter fallback 与 quality 闸门
### 10.1 现象
重刷 hybrid fixtures 后,`chat-l0-filter-fallback` 变红:
- 只命中 decoy(`overfilter-decoy`)
- `selectedAttempt=FILTERED_VECTOR`,**没有** unfiltered retry
- 根因:hybrid **纯 rank→quality** 时 rank1 恒为 ~1.0 → `isLowQuality` 永不成立
```mermaid
flowchart TB
subgraph before["纯 rank quality(有问题)"]
H1[hybrid RRF 序] --> R1[rank1 quality=1.0]
R1 --> N1[isLowQuality=false]
N1 --> X1[不 retry · decoy 留下]
end
subgraph after["denseDistance 闸门(已修)"]
H2[hybrid RRF 序 · 排序不变] --> D2[并行 dense 填 denseDistance]
D2 --> Q2[quality = L2 归一化]
Q2 --> L2{top quality < 0.5?}
L2 -->|是| RET[UNFILTERED_VECTOR_RETRY]
L2 -->|否| KEEP[保留 filtered 结果]
end
```
### 10.2 设计取舍(必须记清)
| 信号 | 用途 |
|------|------|
| **RRF / originalRank** | **排序权威**(谁在前) |
| **denseDistance → quality** | **绝对质量闸门**(要不要 retry、level 档) |
| **不**再:用 L2 覆盖 hybrid 主分 / label | 避免回到「伪装成 L2」 |
```mermaid
flowchart LR
subgraph sort["排序"]
RRF[RRF 返回序]
end
subgraph gate["质量闸门"]
L2[dense L2 若有]
RK[rank 回退若无 dense]
L2 --> QS[qualityScore]
RK --> QS
end
RRF --> LIST[evidence 列表顺序]
QS --> LV[relevance_level]
QS --> FB[isLowQuality → filter fallback]
```
这与 quality 统一文的精神一致:**排序与闸门分信号**;只是 hybrid 闸门不能**只**靠序数分。
验证(归档时):
- offline **7/7 pass**
- fallback case:`UNFILTERED_VECTOR_RETRY` + `filtered_vector_low_quality`
---
## 11. Baseline 纪律
```mermaid
flowchart TB
RED[离线变红] --> Q{实现退步还是预期变了?}
Q -->|退步| CODE[改代码]
Q -->|预期变了| GOLD[改 golden / 重刷 fixture]
GOLD --> NOTE[写清原因 · 更新 baseline]
Q -->|禁止| BLIND[不看 diff 整锅覆盖]
```
README 原话仍然成立:fixture 对不上,要么修链路,要么改期望——**二选一要显式**。
---
## 12. 与完整评测体系的位置
```mermaid
flowchart TB
subgraph L1["L1 离线契约 — 已有且已对齐 hybrid"]
OFF[fixtures × golden]
end
subgraph L2["L2 在线召回 — 生成器已接线"]
LIVE[seed → snapshot hybrid]
end
subgraph L3["L3 对照与标定 — 部分未做"]
DD[dense vs hybrid 双目录表]
CAL[level/阈值直方图标定]
end
subgraph L4["L4 诊断 E2E — 另一套"]
DIAG[多工具 · 最终回答]
end
L1 --> L2
L2 --> L3
L2 -.-> L4
```
| 层 | 状态 |
|----|------|
| L1 离线 | **已落地**,hybrid fixtures + baseline 绿 |
| L2 在线生成 | **已接线**,本机已成功重刷 |
| L3 双 mode 对照目录 | **未做**(第二刀) |
| L4 诊断 E2E | 独立 harness,非本文 |
---
## 13. 实践清单
1. **日常**:只跑离线 `eval_rag_retrieval.py`。
2. **改检索 / 索引 / mode / 闸门**:seed → hybrid 生成 → 离线 → 看 diff 再更新 baseline。
3. **对照 dense**:`-SearchMode dense` 指到另一 fixtures 目录(第二刀可产品化报表)。
4. **Golden** 锁业务真值;**score / 完整 trace** 默认不锁。
5. **relevance_level** 先观察,慎作硬门禁。
6. **排序听 RRF**;**retry/level 听绝对 quality(dense L2)**。
7. 评测语料固定 `kb_scope=rag-eval`,勿绑生产杂库。
---
## 14. 结语
离线评测不是「再造一个复杂平台」,而是:
```text
固定问题(Golden)
× 冻结答卷(Fixture)
× 关键字段裁判
→ 可 diff 的报告
```
项目原本骨架正确;本轮补上了 **hybrid 时代的生成接线、fixture meta、baseline 重刷**,并在真实跑通时修正了 **「序数 quality 杀死 filter fallback」** 的闸门设计。
下一有价值的增量是 **dense/hybrid 同期对照表(第二刀)** 与 **level 分布标定**,而不是换评测框架。
---
## 附录 A:目录与命令速查
| 路径 | 作用 |
|------|------|
| `eval/rag-retrieval/cases/golden-cases.json` | Golden |
| `eval/rag-retrieval/fixtures/*.json` | Fixtures(含 searchMode) |
| `eval/rag-retrieval/seed-docs/` | 评测语料 |
| `eval/rag-retrieval/reports/baseline.*` | 离线基线报告 |
| `scripts/eval_rag_retrieval.py` | 离线裁判 |
| `scripts/generate_rag_lookup_snapshots.ps1` | 在线生成 fixture |
| `scripts/prepare_rag_eval_seed.ps1` | 导入 seed |
```powershell
# 离线
python scripts\eval_rag_retrieval.py
# 完整刷新(需 embedding + Milvus 等)
.\scripts\prepare_rag_eval_seed.ps1
.\scripts\generate_rag_lookup_snapshots.ps1 -SearchMode hybrid
```
## 附录 B:相关文档
| 文档 | 内容 |
|------|------|
| `eval/rag-retrieval/README.md` | 操作说明(以仓库为准) |
| `docs/RAG-Hybrid质量分与后处理.md` | quality / 后处理 |
| `docs/RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
| `docs/RAG排序-多路召回与RRF.md` | 多路与 RRF |
| `devflow/projects/2026-07-28-rag-eval-hybrid-baseline/` | 本 change 档案 |
| `openspec/specs/rag-eval-offline-baseline/spec.md` | 主规格 |
File diff suppressed because it is too large Load Diff