# 三层检索契约（Tier-0 / Tier-1 / Tier-2）v2

> 2026-09-14 定稿（v2 重整：补 RAG 管线要素、用实测数据定预算）。
> 背景：三层 OpenViking 式架构**验收"以为过了、实际没过"**——只验了声明项，没验能力可达。
> 本文件是施工与验收的**唯一依据**。相关：`ROADMAP.md`（主线）、`TODO-GRAPH.html`（P0-② 阶段门）、`MEMORY-MUTATION-AND-INDEX-DESIGN.md`（索引侧）。

---

# 第一部分 · 三层安排（Arrangement）

## 1.1 为什么是三层

三层的本质是**把"要不要用"和"内容是什么"分开**：上层负责**判断与缩窄**（便宜、常驻、永远在场），下层负责**给出证据**（贵、按需、可溯源）。因此判定顺序是"上层先出 → 搜索空间缩窄 → 才下探"，而不是把三层一起灌进上下文。

## 1.2 三层总表

| 维度 | **Tier-0 · 目录（指引层）** | **Tier-1 · 摘要（候选层）** | **Tier-2 · 原文块（证据层）** |
| --- | --- | --- | --- |
| 回答的问题 | 要不要用某条记忆？ | 该下探哪一条？ | 原文到底怎么说的？ |
| 输入 | 项目笔记 + 用户级记忆 + 当日日志的**结论行** + 白板 | 语料记录（日志 / 反思 / 笔记 / 用户记忆 / 白板） | 命中记录的 chunk（`chunkId = hash(记忆ID, 内容摘要, 序号)`） |
| 产出 | 每条 **1 行**：`标题 · 一句结论 · layer · status · 日期` | 每条 1 段摘要（≤`L1` 字符）+ `id` + 得分 + 匹配原因 | 命中块原文 + `文件:行号` + `digest` |
| 常驻性 | **每轮都注入**（不依赖命中） | 命中后按需（top-`K`） | 需要证据时下探 |
| 预算 | ≤ `B0` = **800 token** | ≤ `L1` = **140 字符** × `K` = **8** | ≤ `B2` = **2400 字符** / 次 |
| 失效方式 | 陈旧即被上游覆盖（无状态） | 状态过滤（`superseded`/`retracted`） | 随快照版本（`miv`）失效 |

## 1.3 数据流

```
源文件（日志 / 反思 / 笔记 / 用户记忆 / 白板）
   │  ①抽取：标题 → 首句 → 截断（三级降级）      ← Tier-1 的产出
   ├──────────────► Tier-1 摘要（≤140 字符/条）
   │  ②分块：chunkId = hash(记忆ID, 内容摘要, 序号)  ← Tier-2 的存储单位
   ├──────────────► Tier-2 原文块（≤1500 字符，实测）
   │  ③目录：从"当前认知"里抽 1 行/条              ← Tier-0 的产出
   └──────────────► Tier-0 目录（≤800 token，常驻）

检索时：Tier-0 先出 → 缩窄 → 命中不足才下探 Tier-1 → 需要证据才取 Tier-2
```

---

# 第二部分 · 每层的要素清单

## 2.1 Tier-0（目录）要素

| 要素 | 要求 |
| --- | --- |
| 内容来源 | **只取"当前认知"**：项目笔记 / 用户记忆 / 白板的标题与结论行；**禁止原始转储** |
| 每行字段 | `标题`、`一句结论`、`layer`、`status`、`日期`（+ 内部 `id` 供下探） |
| 优先级 | `project > whiteboard > user > log`；同层按日期倒序（超预算时按此裁剪） |
| **per-layer 配额（必须有）** | 纯严格优先级会让 `project` **吃满预算**——真实语料实测：project 77 块把 800 token 全占，`whiteboard`/`user`/`log` **一条都进不来**，于是"分层"退化成单层。**规定：`project ≤ 60%·B0`；`whiteboard`、`user` 各**保底 10%**；剩余额度再按优先级填充** |
| 刷新时机 | 每轮或笔记变更后重建；重建**必须廉价**（纯文本处理，不涉嵌入） |
| 状态位 | 带 `status`；被 `superseded`/`retracted` 的**不出现在目录**（I5） |
| 降级 | 目录为空/来源缺失 → 输出显式提示，不静默给空（I7） |

## 2.2 Tier-1（摘要层）要素 —— 第二层要做好的事

| # | 要素 | 说明与要求 | 现状 |
| --- | --- | --- | --- |
| 1 | **抽取规则** | 标题 → 首句 → 截断（三级降级），保证**任何记录都有摘要** | ✅ 已有（`l0-extract-pre.js`） |
| 2 | **必须保留"判定信息"** | 摘要里要留下**结论句**（例："已拍板 X""根因是 Y"）；只留主题词等于没用 | ❌ 缺（决策句常被截掉） |
| 3 | **长度上限** | ≤`L1`=140 字符；超限按"信息密度"裁，不是简单截断 | ⚠️ 现约 93 字符，无规则 |
| 4 | **层级与状态** | 每条带 `layer`（五值）与 `status`（三值） | ❌ 缺（C1 在做） |
| 5 | **得分与匹配原因** | 返回 `词法×n / 语义×score` 与命中词，便于调参与解释 | ✅ 已有 |
| 6 | **多路融合** | 词法 + 语义 → RRF 融合；任一路不可用要标注（I7） | ⚠️ 语义臂会静默消失 |
| 7 | **同源去重 / 合并** | 同一文件多个块命中 → 合并为一条显示，避免刷屏占预算 | ❌ 缺 |
| 8 | **排序体现当前认知** | 笔记/结论优先于历史日志；被替代项降权或过滤 | ❌ 缺（现为纯 top-K） |

## 2.3 Tier-2（原文块）要素 —— 第三层要做好的事

| # | 要素 | 说明与要求 | 现状 |
| --- | --- | --- | --- |
| 1 | **分块粒度** | 按**记录/小节**切（现有 `chunkOrdinal/chunkCount`）；实测 p50=418、p90=1186 字符 | ✅ 已有 |
| 2 | **块边界与重叠** | 边界不切断结论句；跨块语义断裂处应有**少量重叠**（或把结论句并入首块） | ❌ 未见重叠 |
| 3 | **块元数据** | `chunkId`、`memoryId`、`sourceRef`、`recordDigest`、`chunkOrdinal/Count` | ✅ 已有（15 字段） |
| 4 | **按块取，不取整篇** | 只回命中块（I3）；长文档禁止整篇灌入 | ⚠️ 契约新定 |
| 5 | **可溯源** | 返回 `文件:行号` + `digest`，便于核验与回滚 | ✅ expand 已返回 |
| 6 | **超长块处理** | 单块 > `B2` 时：先截结论句 + 尾注"（已截断，全文 N 字符）"，不静默丢 | ❌ 缺 |
| 7 | **保真** | 原文不改写、不摘要（引用必须逐字） | ✅ |
| 8 | **编码与 BOM** | 读回时剥离 BOM，保持 UTF-8 | ⚠️ 需断言 |

---

# 第三部分 · 一条好 RAG 管线的关键部件（对照表）

| # | 部件 | 我们要做到什么 | 现状 |
| --- | --- | --- | --- |
| 1 | **分块（Chunking）** | 语义完整、带元数据、必要时重叠 | ✅ 主体已有；重叠缺 |
| 2 | **嵌入（Embedding）** | 引擎身份入键（模型/维度/归一化）；换引擎=全量重建 | ⚠️ OR 双引擎要写清切换语义 |
| 3 | **索引（Index）** | 向量 + 词法双路；**缓存键=chunkId**（内容寻址） | ❌ 现按整份语料哈希（P0-④） |
| 4 | **查询理解** | 多键提取、时间意图（"最近/上次"） | ⚠️ 部分 |
| 5 | **召回与融合** | 词法 + 语义 RRF；分数可解释 | ✅ 融合已有；语义臂不稳 |
| 6 | **重排（Rerank）** | 现在靠融合排序；有需求再上轻量重排 | ⚠️ 够用 |
| 7 | **过滤与权限** | `layer`/`status` 过滤、来源白名单、**检索+注入双层** | ❌ 缺（C1/C2） |
| 8 | **上下文组装** | 预算分配、排序（相关性 vs 时序）、去重、provenance 标注 | ❌ 缺（C5） |
| 9 | **可溯源（Grounding）** | 每条注入带出处（文件+行号+digest） | ✅ 部分 |
| 10 | **评估（Evaluation）** | ground truth 集 + 指标（命中率/答案可达率/噪声比/token） | ⚠️ 只有词法基线 |
| 11 | **容错与降级** | 跳过+计数+quarantine；未就绪显式标注（I7）；fail-open | ❌ 现在 fail-closed 且静默 |
| 12 | **新鲜度与增量** | 写后防抖触发、快照 epoch、差量同步 | ❌ 缺（P0-④c/④e） |

---

# 第四部分 · 预算（Budget）：要，而且要算出来

## 4.1 结论先说

**需要预算，并且它必须是三层一起核对过的总量。** 三层若同时全给，会直接顶穿注入预算——这不是理论，是实测：

```
Tier-0 800 token(≈1600 字符) + Tier-1 8×140(=1120 字符) + Tier-2 2400 字符
= 5120 字符，而注入预算 injectBudgetChars = 8000 字符（**可配置项**；见 SPEC §0.2）
→ 三层仍不同时给：本契约规定"逐层下探"，与预算是否宽裕无关
```

**所以本契约规定：三层不是"同时给"，而是"逐层下探"**（见 §5 闸门）。默认只给 Tier-0；命中不足才给 Tier-1；要证据才给 Tier-2。

## 4.2 预算的实测依据（E2，2026-09-14 重启后复跑）

来源：`~/.dsh/memory/semantic-pre/derived-corpus.json`（重启后重建，**34 条记录**，字段含 `text`）：

| 指标 | 实测值 |
| --- | --- |
| 单条原文长度 | 最小 **144** ／ p50 **760** ／ p90 **1196** ／ p99 **1692** ／ 最大 **1692** 字符 |
| 合计 | 24,254 字符（均值 713） |
| 超过 1000 字符 | 7 条（20.6%） |
| 超过 2000 / 5000 / 10000 字符 | **0 / 0 / 0 条** |

**对账（证明"语料 text 长度"这个代理指标可信）**：抽 2 条真实 `expand` 与语料长度比对 —— `mem_d55f8e8e`：expand 报 **1499 字符** ↔ 语料 1499 ✅；`mem_5a7f779a`：expand 报 **1403 字符** ↔ 语料 1403 ✅。

## 4.3 由此定出的预算（初值）

| 参数 | 取值 | 依据 |
| --- | --- | --- |
| `B2`（Tier-2 单次） | **2400 字符**（≈1200 token） | E2 实测 max=1692；语料仍在增长（条均 713），留 ~42% 余量；超出者按 §2.3-6 截断（截结论句 + 标注"已截断，全文 N 字符"） |
| `L1`（Tier-1 每条） | **140 字符**（≈70 token） | 原文 p50=760 → 压到 ~1/5 保留事实；现摘要约 93 字符偏短、缺结论句 |
| `K`（Tier-1 条数） | **8** | 8×140=1120 字符 ≈ 560 token，占注入预算 ~1/4 |
| `B0`（Tier-0 常驻） | **800 token**（≈1200 字符） | 占注入预算约 1/4；目录"每条 1 行"约 12 字 → 可容纳 ~30 条当前认知 |
| 总量上限 | 逐层下探，**不同时给**；单轮注入总长 ≤ `injectBudgetChars`（默认 8000） | §4.1 的实测校验 |

> **口径（与 SPEC §0.2 一致，别再当矛盾）**：Tier-0 常驻有两层门 —— `tier0MaxTokens` **默认 400**（**可配置项**），硬上限 `B0` = **800 token**；属「默认值 vs 上限」之别。

## 4.4 中间"概览层"要不要？——**本数据下不需要**

OpenViking 是 L0(~100 token) → L1(~2k) → L2(原文)。我们的实测是：**原文本身 p90 只有 1196 字符、max 1692**，L0(140 字符) 直接跳到原文块（≤2400）**跨度可接受**。
判定规则（写进契约，可复测）：**当出现单块 > 5000 字符的源（如整篇 PLAN.md、超长日志）时，才需要"概览层"或更细的分块**；当前 **0 条**命中该条件（最近复核：34 条，max 1692）。

## 4.5 预算不是拍脑袋（校准流程）

1. 跑 **E1**（预算—召回曲线）：扫 `L1 ∈ {60, 90, 140, 220, 400}`、`B0 ∈ {200, 400, 800, 1600}`，记录命中率与**答案可达率**；
2. 跑 **E3**（直接灌 vs 逐层）：三策略的正确率 / token / 噪声比；
3. **选值规则：在满足"答案可达率 ≥ 0.9"的前提下取最小预算**（膝盖点）；
4. 校准结果回写本表，并同步 `TODO-GRAPH.html` 的 P1-⑯。

## 4.6 token 计量口径（不统一，I1 就无法验证）

仓库既有 `estimateSessionTokens = ceil(chars/4)+4`（`lib/index.js:2471`）**对 CJK 低估 2–4 倍**，直接拿它当预算门会**静默突破 I1**。

- **契约规定：预算门取 `max(ceil(chars/2), 仓库口径)`**（保守值恒 ≥ 仓库值）；
- C4 的 Tier-0 模块已按此实现并锁进测试；`estimateMode:'repo'` 可复算仓库口径对照；
- 换算：`B0 = 800 token` ≈ **1600 字符**目录容量。

---

# 第五部分 · 递进闸门

```
默认（常态）：只注入 Tier-0（≤ B0 = 800 token）
  ↓ 当（Tier-0 命中 < 2 条）或（问题含「为什么 / 怎么 / 具体 / 复现」语义）时
下探 Tier-1：top-K（K=8，每条 ≤ L1 = 140 字符）
  ↓ 当（需要引用 / 行号 / 复现命令 / 判定"原文怎么说"）时
下探 Tier-2：取该条的命中块（≤ B2 = 2400 字符）
```

- 允许**升层**（下层命中不足→回上层重选），**禁止**一次性三层全灌；
- 阈值（2 条 / 8 条 / 140 字符）为初值，由 E1/E3 校准。

---

# 第六部分 · 不变量（违反即回归）

- **I1** Tier-0 常驻且 ≤ `B0`；**I2** Tier-1 每条 ≤ `L1`、条数 ≤ `K`；**I3** Tier-2 按块且 ≤ `B2`；
- **I4** 每层条目必须带 `layer` + `status`；
- **I5** 非 `current` 的条目在**检索结果与注入内容两处**都被过滤；
- **I6** 三层来自同一份快照（同一 `miv`），混版视为错误；
- **I7** 索引未就绪 / 语义臂不可用**必须显式降级标注**（例：`[语义索引未就绪 · 已降级为词法]`），禁止静默丢弃注入。

`layer` ∈ { `user` | `project` | `log` | `reflection` | `whiteboard` }；`status` ∈ { `current` | `superseded` | `retracted` }。

---

# 第七部分 · 施工清单与验收

| # | 改动 | 文件 | 状态 |
| --- | --- | --- | --- |
| **C1** | 抽取层补 `layer` + `status` | `lib/l0-extract-pre.js` | ✅ **完成**（+100/−2；`classifyLayerPre`/`L0_LAYERS`/`L0_STATUSES`/`L0_DEFAULT_LAYER`；21 断言绿） |
| **C2** | 召回返回带 `layer/status` + **检索侧过滤**（I5） | `lib/index.js` | ✅ **完成**（l0Mode 与语义臂两处；导出 `isCurrentPre`；21 断言绿；p2 回归已修复） |
| **C4** | Tier-0 目录生成器（每条 1 行，≤ `B0`，按优先级 + 配额裁剪） | 新 `lib/tier0-catalog-pre.js` | ✅ **完成**（33 断言绿；真实语料 788 token ≤ 800；**配额已随 C5 落地**：`allocateTier0QuotaPre` 为 opt-in，关闭时逐字节保持旧行为） |
| **C3** | 接线 `l0-index-pre.js`（L0 自己的向量索引，增量；**需显式落 layer/status 两列**） | `lib/l0-index-pre.js` + 新 `lib/l0-index-sync-pre.js` + `lib/index.js` | ✅ **完成**（模块侧显式落两列并把层/状态计入索引身份；接线经 `createL0IndexSyncPre` 的 `update({layer})` 显式传层；**按层各一份**索引文件；开关 `l0IndexEnabled` **默认开**（用户 2026-09-14 裁定）、5 分钟节流、全 fail-soft；**待宿主重启复核**） |
| **C5** | 注入层改造：Tier-0 常驻 + 按闸门下探 + **per-layer 配额** + 降级标注（I7） | 新 `lib/tier-layer-inject-pre.js` + `lib/index.js` + `lib/context-host-pre.js` + `lib/activation-host-pre.js` | ✅ **完成**（83 断言绿 + 5 处定向变异报红；**入口更正：每轮 `<memory_system>` 块由 `renderMemoryDynamic` 产出，`injectionText` 是零引用死代码**；`index-not-ready` 由静默丢弃改为降级仍注入；**待宿主重启复核**） |
| **C6** | 验收套件（每条能力一个**能失败**的断言） | `tests/smoke/smoke-test-three-layer-pre.mjs` | ✅ **完成**（**122 断言**：Tier-0 预算 19 / layer+status 27 / supersede 双层 14 / expand 回归 9 / 源文件损坏降级 11 / C3 接线 32 / C7 回归 10；7 处定向变异全部报红） |
| **C7** | 注入侧可见性：块内 `Score: 0.xx (rank n/m)` + reason 串带 `intent/dense/margin`（P1-⑮） | `lib/activation-inbox-pre.js` + `lib/context-host-pre.js` + `python/worker_semantic_pre_v1.py` | ✅ **完成**（29 断言绿：分值在、降序排名、乱序必重排、预算计入、无分省略；**待宿主重启后在真实注入里复核**） |

**验收判据（"真过"的定义）**：
1. Tier-0 常驻且 ≤ `B0`，内容为"当前认知"而非原始转储；
2. L0 列表**每条**带 `layer` + `status`；
3. 造一条被 supersede 的记忆 → 断言它在**结果与注入**两处都不出现，但审计视图可见；
4. 造一条命中 → 断言 `expand` 取回原文（**已通，作为回归钉子**）；
5. **故意破坏一个源文件** → 断言检索仍返回词法命中 + 明确降级标注（不是整条失败）；
6. 上列每条都在 `tests/smoke/` 有对应套件，且**故意改坏实现时确实会红**。
7. **注入侧看得见相似度**（C7）：注入块每条带 `Score: 0.xx (rank n/m)`，块序按分值严格降序；分值非法整行省略（不写 NaN）；这几行的字节计入预算（不得静默超预算）。
