# 记忆可变 × 索引稳定：方案设计（2026-09-14）

> 起因：用户指出一个真实痛点 —— **记忆必须能增删改**（AI 发现自己记错了要改；用户反映"过去的错误记忆影响了现在的工作"），但当前**一改就要把整个语义库重排**。
> 本文只做方案设计，不动工。状态：待与"跨会话/跨 Agent 检索"一并拍板。

---

## 一、先把因果拧准（实测数据）

| 说法 | 判定 |
| --- | --- |
| "多 Agent 协作导致记忆被订正/增删，进而导致向量检索失败" | **部分成立**：51 个阻塞文件里**只有 3 个**是"增删"直接造成的（9/13 修重复 `tool_call_id` 时删过事件 → seq 缺口）；另外 48 个与增删无关（39 个 `descriptor` v2、7 个 `permission/preset` 多出 `origin`、2 个畸形字段）——那是 DSH 对老日志的 schema 冻结清单过严。 |
| "和多个 Agent 有关" | **相关但不是因果**：只有跑过子代理的会话里才有 `descriptor` 事件，但让校验失败的是**版本号**（v2 不在冻结清单里），不是协作本身。 |
| "**一改就让整个语义库重排**" | ✅ **完全成立**，且发生在插件自己的索引里（见 §二）。 |

**归纳出的唯一原则**：对"不可变日志"做物理删除 → 必然留下空洞（seq 缺口 / 断链），而事后读取器可能拒绝空洞。**改正只能用"标记 + 追加"，不能用"物理删除"。** 这一条同时适用于 DSH 会话日志、我们的每日日志、以及向量索引。

---

## 二、现状：为什么"一改就整体重排"（代码级）

1. **语料身份是整份的**：`memoryIndexVersion`(miv) 是整个语料的哈希 → 任何一处改动 = 新 miv。
2. **同步是全量的**：宿主 `ensureIndexReady` 以 `(wsRef, scope)` + miv 为 key；miv 变了就重发**整份** records（`index-sync-pre.js` 的 `buildIndexSyncPlansPre` 每次都从全量 records 造页）。
3. **嵌入是全量的**：worker 收到 commit 后，对 payload 里**所有** record 做 `_chunk_texts_for` → `encode_texts(encode_items)` → 整体重算并原子覆写 `vectors-<key>.json`（`python/worker_semantic_pre_v1.py:355-372`）。
4. **块级 ID 早就有、但没用于缓存**：`chunkId = chunk_id_for(memoryId, recordDigest, ordinal)`（天然内容寻址）→ 完全够做"只重嵌变化块"。
5. 持久化格式也是整块快照：`{'identity', 'chunks': [...], 'vectors': [...]}` —— 没有 `chunkId → vector` 的映射表。

**结论**：改一条记忆 = 重嵌整个工作区语料。语料一旦上万块，这个代价就是用户说的"重排"。

---

## 三、五个方案（按"性价比"排序）

### 方案 1｜块级向量缓存（**首选**，改动最小、收益最大）
- **做法**：持久化改为 `Map<chunkId, {vector, meta}>`；commit 时对 `chunkId` 做集合差 →
  - 新增 id → 只 embed 这些；
  - 消失 id → 只删条目；
  - 未变 id → 直接复用旧向量。
- **identity 校验收窄**：仍校验 embedding provider/model/config（换模型才全量重算）——这是**正确且必要**的行为，不能省。
- **效果**：改一条记忆 ≈ 嵌一条（毫秒级）；删一条 = 零计算。工程量：worker 内 ~60 行 + 一个迁移（旧格式首次读入即转成新映射）。
- **风险**：低。旧文件可原地迁移；迁移失败退化为"重新嵌一次"。

### 方案 2｜差量同步（让"整份重发"也消失）
- **做法**：同步走 **upsert + tombstone** 差量；宿主 readyCache 改为记录"已同步的 `memoryId → recordDigest` 集合"而不是单一 miv；miv 退化为"语料版本号"仅用于诊断。
- **收益**：wire 成本与改动量成正比；大语料不再顶 256KB/page 预算（`INDEX_SYNC_PAGE_BUDGET`）；数千次编辑不再重复发送未变内容。
- 工程量：`index-sync-pre.js` + `m7-index-sync-host-pre.js` ~80 行 + 测试。

### 方案 3｜改正语义：**supersede（替代）而不是删除**
- **做法**：AI 发现记忆有错 → 写**新**记录并声明 `supersedes: <旧 memoryId>`；旧记录保留、标记 `supersededBy`；检索阶段过滤掉被替代项（或大幅降权）。删错记忆 = 新增一条"作废声明"。
- **收益**：①改正变成"只增不改"→ 与增量索引天然契合；②审计链完整（"当时为什么这么记"可回放）；③与既有 append-only 日志纪律一致；④**直接回答用户痛点**：过时/错误记忆不再被检索命中，但没被销毁。
- 工程量：`memory_note_pre`（工具侧加参数）+ L0 检索过滤 ~40 行 + 契约文档。

### 方案 4｜检索侧 fail-open（防重蹈 DSH 覆辙）
- 索引不可用 / 部分过期时：**退回词法结果 + 明示"语义索引未就绪/部分过期"**，绝不整条失败（对照：DSH 因单文件坏而全库拒答）。
- 工程量：小（`recall()` 降级分支）。

### 方案 5｜三层分离的纪律（写进文档与工具说明）
- **不变层**：每日日志（append-only，永不改）——事实来源。
- **可变层**：项目笔记 / 用户记忆（可编辑）——当前认知。
- **派生层**：向量索引（随时可重建、可丢弃）——加速器。
- 规则：**改正 = 追加更正 + 改当前认知；永不改日志；索引永远可从"不变层 + 可变层"重建**（重建成本由方案 1 压到与改动量成正比）。

---

## 四、推荐落地顺序

| 阶段 | 内容 | 工程量 | 直接解决的问题 |
| --- | --- | --- | --- |
| P0 | 方案 1 块级向量缓存 + 方案 4 检索 fail-open | ~1 天 | "一改就重排"消失；索引故障不再让检索整体失败 |
| P1 | 方案 3 supersede + L0 过滤 | ~半天 | "过去的错误记忆影响现在的工作" |
| P2 | 方案 2 差量同步 | ~1 天 | 大语料下的 wire/时间成本 |
| P3 | 方案 5 纪律固化（工具说明 + 契约文档） | 小 | 防止未来再引入"物理删除"类问题 |

**验收判据（可测）**：
1. 编辑 1 条记忆 → 断言 `encode` 调用只覆盖变化的 chunk（不是全部）；
2. 删除 1 条 → 断言向量表条目减少、不触发任何 encode；
3. 被 supersede 的记录 → 断言不在检索结果里、但仍在审计视图里；
4. 故意破坏一个源文件 → 断言检索仍返回词法命中 + 明确的降级标注（不抛错）。

---

## 五、与另外两条线的关系

- **DSH 会话日志那条**（§一 的 51 个文件）：属于上游 schema 过严 + fail-closed，**不由本方案解决**；本方案只保证"我们自己的索引不重蹈这条路"。上游仍建议单独上报。
- **跨会话/跨 Agent 检索**（`CROSS-SESSION-SEARCH-RESEARCH.md`）：那个 P0 是"自建 FTS5 索引"。**两者共享同一套 ID 与增量纪律**（chunkId 内容寻址 + 差量 + 标记删除），建议合并到同一版实现，别做两套。
