feat(harness,rag): dual LLM audit fields, run conclusion, and hybrid quality

Persist provider reasoning and assistant text separately on agent_reasoning_audit
(DeepSeekAssistantMessage path), extract diagnosis_run.conclusion, enrich RAG
tool audit (step_id/query/qualityScore), gate empty mysql tools, drop devtools,
and align MVP docs after live E2E verification.
This commit is contained in:
zhuyongxin
2026-07-28 19:43:13 +08:00
parent 2f40536248
commit 7ae9707a3b
116 changed files with 8364 additions and 1141 deletions
+12
View File
@@ -53,6 +53,18 @@ docs/
1. [分析笔记目录](analysis/) - 代码分析和问题分析
2. [临时报告目录](reports/) - 修复和验证报告
### 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
### 诊断全流程(E2E 导读)
1. [一次诊断到底发生了什么](一次诊断全流程-E2E导读.md) - SUCCESS 全流程:阶段拆解、token、timeline、字段词典
2. [RAG 审计补丁 E2E:step_id + query](RAG审计补丁-stepid-query-E2E验收.md) - 审计字段 live 验收(含业务 FALLBACK 样本)
---
## 📚 学习笔记 (learning/)
@@ -4,7 +4,7 @@
**前提**:旧 Milvus SDK 直连检索路径后续废弃,不作为长期实现基础
**目标**:在现有 `lookup_knowledge` pipeline 上接入 dense + sparse/BM25 混合检索,融合优先走服务端 RRF
**关联文档**:
- `docs/rag-ranking-multipath-retrieval-and-rrf.md`(排序与多路召回判断框架)
- `docs/RAG排序-多路召回与RRF.md`(排序与多路召回判断框架)
- 本文后续实现讨论以本节 **「交付拆分:分块去重 + Hybrid 同规划」** 为基线
---
@@ -12,7 +12,7 @@
## 1. 结论先说
可以接,而且和前面讨论的多路召回 / RRF 高度一致。
但当前项目 **还不具备 hybrid 运行条件**,缺的不是“再调一次 search”,而是:
但(写作当时)项目 **还不具备 hybrid 运行条件**,缺的不是“再调一次 search”,而是:
```text
1. schema 只有 dense,没有 sparse/BM25 字段
@@ -32,7 +32,27 @@
- 分块去重与 hybrid 同规划、分里程碑交付(先共用地基,再开 hybrid)
```
## 实现状态(2026-07-27)
```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,后续已完成)
| 里程碑 | 状态 | 说明 |
|---|---|---|
@@ -40,17 +60,42 @@
| 交付 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_hybrid)
collection: milvus.collection (default biz)
mode: retrieval.search.mode = dense | hybrid
hybrid: dense ANN + BM25 sparse ANN + RRFRanker
```
**运维必做:** 全量重灌知识库到 `biz_hybrid`;旧 `biz` collection 不再被知识路径使用。
```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 用。
---
@@ -83,6 +128,19 @@ VectorIndexService / VectorSearchService
-> 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]
```
若拆开且顺序错误:
| 只做一项 | 后果 |
@@ -305,6 +363,27 @@ VectorIndexService / VectorSearchService
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 新客户端、或其他封装。
@@ -385,6 +464,19 @@ category/kb_scope/doc_id -> 标量过滤索引(如需要)
- 稳定 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`
@@ -429,6 +521,16 @@ SearchHit {
}
```
```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` 怎么演进
短期:
+396
View File
@@ -0,0 +1,396 @@
# 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
@@ -0,0 +1,592 @@
# 混合检索上线之后:为什么还要统一 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/` |
@@ -0,0 +1,163 @@
# 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) |
@@ -39,6 +39,18 @@
> 现在的 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”
@@ -57,6 +69,17 @@ Agent query
-> 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
@@ -69,6 +92,20 @@ finalScore = baseScore
再按 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 这套做法解决了什么
@@ -110,6 +147,18 @@ finalScore = baseScore
排序策略必须和 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 |
@@ -157,6 +206,15 @@ topK = 3,召回、排序、返回都是 3
若结果差,再 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 的全部结果
@@ -170,6 +228,16 @@ topK = 3,召回、排序、返回都是 3
去重合并后一起排序
```
```mermaid
flowchart LR
Q[query] --> A[路A filtered dense]
Q --> B[路B unfiltered dense]
A --> M[去重合并]
B --> M
M --> RRF[RRF / 统一排序]
RRF --> OUT[topN]
```
#### 为什么值得做?
因为两路解决的是不同失败模式:
@@ -333,6 +401,23 @@ RRF(d) = Σ 1 / (k + rank_i(d))
- 多路都靠前的候选,融合分自然更高
- 只在一路偶然靠前的候选,不会单靠绝对分尺度“爆掉”
```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 特别适合下面这种现实约束:
@@ -553,8 +638,29 @@ Query
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:先修前提
否则后面多路都会被吞:
+583
View File
@@ -0,0 +1,583 @@
# 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