# 它不做什么、还做不到什么，以及十四条被证伪的路

> 本文是**说明书**，[README](../README.md) 是**门面**。这一份是这个项目最该被读的部分：
> 承诺的底线、真正的短板，以及**试过并且被数据否决掉**的方案 —— 后者能省下你重复踩坑的时间。

覆盖：底线、自愿的约束、真的短板、路线图、可选语义增强（`llm.js`）与语义检索接缝（`semantic.js`，
默认关闭）、十四条被实测证伪的路。

> 默认值、旋钮含义与工作机制见 [how-it-works.md](./how-it-works.md)，实测数字见 [measuring.md](./measuring.md)。

---

## 底线

| 底线 | 本插件如何守 |
|---|---|
| 数据不出本机 | 纯 `node:` 内置模块；`capabilities` 只需要 `fs-read` / `fs-write`，不需要 network |
| 不每轮塞正文 | 只在**命中**时注入，且只注入「文件名 + 一行片段」；没命中一个 token 都不注入 |
| 纯文本、可跨 Agent | 只读记忆库的 markdown，不建库、不产生索引文件 |
| 有上限 | 注入量硬上限 `maxChars`（默认 480 字符）+ `maxPointers`（默认 4 条）+ 预算降级阶梯 |
| 不静默改数据 | 召回只读、体检只报告、归档每次都要显式确认且先备份、写入闸门只拒绝不代写 |

## 它不做什么，以及还做不到什么

### 一、自愿的约束（能做到，但不做）

- **不写记忆库**：写入只由人和模型判断，走 Engramory 的纪律。一个能自己往库里塞东西的
  记忆插件，等于把用户的笔记变成模型的草稿纸。
- **不删任何东西**：没有自动遗忘，没有自动合并。「合并」意味着原文消失。
- **不建第二份存储**：不建 SQLite、不建向量库、不生成索引文件。markdown 是唯一真相源，
  可读、可 diff、可手改、可跨 Agent 共用。

### 二、真的短板（还做不到）

- **不替你上语义**：没有内置 embedding、没有向量库。中文用「单字 + 相邻双字」切分，
  同义改写、隐含意图会漏检，目前唯一的桥接是一张 177 条的手工同义表。
  0.9.0 起留了接缝（`semanticProvider`，见下）：你可以挂本地模块或宿主模型来重排 / 改写，
  但**默认那一条路径仍然是纯词面的**，我们不会偷偷替你发一个请求出去。
- **没有跨插件排名**：LongMemEval-S 跑得了（见上），但那是一个英文词面基准，且各家 harness 不同；
  合成基准与真库探针都是自建口径，只能自己跟自己比。
- **门控的判据是概率判据，不是「这条笔记没用」的证明**：我们只有「被带进上下文了」与
  「后来被打开了」两个事件，看不到「它到底有没有帮上忙」。所以阈值是按基础打开率算的
  期望值（降权 = 期望被读 1 次，抑制 = 期望被读 3 次），并配两条保险：观察天数、以及
  `assist` 档只降权不移出。它降低的是「每次都来、从来没被读过」这类噪声的排序权重，
  不是给笔记下判决 —— 而且**默认档下一个字都不删**。
- **基础率会被库自己的使用方式带偏**：如果某个库的账本几乎全是子代理轮（实测本机
  320 条指针里绝大部分是子代理轮，子代理几乎不读笔记），基础率会被压得很低，门槛相应
  抬高（更难判「用不上」）。这是**故意**的方向：宁可漏判，不可误杀。
- **老账本无法追溯门控**：`demoted` / `suppressed` / `suppressReady` 是 0.16.0 起才落盘的
  字段，之前那些轮次只能重算、不能翻旧账；周报第 6 节会明确写这一点。
- **快照缓存有 15 秒窗口**：门控的账本快照按「账本文件大小 + 15 秒 TTL」复用（否则每轮
  都重扫 2000 行）。刚被打开的笔记最多滞后 15 秒才影响排序，这个延迟不影响判据口径。

### 三、曾经没做、现在补上了

- **不自动归档**：已实现。它原来确实写在这一节里，理由也写了「不可逆」。
  但确定性分层不需要 LLM，只是当时没做，所以补上了。
- **同义鸿沟**：同义表已扩到 177 条并逐条经探针验证；笔记别名也真的被用起来了。
- **不自动合并**：降级成只出报告（体检器报疑似重复主题与失联路径），不改文件。

### 路线图

| 缺口 | 计划 | 状态 |
|---|---|---|
| 追加式日志撑爆单篇 | 物理分层归档，零 LLM | 已实现 |
| 同义 / 别名鸿沟 | 同义表扩容 + 别名自动利用，零 LLM | 已实现 |
| 自动合并 | 只出报告，不自动改文件 | 已实现（只报告） |
| 语义冲突检测 | 走宿主 `ctx.llm`，**默认关闭** | 已实现（`semanticConflicts`） |
| 自动摘要 | 走宿主 `ctx.llm`，**默认关闭** | 已实现（`semanticSummary`） |
| 跨宿主分发 | 零依赖 stdio MCP 服务器，只读 | 已实现（`mcp.js`） |
| 跨会话覆盖 | 「结果要多样」的选桶护栏（MMR / 覆盖优先） | 量过了，**被证伪**（见「被实测证伪的路」），收益改在长度归一 `B` 上拿到 |
| 可选语义检索 | 可插拔 seam（向量 / 本地模型 / 远端 API / 宿主模型都行），默认关闭 | 已实现（`semantic.js`） |

后两条默认关闭，是为了让「零模型调用」这条基线保持可复现。开启是用户的显式选择，
而不是装完就生效：一个会自己调模型改写你笔记的插件不该默认打开。走 `ctx.llm`
意味着用宿主已经配好的模型，插件自己不配 key、不直接出网。

### 可选：语义增强（`llm.js`）

词面检索对「换一种说法」只能靠同义表，而「两篇笔记互相矛盾」这种判断词面根本测不出来
（实测过：对全库笔记两两算词面相似度，相似度最高的那些对全是不同主题）。这两件事只能过模型，
所以做成了两个**显式开关**，默认关闭：

```yaml
        semanticConflicts: true   # 注册 engramory_conflicts：让宿主模型找出互相矛盾的笔记对
        semanticSummary: true     # 注册 engramory_summarize：把一篇长笔记压成一段话
        llmProvider: ''           # 留空就用宿主的默认模型
        llmModel: ''
        llmTimeoutMs: 30000
```

关着的时候**连工具都不注册**，模型根本看不到这两个入口；开着时也只在工具被调用那一刻
才 `await import('./llm.js')` —— 静态依赖图里没有它，所以自动召回、面板、体检、写入闸门
在任何情况下都不会顺带发一次模型请求。这条承诺是结构上成立的，不是靠自觉。
（`test/llm-wiring.test.mjs` 就是钉这条的：默认只注册一个工具，且 `index.js` 里
不许出现静态 import。）

开启后会多花模型 tokens，用量上限是明说的：一轮冲突检查最多 4 次调用 × 每次 20 篇，
单篇正文超过 1200 字符会截断，**没查到的部分会如实报出来**（`semantic-truncated`），
不假装全看过。模型编造的文件名一律丢弃（只认本批真实文件名），
丢弃的条数也会报出来 —— 宁可显示「这次有 2 处输出不可信」，也不拿它冒充发现。

### 可选：语义检索接缝（`semantic.js`，默认关闭）

同义表和别名覆盖不到的说法，只有语义能救。但**默认路径不许因此变慢**，所以这里做的是一个
接缝（seam）而不是内置向量库：用户自己指一个提供方，指了才生效，指错了也不会把检索弄坏。

```yaml
        semanticProvider: ''      # 留空 = 纯词面（默认）。可填本地模块路径，或字面量 host-llm / host-embed
        semanticTimeoutMs: 2000   # 单次预算；超时/抛错/返回垃圾一律降级回词面结果
        semanticPool: 50          # 提供方看多宽的候选池（默认 50，上限 200）
        semanticFusion: rrf       # rrf = 各一票（默认，稳）；guarded = 有把握才准插队；trust = 提供方说了算
        semanticGuard: 0.5        # 只有 guarded 用：分差要超过「池内分数跨度」的这个比例才准插队
        semanticHostRoute: ''     # 只有 host-embed 用：复用哪条 DSH 路由（留空 = 当前默认模型的路由）
```

提供方就是一个本地 ESM 模块，导出两个函数里的任意一个：

```js
// 1) 重排：给候选，还顺序（可以用 embedding、可以用本地小模型、可以调远端 API，随你）
export async function rerank({ query, docs, limit, signal }) {
  // docs: [{ id, snippet, matched, lexicalRank, lexicalScore }]
  return [{ id: 'reference-publish-plugin-to-npm.md', score: 0.91 }];
}

// 2) 扩展：把问题改写成几句别的说法，只用来**补空位**（词面排出来的顺序一个字不动）
export async function expand({ query, signal }) {
  return ['npm 发布 token 403', '怎么把包发到 npm'];
}
```

`host-llm` 是内置的第二种：它用宿主已经配好的模型做**查询改写**（`expand`），走 `llm.js`
的动态 import —— 与上面两个开关同一条纪律，静态依赖图里没有它。

四件事是刻意设计的，不是随口承诺：

| 行为 | 为什么 |
|---|---|
| `rerank` 默认 **rrf 融合**（词面与提供方各一票），`expand` **只补空位** | `trust`（提供方的顺序即结果）天花板更高，但只在提供方**确实更准**时兑现，噪音会全额转成损失 —— 哈希假向量把真库 72.2% 砸到 5.6%，真模型 qwen3-embedding:0.6b 也只做到 72.2%（等于白接）；`rrf` 在这两种情况下分别是 19.4% 和 86.1%。量过端点的可以用 `guarded`（有把握才准插队、且只在窗口内部换次序）—— 174 条卷子上它是 hit@1 最高的一档，且 hit@4 与纯词面**恒等**。`expand` 若也拿去融合，实测 hit@1 掉 7~19pt（原因见下面「让宿主模型当改写提供方」）：词面那 1 票没有任何理由比模型那 3 票更该被信任。所以它只把词面没排上的文档**接在后面**，`added` 报补了几条。两种提供方返回候选之外的文件名一律丢弃并报数 |
| 超时**硬兜一层** `Promise.race` | 提供方可以完全忽略 `AbortSignal`（第三方代码什么都干得出来），只靠 abort 会让检索永远等下去 |
| 失败**不抛错**，降级回词面并如实报 `code` | `off` / `not-found` / `unsupported` / `provider-error` / `timeout` / `provider-empty`，检索工具返回值里的 `semantic` 字段直接给出这次到底用没用上 |
| **提供方看的是宽池**（`semanticPool`，默认 50） | 池子等于输出窗口时，提供方只能在词面已经挑中的那几条里排序 —— 语义再准也捞不动排在第 12 名的正确笔记。池宽从 5 抬到 50，池内召回从 70.3% 涨到 97.3%（`npm run bench:seam`） |
| **只管按需检索工具**，不碰每轮自动注入 | 自动召回每个用户轮都跑；挂一个要 200ms 的外部件等于给每一轮加税。热路径永远纯词面 |


#### 实测：接缝的代价与天花板（`npm run bench:seam`）

`bench/semantic-seam.mjs` 用合成基准的 704 条查询跑同一批检索，只换提供方，全程离线、零模型调用
（这条命令你自己就能复算）：

| 场景 | hit@1 | hit@4 | 平均耗时 | 返回码 |
| --- | --- | --- | --- | --- |
| 关闭（默认） | 50.4% | 67.5% | 1.3 ms | `off`×704 |
| 提供方抛错 | 50.4% | 67.5% | 1.4 ms | `provider-error`×704 |
| 提供方卡住不返回 | 50.4% | 67.5% | **53.0 ms**（预算 50ms） | `timeout`×704 |
| 提供方返回编造的 id | 50.4% | 67.5% | 1.4 ms | `provider-empty`×704 |
| 离线字符三元组重排（真打分） | 47.4% | 65.5% | 1.4 ms | `reranked`×704 |
| 按 gold 重排（**作弊上界**，只验管线） | 86.6% | 86.6% | 1.4 ms | `reranked`×610 |

（候选池 = 20，评测看前 4 名。`node bench/semantic-seam.mjs --pool 4` 是另一种用法：池子等于输出量时
RRF 只能在池内重排，所以 hit@4 一动都不动（67.5%），坏的提供方只赔 hit@1。两种都量过。）

三个数字值得单独说：

- **默认零成本**：关着的时候与词面路径是同一条同步路径，1.3 ms，没有任何 import。
- **失败不改变结果**：三种坏提供方下命中与词面**逐字相同** —— 降级是「结果不变 + 如实报码」，
  不是「返回半个答案」；卡住的那种被硬兜在 53 ms（提供方完全可以无视 `AbortSignal`，所以兜的是 `Promise.race`）。
- **接缝本身不产生收益，收益上限 100% 取决于提供方**：随手写的词法变体反而赔 1.5~3.0pp
  （安全上限是作弊上界，不是期望值）；而上界本身由候选池决定 —— 20 个候选里有 gold 的查询只占
  **86.6%**（610/704），所以「完美提供方」也只能把 hit@1 从 50.4% 抬到 86.6%，那 36.2pp 的缺口
  要靠**换真正不同的信号**（比如真 embedding、跨编码器）去补，插件不替你选。

`test/semantic.test.mjs`（假提供方模块：抛错、卡住、返回垃圾、编造文件名）与
`test/semantic-wiring.test.mjs`（关着时连 import 都不发生、开着时真的融合、自动注入不碰接缝）
就是钉这四条的。

#### 实测：候选池与融合模式（同一个 `npm run bench:seam`）

合成语料 704 条、输出窗口 top-4。池宽决定「提供方最多能捞多远」——池内召回就是它的天花板：

| 候选池 `semanticPool` | 5 | 10 | 20 | **50（默认）** | 100 |
|---|---|---|---|---|---|
| 池内召回（合成 704 条） | 70.3% | — | 86.6% | **97.3%** | 97.9% |
| 池内召回（真库探针 174 条） | 93.1% | **97.1%** | 100% | 100% | 100% |

（真库那行是 `npm run bench:probe -- --pool N` 数的：174 条真人 query 里，正确笔记落在词面前 N 名的比例。
**探针卷子从 36 条扩到了 174 条**（0.12.9）：旧卷子一条 query 值 2.8 个点，融合模式与两家端点之间的差别
全被噪声吃掉；扩到 174 条后一条值 0.57 个点，机制才看得见。）

融合模式决定「提供方的话算不算数」（同一张卷子，只换提供方与融合）：

| 提供方 | `trust` | `rrf`（默认） |
|---|---|---|
| 理想：全量且完美的排名 | **86.6% / 86.6%** | 52.7% / 73.6% |
| 理想：只投 gold 一票 | 86.6% / 86.6% | 86.6% / 86.6% |
| 词面倒排（最坏的可信提供方） | 1.0% / 3.8% | **26.0% / 59.7%** |
| 与语义无关的稳定顺序 | 3.1% / 16.8% | **18.8% / 43.9%** |
| 哈希假向量（真库 174 条，池 10） | 11.5% / 35.6% | **29.3% / 77.0%** |
| 纯词面（不看提供方） | 合成 50.4% / 67.5%；真库 **67.2% / 91.4%** | 同左 |

（每格 = hit@1 / hit@4；前四行是合成 704 条卷子，后两行是 174 条真库探针。
两家真端点的数字在下面「付费云端端点实测」里。）三点结论：

1. **默认 `rrf`**：`trust` 的高天花板只在「提供方确实比词面准**、而且只投头部**」时兑现（理想提供方 86.6%），
   它的噪音会**全额**转成损失 —— 哈希假向量把真库 67.2% 砸到 11.5%，真端点上也是一样（见下表）。
2. **`trust` 有个反直觉的病症：更敢翻盘，也更容易砸锅。** 真端点那 174 条里，`trust` 在「词面排第 2~20 名」
   的争夺集里翻盘率最高（57.9% vs `rrf` 的 47.4%），但它把「词面本来第 1」的 117 条**挤掉 53 条**
   （`rrf` 只挤掉 16 条）—— 总分 55.7% vs 73.6%，差别全在这里。想让 `trust` 的收益兑现，
   先用量尺确认你的端点真的比词面准（`npm run bench:embed -- --real`）。
3. **「只投一票」两种融合同分**：稀释来自提供方**自己的低名次**，不是来自「它参与了融合」。
   所以问题不在「要不要融合」，而在「按谁的顺序」。
4. **第三档 `guarded` 把上面第 2 条的病灶直接切掉**（下面单独一节）：它不按谁的顺序走，而是
   「准不准它插队」—— 只动第 1 名、只在窗口内部动、还要过把握度门槛。

#### 把握度门槛 `guarded`：只准窗口内部换次序，不准从池子里换人（`npm run bench:guard`）

174 条真库卷子，池 10 / 窗口 4：**保护集 117 条**（纯词面本来就答对）与**争夺集 52 条**（正确笔记被词面排在第 2~10 名）。
「假分数」一列是把提供方的分数换成与相关性无关的确定性哈希分（= 垃圾提供方的最坏情况；词面一侧不变，所以基线还是 67.2%）：

| 融合 | 智谱 `embedding-3` | 本机 `qwen3-embedding:0.6b` | 保护集被挤掉 | 假分数（垃圾提供方） |
|---|---|---|---|---|
| 纯词面（不看提供方） | 67.2% / 91.4% | 同左 | 0 | 67.2% / 91.4% |
| `rrf` | 74.7% / 93.7% | 68.4% / 94.3% | 11 / 15 | 34.5% / 78.7% |
| `trust` | 58.0% / 89.7% | 60.3% / 90.8% | 48 / 40 | 10.9% / 37.9% |
| **`guarded`（门槛 0.5）** | **76.4% / 91.4%** | **75.9% / 91.4%** | **2 / 1** | **54.6% / 91.4%** |
| `guarded-pool`（对照：允许换人） | 78.2% / 94.3% | 75.9% / 93.1% | 4 / 4 | 34.5% / 90.2% |

每格 = hit@1 / hit@4。四条读法：

1. **`guarded` 是 hit@1 最高的一档，而且 hit@4 与纯词面恒等** —— 因为它「不换人」：只把提供方的头名
   在窗口内部提到最前，窗口里的集合一个字不动（这是它的硬性质，不是巧合）。
2. 它比 `rrf` 多赚 1.7（智谱）/ 7.5（本机）个 hit@1，同时把保护集的损失从 11 / 15 条压到 2 / 1 条。
3. 最坏情况（垃圾提供方）它只赔「插错队」的那一格：hit@4 一格不掉、hit@1 掉 12.6 个点；
   同一场景 `rrf` 掉 32.7 个点、`trust` 掉 56.3 个点。
4. 代价是吃不到「把词面第 5~10 名的正确笔记捞进窗口」的红利 —— 允许换人的形状（`guarded-pool`，
   只留在尺子里做对照）在智谱上是 78.2% / 94.3%，但垃圾提供方下 hit@1 塌到 34.5%。产品里只保留
   「窗口内部」这一种，理由就是这一行。

门槛怎么选：τ=0.5 在两个真端点上同时是 hit@1 的峰值（0.3 太松、0.7 太紧都会掉分）；
τ=0 等于「谁分高听谁的」，实测就退化成 `trust` 的下场。

#### 池宽在公开数据集上量不出差别（`npm run bench:ceiling`）

LongMemEval-S 500 题，每题自带的 haystack 只有 30~50 个 session（数据 `sha256_12 = d6f21ea9d60a`）：

| 候选池 | 5 | 20 | 50 | 100 |
|---|---|---|---|---|
| 池内召回 | 98.2% | 99.6% | 99.8% | 99.8% |

按题型看，唯一在 top-5 有明显缺口的是偏好题（90.0%），它在 top-20 就补满。所以这份公开卷子上
**top-5 已经够了，池宽放宽买不到分数**——它的意义在库比窗口大得多的时候（合成 704 条 70.3% → 97.3%；
真库 66 篇 / 200 chunk 从池 5 起就是 36/36）。一句话：**池宽是给大库留的余量，不是公共基准上的分数。**

代价（离线估算，按池 100 算，即最坏情况）：

| 项 | 中位 | p90 | 最大 |
|---|---|---|---|
| 候选条数 | 27 | 41 | 48 |
| 片段字符数 | 1916 | 2921 | 3425 |

按英文 ≈4 字符/token 粗估：一条查询约 479 token（中位）、730 token（p90）。提供方按文本缓存，
同一篇笔记反复出现只付一次；**真延迟要在有端点时量**（`npm run embed:check` 会报一次往返耗时）。

#### 内置参考提供方：OpenAI 兼容的 embeddings（`providers/openai-embeddings.mjs`）

填两个环境变量就能试；密钥只从环境变量或一个 0600 的小文件进，**不进仓库、不进日志**：

```bash
node providers/openai-embeddings.mjs --check     # 先自检：报维度与耗时（不打印密钥）
```

```yaml
        semanticProvider: 'dsh-engramory-recall/providers/openai-embeddings'
        semanticTimeoutMs: 15000   # 远端接口要给足预算（默认 2000ms 是给本机小模型的）
        semanticPool: 10           # 量过：池 10 在真库 174 条上 hit@1 最高（见「付费云端端点实测」）
        semanticFusion: guarded    # 量过端点才敢开的档：有把握才准插队，且只在窗口内部换次序
        semanticGuard: 0.5         # 门槛；0.5 在两个真端点上都是 hit@1 峰值
```

```bash
export ENGRAMORY_EMBED_API_KEY=sk-…                                  # 或写 ~/.config/engramory/embed.json
export ENGRAMORY_EMBED_BASE_URL=https://api.openai.com/v1            # 换一家就换这两行
export ENGRAMORY_EMBED_MODEL=text-embedding-3-small
```

**embeddings 只有 OpenAI 这一种接口形状**（`POST {base}/embeddings`）：Anthropic 没有 embedding 接口，
所谓「Claude 协议」在这里用不上。国产替代填 `BASE_URL` + `MODEL` 即可，例如阿里百炼
`https://dashscope.aliyuncs.com/compatible-mode/v1` + `text-embedding-v4`、智谱
`https://open.bigmodel.cn/api/paas/v4` + `embedding-3`、硅基流动 `https://api.siliconflow.cn/v1` + `BAAI/bge-m3`。

#### 哪些服务能用（以及 DeepSeek 为什么不行）

要求只有一条：提供 `POST {base}/embeddings`。常见可用名单（`BASE_URL` + `MODEL` 照填）：

| 供应商 | BASE_URL | 模型示例 |
|---|---|---|
| OpenAI | `https://api.openai.com/v1` | `text-embedding-3-small` / `-large` |
| 智谱 | `https://open.bigmodel.cn/api/paas/v4` | `embedding-3` |
| 阿里百炼 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `text-embedding-v4` |
| 硅基流动 | `https://api.siliconflow.cn/v1` | `BAAI/bge-m3` |
| Mistral | `https://api.mistral.ai/v1` | `mistral-embed` |
| Together | `https://api.together.xyz/v1` | `BAAI/bge-large-en-v1.5` |
| 本机 Ollama | `http://localhost:11434/v1` | `nomic-embed-text` / `bge-m3` |
| 本机 LM Studio | `http://localhost:1234/v1` | 你加载的那个 embedding 模型 |

**不能用的两家**，原因不同：

- **Anthropic（Claude）没有 embeddings 接口** —— 「Claude 协议」跟向量无关，填了也只会 404。
  官方推荐的做法是另找一家 embeddings 服务。
- **DeepSeek 官方 API 只有对话模型**（[官方模型表](https://api-docs.deepseek.com/quick_start/pricing)
  里只有 `deepseek-flash` / `deepseek-v4-pro`，没有 `/embeddings`）⇒ **你现在这把 DeepSeek key 用不上这条路**。
  想零成本试就把 Ollama / LM Studio 在本机起一个（完全不出网），想真量就挑上面任一家。

**本机端点不需要密钥**：`localhost` / `127.0.0.1` / `::1` / `0.0.0.0` 一律免密钥（也不再发
`Authorization` 头）—— Ollama、LM Studio、llama.cpp 直接填 `BASE_URL` + `MODEL` 就能用。
远端端点若也不需要密钥（内网网关），随便填一个值即可。

一探便知：`node providers/openai-embeddings.mjs --check` —— **404 = 这家没这个接口；401 = 密钥不对**；
成功了会报维度与一次往返耗时（本机端点会直接写“本机端点，无需密钥”）。

#### 五步接上本机 Ollama（零成本、零出网、已实测）

```bash
brew install ollama                    # 或从 ollama.com 下 Ollama-darwin.zip 拖进 /Applications
ollama pull qwen3-embedding:0.6b       # 639 MB，1024 维；中文/英文都够用（4b / 8b 更准也更慢）
ollama serve                           # 桌面 App 会自动起；装完 App 就不用手动跑这条
```

```jsonc
// ~/.config/engramory/embed.json（chmod 600；不需要密钥字段）
{
  "baseUrl": "http://127.0.0.1:11434/v1",
  "model": "qwen3-embedding:0.6b",
  "queryPrefix": "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
}
```

```yaml
        semanticProvider: 'dsh-engramory-recall/providers/openai-embeddings'
        semanticTimeoutMs: 15000   # 本机 0.6b 一次检索 p50 约 0.3~0.5 秒，给足预算
        semanticPool: 10           # 量过：池 10 比池 20/50 更准也更省（见下表）
        semanticFusion: guarded    # 量过端点才敢开的档：有把握才准插队，且只在窗口内部换次序
        semanticGuard: 0.5
```

`qwen3-embedding` 是**指令型**模型：查询要带上面那个 `queryPrefix`（文档不带前缀），实测带比不带
真库 hit@1 高 5.5 个点（86.1% vs 80.6%）；换别家的模型时把 `queryPrefix` 删掉即可。

用云端端点的话，配置文件长这样（**不要** query 前缀，`embedding-3` 不是指令型模型）：

```json
{ "baseUrl": "https://open.bigmodel.cn/api/paas/v4", "model": "embedding-3", "apiKey": "…" }
```

两份配置可以并存成 `embed.json.zhipu` / `embed.json.ollama`，`cp` 一下就换家；
**改完不用重启 App**（配置每次检索现读，只有改代码才要重启）。量尺是同一条：
`npm run bench:embed -- --real --store ~/memory/engramory`。

**限流与临时故障会自动扛**：`429 / 500 / 502 / 503 / 504`（以及 408/409/425）重试 2 次，尊重服务端
`Retry-After`（上限 5 秒，而且等待可被超时预算打断）；`400 / 401 / 403` 这类不重试 —— 重试改不了
鉴权与请求体的问题。重试到上限的错误会写成 `embeddings HTTP 503（已重试 2 次）：<服务端原话>`。

**本机端点零成本零出网，所以已经量过了**（2026-10-15，M 系 Mac，Ollama
`qwen3-embedding:0.6b`、1024 维、query 前缀 `Instruct: … \nQuery: `、窗口 top-4）：

| 配置 | 真库 174 条 hit@1 / hit@4 | 争夺集翻盘 | 保护集守住 / 被挤掉 | 真库每次检索 p50 |
|---|---|---|---|---|
| 纯词面 | 67.2% / 91.4% | — | — | — |
| `rrf` · 池 10 | 68.4% / 94.3% | 32.7%（17/52） | 102/117（87.2%） / 15 | 132 ms |
| **`guarded` · 池 10** | **75.9%** / 91.4% | 30.8%（16/52） | 116/117（99.1%） / **1** | 133 ms |
| `trust` · 池 20 | 59.2% / 89.1% | 50.9%（29/57） | 74/117（63.2%） / 43 | 174 ms |

**付费云端端点也量过了**（2026-10-15，智谱 `embedding-3`、2048 维、0.5 元/百万 token，
配置只有 `baseUrl` + `model` + `apiKey`，**没有** query 前缀）：

| 配置 | 真库 174 条 hit@1 / hit@4 | 争夺集翻盘 | 保护集守住 / 被挤掉 | 真库每次检索 p50 |
|---|---|---|---|---|
| 纯词面 | 67.2% / 91.4% | — | — | — |
| `rrf` · 池 10 | **74.7%** / 93.7% | 46.2%（24/52） | 106/117（90.6%） / 11 | 273 ms |
| `rrf` · 池 20 | 73.6% / 94.3% | 47.4%（27/57） | 101/117（86.3%） / 16 | 272 ms |
| `rrf` · 池 50 | 73.6% / 95.4% | 49.1%（28/57） | 100/117（85.5%） / 17 | 281 ms |
| **`guarded` · 池 10** | **76.4%** / 91.4% | 34.6%（18/52） | 115/117（98.3%） / **2** | 268 ms |
| `trust` · 池 20 | 55.7% / 85.6% | 57.9%（33/57） | 64/117（54.7%） / 53 | 288 ms |

（「争夺集」= 词面排第 2~pool 名、重排有机会翻到第 1 的题；「保护集」= 词面已经第 1、重排只能弄坏的题。
**只看总分是不够的**：一个升一个降时总分可能不动，但机制完全不同 —— 这就是扩卷之后才看见的东西。）

**一句实话（四条，前两条推翻了 0.12.7 的结论）**：

1. **云端确实比本机 0.6B 强，但只强 6.3 个点。** 同一张 174 条卷子：智谱 `rrf`·池 10 **74.7%**，
   本机 `qwen3-embedding:0.6b` 68.4%（11 条之差）。0.12.7 说的「云端无法区分」是 **36 条卷子的噪声产物** ——
   那条卷子一条 query 就值 2.8 个点；扩到 174 条（一条 0.57 个点）才看得出这 6.3 个点。钱没白花。
2. **延迟上本机反而更快（模型常驻时）：132ms vs 273ms。** 0.12.7 说的「云端赢在速度」同样是短卷子 +
   本机冷启动摊出来的 —— 单次延迟受机器状态影响很大，别把它当结论。云端真正稳的便宜是**不占本机内存**
   （本机那个模型常驻约 5.5GB）。成本侧不变：**0.00017 元/次检索**（真端点 `usage` 实测一次 335 token，
   约 6000 次一块钱）。
3. `trust` 这一档**不要碰**：两家提供方、真库与合成，数字全部明显低于 `rrf`，真库上它把 117 条本来第 1 的
   挤掉 53 条（`rrf` 挤掉 11 条）。
4. 上面第 4 条曾经写着「下一块能看到的天花板：加一道把握度门槛，真库 hit@1 能从 74.7% 上到约 81%」——
   **已经做了，而且不用换人**：`guarded` 在两个真端点上分别是 76.4% 与 75.9%（hit@4 与纯词面恒等，
   保护集只丢 2 条 / 1 条）。当时估的 81% 是「允许从池子里换人」的版本，那个版本在垃圾提供方下
   会塌到 34.5%，所以产品里没要（对照见上面「把握度门槛」那节的最后一行）。

换一家端点就是改配置文件那三行；两套配置可以并存（`cp embed.json.zhipu embed.json` 即切），
改完**不用重启 App** —— 配置每次检索现读。

#### 真端点怎么量：`npm run bench:embed`

一条命令，两种模式：`--fake`（默认）起一个**本机假 embeddings 服务**，只读「管线与代价」；
`--real` 用 `providers/openai-embeddings.mjs` 的配置读法找真端点，那时质量列才是真的。
真库那半边要显式加 `--store ~/memory/engramory` —— 加了就意味着**你的笔记片段会发给那个端点**，
所以默认不开。

`--timeout <ms>`（默认 20000）是给这条端点的预算：**超时即降级为词面结果**，不是报错。
拿本机慢服务自证过：服务端故意每次慢 1.5 秒、预算给 800 ms ⇒ 每次检索 p50 卡在 805 ms
（预算被当真了），结果全列 `超时/失败 = 20（timeout×20）`，质量列退回词面基线 —— 端点在抖也不会拖死检索。

假向量（哈希向量 = 最坏提供方）跑出来的两组数字，正好把上面那张融合表变成实测：

| 卷子 | 词面 hit@1 / hit@4 | 假向量 + `trust` | 假向量 + `rrf` | 假向量 + `guarded` |
|---|---|---|---|---|
| 合成基准 704 条 | 50.4% / 67.5% | 18.3% / 25.4% | 26.8% / 43.6% | — |
| 真库探针 174 条（池 10） | 67.2% / 91.4% | 11.5% / 35.6% | 29.3% / 77.0% | **54.6% / 91.4%** |

`trust` 对垃圾提供方是**灾难**（真库上 67.2% → 11.5%，117 条本来第 1 的只剩 12 条），
`rrf` 也一样亏但守住三分之一，`guarded` 只赔「插错队」的那一格（hit@4 与词面恒等）。
所以：**不确定提供方靠不靠谱就别开 `trust`**（这也是第一次接第三方接口时的默认建议）；
`guarded` 是这三档里最抗垃圾提供方的一档，但**它仍然会因为「插错队」掉 hit@1** —— 端点配错了先修端点。

代价侧（同一次运行数的）：池子实际大小中位 31~39 条（不是 50，因为词面只回有分的文档）、
每次检索 p50 19~20 ms（假服务在本机，真延迟看端点）；提供方按文本缓存让
**26925 条候选只发了 1994 条（省 92.6%）** —— 同一篇笔记在不同查询里反复出现时只付一次钱。

#### 复用 DSH 里已经配好的那条路由（`provider: "host-embed"`）

不想再填一遍环境变量：DSH 设置页 → 模型里配好的路由（`baseURL` + 模型 + API 密钥）可以**直接借来用**：

```yaml
        semanticProvider: 'host-embed'
        semanticHostRoute: ''      # 留空 = 用当前默认模型的那条路由；也可指名（如 openai / zhipu）
        semanticTimeoutMs: 15000
```

它按四步读宿主，每步失败都给**可照做**的提示（而不是一句「配置错误」），失败一律降级回词面：

| 步骤 | 从哪读 | 读不到时 |
|---|---|---|
| 路由 | `semanticHostRoute` → 插件的 `llmProvider` → `agentDefaultModel.currentSelection()` | 提示写 `semanticHostRoute` |
| `baseURL` | 该路由配置里的 `baseURL`；目录自带路由（openai / mistral / together / 智谱）有内置兜底 | 提示去「自定义模型 API」加一条路由，或写 `semanticHostBaseUrl` |
| 模型 | 路由 `models[]` 里名字含 embed 的那个；单模型路由直接用它；内置兜底 | 提示写 `semanticHostModel` |
| 密钥 | 路由的 `apiKeyEnv`（没有就按 `<ROUTE>_API_KEY` 派生）→ `ctx.credentials.resolve()` | 提示去设置页填密钥，并说清 ref 名字 |

三条纪律：密钥**只在这四步里过一遍**，不进返回值、不进日志、不进错误信息；凭据**每次检索现解析**
（宿主规范：不许跨操作缓存）；MCP 那种没有宿主的场景会明确报 `no-host` 并提示改用本地模块提供方。
`host-llm` 还有第三种走不通的情形：运行时与默认模型都在、但 `ctx.get('llm')` 拿不到带 `stream()`
的服务 —— 报 `no-llm`（0.13.14 修：此前这里读的是路由对象上的 `route.llm`，而路由只带
provider/model 两个名字，于是真宿主里这条路恒 `TypeError` 再降级，JSON 抽取那段根本没跑到过）。



`host-llm` 能跑通不等于划算，所以这条尺子用**真宿主模型**量了一遍（2026-10-15，逐条调用本机
harness，提示词直接 `import` 产品的 `hostLlmPrompt()` —— 尺子问的就是产品要问的那句话；
采集结果缓存在 `bench/.tmp/`，`--offline` 可以只重算不重采）：

| 卷子 | 条数 | 延迟（中位 / p90 / 最大） | 超过默认 2000ms 预算 | 词面 → 接缝（hit@1 / hit@4） | 池内有 gold | 任意一条改写单独检索命中 gold@1 |
| --- | --- | --- | --- | --- | --- | --- |
| 合成基准子集 | 70 | 3796ms / 11379ms / 14043ms | **70/70** | 70.0% / 87.1% → 逐条相同 | 68/70 | 50.0% |
| 真库探针（36 条真实口语） | 36 | 4230ms / 9937ms / 10978ms | **36/36** | 72.2% / 100% → 逐条相同 | 36/36 | 80.6% |

代价和收益在同一张表里，结论就一句：**信号在，但用不上**。

- **延迟**：中位 3.8~4.2 秒，没有一次落在插件默认的 2000ms 预算里。这不是「把超时调大」能解决的 ——
  按需检索是人等着的那种调用，挂 4 秒等于把插件废掉（自动召回每轮都跑，更不能挂）。
- **收益**：改写**一条也没把 gold 从前 4 名里救出来**（两种卷子都与词面逐条相同）。
- **上界**：模型给的 3 条改写里「任意一条单独检索」能命中 gold@1 的比例是 50.0% / 80.6%，
  而词面是 70.0% / 72.2% —— 真库那卷高 8.4pt，说明**改写里确实有信号**；但要用上它，
  得先有一个能判断「哪条改写更好」的打分器，而宿主只给对话接口、没有 embedding。

「不知道好坏就投票」的后果也量了（同一批改写离线重放，`--json` 里是全部逐条数字）：

| 融合策略 | 合成基准子集 hit@1 / hit@4 | 真库探针 hit@1 / hit@4 |
| --- | --- | --- |
| `append`（**当前实现**：词面顺序不动，只用改写补空位） | 70.0% / 87.1% | **72.2% / 100%** |
| `gate`（只有词面什么也没查到时才用改写） | 70.0% / 87.1% | 72.2% / 100% |
| RRF，k = 1 / 3 / 5 / 60 | 51.4% / 48.6% / 51.4% / 47.1% | 69.4% / 63.9% / 61.1% / 52.8% |
| 归一化分数：求和 / 取最大 | 57.1% / 28.6% | 63.9% / 27.8% |

模型写了 3 条「更好的查询」，融合却让 hit@1 掉 7~19 个百分点（真库 72.2% → 52.8%）：投票数变成
4 倍，而词面那 1 票并不比模型那 3 票更该被信任。所以 0.11.1 起 `expand` 的语义就是**只补空位**，
`semantic` 字段里的 `added` 会告诉你补了几条。

**那 `host-llm` 还开不开？** 只有两种场合值得：你要一次拿满 limit 条（它补位不排序），
或者你在自己写的脚本里拿它当**候选生成器**。插件这条路上更实际的做法是
**你（agent）自己换个说法再检索一次** —— 你有能力判断哪条结果对，而且你的模型调用不是逐条同步的。

## 十四条被实测证伪的路

| 尝试 | 结果 |
|---|---|
| 让**每轮自动注入**也走语义（现在只有按需检索走） | 离线卷子 174 条、注入窗口 5 条上算：词面 hit@5 93.1% → rrf **95.4%**（+2.3 点，净捞进 4~5 条、净挤掉 0~1 条；hit@1 67.2% → 71.8~74.7%），trust 反而掉到 92.0%。收益真实但代价是**每一轮**：把最多 10 条候选片段发给端点（云端 = 每轮出网，本机 = 每轮 +130ms 且要常驻 5.5GB 显存）。而漏掉的那些指针并没有丢 —— 按需检索（`engramory_search`）带着语义接缝还能把它们找回来，每轮注入省下的这份开销换的是「少提醒一次」。**结论：分工不变**（每轮注入零出网零常驻，语义只按需） |
| 以为「把握度门槛」够安全，就允许它从池子里换人 | 174 条真库卷子（智谱 / 本机两个端点）：允许换人的形状在智谱上是 78.2% / 94.3%，比只换次序的 76.4% / 91.4% 多 1.8 个 hit@1、多 2.9 个 hit@4。但把提供方分数换成与相关性无关的假分数后，它 hit@1 从 **54.6% 塌到 34.5%**（保护集 117 条里被挤掉 61 条，而只换次序的版本只丢 26 条）。机制：换人意味着**窗口里的集合**变了 —— 提供方一旦在窗口外乱指，词面本来排第 1 的那篇就被顶出窗口，而「已经在窗口里的人」被重排最多只伤到 hit@1。所以产品里只保留「窗口内部换次序」（`npm run bench:guard` 里留了对照行） |
| 拿「结果要多样」当选桶护栏：MMR / 覆盖优先 | 在 LongMemEval-S 500 题上，MMR（对 tf 集合算 Jaccard，λ 取 0.15 / 0.3 / 0.5）与基线**逐位相同**（87.8% @1）：不同 session 之间几乎不重叠，惩罚项无处发力。覆盖优先 β=1.0 让 @1 从 87.8% 掉到 86.4%，换来的 @5 只有 +0.2pt。机制：我们要解决的是「这个词的重心在别处」，不是「两条结果太像」。跨会话类题目（multi-session 133 题）真正的瓶颈是 gold session 里根本没有问题里的名词 —— 948 个 gold 里 16.5% 与问题零词重叠，19.1% 排在第 6~10 名、39.1% 在第 11~25 名，属于「差一点」，需要的是语义不是重排 |
| df 下限（把出现在超过一半文档里的词直接扔掉） | LongMemEval-S 上扫描 keep6 / keep12 / 0.7 三档：@1 与基线持平或更差（80.5% vs 81.5%），@5 一点不涨。英文虚词已经由查询侧停用词处理掉，索引侧再删一遍只会顺手动掉 `len`。它的**查询侧版本**同样无效：把出现在 >35% / >20% 文档里的查询词丢掉，@1 从 88.4% 掉到 87.6% / 85.4% —— 高频词本身不脏，脏的是「求助/建议」那一类词（见「效果」一节的第 1 条实话） |
| 拿日期当检索信号（解析查询里的时间词做时间窗过滤） | `haystack_dates` 与 `question_date` 都在数据集里，喂进去也救不回来：500 题只有 **39 题**能抽出可用时间窗（其余要么没时间词，要么问的是「多少天前」这类间隔 —— 那 96 题现在 @1 已经 89.6%）。硬过滤的账是 **11 题救回 / 11 题误伤**，净收益 0。更根本的机制：**问题引用的是事件时间，不是 session 时间戳** —— `How many different museums did I visit in December?`（问于 2026-03-03）的 gold 全在 3 月；`Which pair of shoes did I clean last month?`（问于 05-24）的 gold 就在当天，而「上个月」那个窗里躺着 23 个干扰 session。日期侧的先验也不成立：948 个 gold 在各自 haystack 里的**新度百分位平均 0.459**（0.5 = 完全随机），纯按日期排的引擎 @1 只有 3.8%（我们 89.2%） |
| 伪相关反馈查询扩展（拿 top-1 的高频词再搜一遍，RM3 那一路） | 在 LongMemEval-S 500 题上前 120 题实测：RecallAny@1 从 89.2% 掉到 **48.3%**。机制：扩出来的词全是 top-1 自己的词汇，等于把「已经赢了的文档」又投了一遍票，稍有偏差的第一次命中会被自己的回声推到第一。Recall@5 只涨 0.4pt，@1 腰斩 —— 这种改动在只看 top-5 的评测里会被误认为「有效」 |
| 提高分数门槛 / IDF 门槛过滤误召回 | 正例最低分 4.59、反例最高分 5.54，区间重叠切不开；IDF 门槛会把 recall 从 28/28 打到 13/28 |
| freshness 时间衰减（学灵枢 / dsh-mneme） | 六档半衰期扫描：recall 恒为 28/28、top-1 只有一档 +1（噪声级）。库里「旧」的那批恰好是基础事实，衰减就是误伤 |
| 时间乘性加权（学 hindsight） | 五档扫描净收益为零，`w ≥ 0.1` 起反而伤对照组 |
| 给一个已有同义桥继续加词（`主题` 从 `['theme']` 扩到含 `light-dark / color-mix / data-theme / token`） | 看着全是相关的技术词，实测对照 B 掉 3 分（136→133）。机制：把词加进查询扩展后，同一篇笔记自己的其它 chunk 会把「标题里真的写着这个词」的那个 chunk 挤下去，长笔记里这叫自己跟自己抢。判据不是「这词相不相关」，而是「加了它，谁会往上浮」。这 4 个词已全部退回 |
| 拿宿主模型的改写去和词面结果融合（RRF / 归一化求和 / 取最大） | 用真宿主模型（逐条调用本机 harness，提示词取自产品代码）在合成子集 70 条 + 真库探针 36 条上量：改写里**确实有信号**（任意一条单独检索命中 gold@1 达 50.0% / 80.6%，词面是 70.0% / 72.2%），但融合发展一律变差 —— RRF 真库 72.2% → 52.8%、取最大 → 27.8%。机制：投票数变成 4 倍，而词面那 1 票并不比模型那 3 票更该被信任，宿主又没有打分器判断改写好坏。修法是让 `expand` **只补空位**（0.11.1），并且承认「模型能写出更好的查询」≠「检索变好」（`npm run bench:rewrite`） |
| 拿等权 RRF 当语义提供方的默认融合 | 合成 704 条、池 20、窗口 4：**完美的全量排名提供方**（真 embedding 的形态）在等权 RRF 下 hit@1 只有 52.7%，比纯词面（50.4%）几乎没赚；同一个提供方在 `trust` 下是 86.6%。机制：提供方返回的是全量排名，它自己第 2 名以后的名次也会拿到票，于是把第 1 名挤下去 —— 稀释来自它**自己的低名次**，不是来自融合本身（「只投一票」的完美提供方两种融合同为 86.6%）。**当时据此把默认改成 `trust`，后来又被两个真提供方测量推翻了**：哈希假向量（trust 真库 5.6% / rrf 19.4%）与本机 qwen3-embedding:0.6b（trust 72.2% / rrf 86.1%）都说明「理想提供方」这个前提不成立 —— 真提供方是**有噪音**的，而 trust 把噪音全额转成损失。默认已改回 `rrf`，`trust` 变成「量过端点后再开」的高天花板档 |
| 拿偏袒的对照当护栏：用对照 A/B 证明「抬高标题权重是安全的」 | 它们都拿标题当查询，对标题权重结构性偏袒，抬高它必然让两组变好。这不是证据，是同义反复。`TITLE_EXTRA` 第一次定成 3 就是被这个漏洞困住的（「口径在 3 之后不再动，所以后面的增量全来自对照」，而对照根本测不出这件事）。补了中立的对照 C 之后，数据说 3 太保守：C 从 0 到 96 一路不降，在 24 到达平台。已按 C 的拐点改成 24 |
| 以为**注入窗口能按分数断层自适应收缩**（注到出现明显落差为止，省几条上下文） | 174 条真库卷子（池 10）上离线扫了 14 条规则。会**省**条数的两条都亏：`断层 < 0.6 保底 1` 平均注 4.29 条、保住 gold 92.5%（比固定 4 条的 91.4% 高 1.1 点，但低于固定 5 条的 93.1%），`肘部截断`（前 4~5 条里最大落差处停）平均 2.57~2.73 条、覆盖掉到 **85.6%~87.4%**（比固定 4 条低 4.0~5.7 点）。反过来「分数 < 3 就停」保住 95.4% 却平均要注 7.85 条 —— 它不是窗口规则，是「把窗口开更大」。机制：gold 在池内的名次分布是 **117/17/21/4/3/0/2/2/1/2**，尾部那 10 条（5.7%）gold 的**词面分本来就低**（它落在断层之下），分数根本分不开「尾部的 gold」与「尾部的噪声」——想要精度只能靠**提供方的分数**（就是 `guarded` 那类门槛），拿词面分数裁窗口只会把对的题一起裁掉。**结论：窗口宽度照 hit@k 曲线走，不按分数裁** |
