# RAG 离线评测:讨论、设计与落地
> **需重新校准(2026-09-29)**:RAG 模块抽离后,L0 hint / categoryFilter 已下沉 py-rag、
> 质量分改为 RERANK 直传、attempt 只剩 `UNFILTERED_VECTOR`;本文设计的 fixture/baseline
> 基于旧 L0/scoreLabel 语义构建,回归评测需按新语义重新生成 fixture 并校准闸门。
> 当前架构见 [../../architecture/RAG知识检索架构.md](../../architecture/RAG知识检索架构.md)。
**日期**: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`(已归档)
- 前置:`RAG-Hybrid质量分与后处理.md`、`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
caseId, query, retrievedAt
searchMode, kbScope?"]
RES["result / lookupResult
有序 evidence[]
attempt / fallback / status
contextPack? / traces?"]
DBG["debug 可选
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
机器可读]
EVAL --> M[baseline.md
人读表格]
EVAL --> D[baseline-diff.*
相对上一版]
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["第一刀(已落地)
search.mode 接线
fixture meta
README
重刷 hybrid baseline"]
K2["第二刀(未做)
fixtures/hybrid vs dense
对照表
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` | 操作说明(以仓库为准) |
| `RAG-Hybrid质量分与后处理.md` | quality / 后处理 |
| `RAG-Agent如何读relevance_level.md` | Agent 如何读 level |
| `RAG排序-多路召回与RRF.md` | 多路与 RRF |
| `devflow/projects/2026-07-28-rag-eval-hybrid-baseline/` | 本 change 档案 |
| `openspec/specs/rag-eval-offline-baseline/spec.md` | 主规格 |