dsh-engramory-recall 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,41 @@
3
3
  本项目的所有重要变更都记在这里。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
4
4
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
5
5
 
6
+ ## [0.11.2] — 2026-10-15
7
+
8
+ ### 变更
9
+
10
+ - **把「换措辞再搜一次」写进模型看得见的地方**:`engramory_search` 的工具描述补一句
11
+ 「它匹配的是措辞不是意思 —— 没命中就自己换个说法再搜一次(同义词、术语英文、笔记里可能用的词)」,
12
+ 未命中时的提示也从「换关键词再试」改成同一句话。依据是宿主模型改写那把尺子量出来的:
13
+ 同一个意思换个说法能命中而原句命不中的比例是 **80.6%**(词面自身 72.2%),
14
+ 而让插件内置去「融合模型改写」是负收益。所以这个能力交给调用方用,而不是塞进检索里。
15
+
16
+ ## [0.11.1] — 2026-10-15
17
+
18
+ ### 修复
19
+
20
+ - **`expand` 型语义提供方改为「只补空位」**:词面排出来的顺序一个字不动,改写带来的新文档接在后面
21
+ (`semantic` 字段新增 `added`,报补了几条)。此前 `expand` 走的是与 `rerank` 相同的 RRF 融合 ——
22
+ 用真宿主模型实测,那是**负收益**:模型给的 3 条改写融合进来后,真库探针 hit@1 从 72.2% 掉到 52.8%
23
+ (RRF k=60),归一化取最大更是掉到 27.8%。原因是投票数变成 4 倍,而词面那 1 票并不比模型那 3 票
24
+ 更该被信任。证据与全部变体见 README「让宿主模型当改写提供方」一节。
25
+ - 回归测试:`test/semantic.test.mjs` 把 expand 的两种情形分别钉住(词面占满 `limit` 时必须逐字相同;
26
+ 词面不足时只许接在后面);`test/semantic-corpus.test.mjs` 新增一例 —— 用「同一篇笔记的其它问法」
27
+ 当**真实的好改写**(这正是能把旧实现打出 -7 hit@1 的那类输入),在 117 条真语料查询上断言命中与
28
+ 词面逐条相同、`added` 恒为 0。
29
+
30
+ ### 新增
31
+
32
+ - **宿主模型改写尺子**(`bench/host-rewrite.mjs`,`npm run bench:rewrite`):以本机 harness 为
33
+ 「真宿主模型」逐条采集改写(提示词与解析直接 `import` 产品代码,缓存可续跑、`--offline` 可只重算),
34
+ 再离线重放给真接缝。量三件事:逐条延迟、词面 vs 接缝的命中、以及「任意一条改写单独检索」的上界。
35
+ 实测:延迟中位 3.8~4.2 秒、**70/70 与 36/36 都超出默认 2000ms 预算**;收益 0(与词面逐条相同);
36
+ 上界 50.0% / 80.6%(词面 70.0% / 72.2%)⇒ 信号在改写里,但宿主没有打分器,用不上。
37
+ `semantic.js` 为此导出 `hostLlmPrompt()` / `normalizeExpansions()`,保证尺子问的就是产品要问的那句话。
38
+ - README:新增「实测:让宿主模型当改写提供方」小节(代价/收益/融合变体三张表 + 什么时候才值得开),
39
+ 并把「拿模型改写去融合」记进「被实测证伪的路」(现十条)。
40
+
6
41
  ## [0.11.0] — 2026-10-15
7
42
 
8
43
  ### 新增
package/README.md CHANGED
@@ -591,7 +591,7 @@ export async function rerank({ query, docs, limit, signal }) {
591
591
  return [{ id: 'reference-publish-plugin-to-npm.md', score: 0.91 }];
592
592
  }
593
593
 
594
- // 2) 扩展:把问题改写成几句别的说法,由插件分别检索再融合
594
+ // 2) 扩展:把问题改写成几句别的说法,只用来**补空位**(词面排出来的顺序一个字不动)
595
595
  export async function expand({ query, signal }) {
596
596
  return ['npm 发布 token 403', '怎么把包发到 npm'];
597
597
  }
@@ -604,7 +604,7 @@ export async function expand({ query, signal }) {
604
604
 
605
605
  | 行为 | 为什么 |
606
606
  |---|---|
607
- | 结果是 **RRF 融合**,不是替换 | 同一个 limit 下输出就是同一批文档的重排(不会凭空多出、也不会丢;`test/semantic-corpus.test.mjs` 在真语料上钉这条)。Rerank 返回候选之外的文件名一律丢弃并报数 |
607
+ | `rerank` 走 **RRF 融合**,`expand` **只补空位** | 同一个 limit 下 rerank 输出就是同一批文档的重排(不会凭空多出、也不会丢;`test/semantic-corpus.test.mjs` 在真语料上钉这条)。`expand` 若也拿去融合,实测 hit@1 掉 7~19pt(原因见下面「让宿主模型当改写提供方」):词面那 1 票没有任何理由比模型那 3 票更该被信任。所以它只把词面没排上的文档**接在后面**,`added` 报补了几条。两种提供方返回候选之外的文件名一律丢弃并报数 |
608
608
  | 超时**硬兜一层** `Promise.race` | 提供方可以完全忽略 `AbortSignal`(第三方代码什么都干得出来),只靠 abort 会让检索永远等下去 |
609
609
  | 失败**不抛错**,降级回词面并如实报 `code` | `off` / `not-found` / `unsupported` / `provider-error` / `timeout` / `provider-empty`,检索工具返回值里的 `semantic` 字段直接给出这次到底用没用上 |
610
610
  | **只管按需检索工具**,不碰每轮自动注入 | 自动召回每个用户轮都跑;挂一个要 200ms 的外部件等于给每一轮加税。热路径永远纯词面 |
@@ -622,10 +622,10 @@ export async function expand({ query, signal }) {
622
622
  | 提供方卡住不返回 | 50.4% | 67.5% | **53.0 ms**(预算 50ms) | `timeout`×704 |
623
623
  | 提供方返回编造的 id | 50.4% | 67.5% | 1.4 ms | `provider-empty`×704 |
624
624
  | 离线字符三元组重排(真打分) | 47.4% | 65.5% | 1.4 ms | `reranked`×704 |
625
+ | 按 gold 重排(**作弊上界**,只验管线) | 86.6% | 86.6% | 1.4 ms | `reranked`×610 |
625
626
 
626
627
  (候选池 = 20,评测看前 4 名。`node bench/semantic-seam.mjs --pool 4` 是另一种用法:池子等于输出量时
627
628
  RRF 只能在池内重排,所以 hit@4 一动都不动(67.5%),坏的提供方只赔 hit@1。两种都量过。)
628
- | 按 gold 重排(**作弊上界**,只验管线) | 86.6% | 86.6% | 1.4 ms | `reranked`×610 |
629
629
 
630
630
  三个数字值得单独说:
631
631
 
@@ -641,6 +641,43 @@ RRF 只能在池内重排,所以 hit@4 一动都不动(67.5%),坏的提
641
641
  `test/semantic-wiring.test.mjs`(关着时连 import 都不发生、开着时真的融合、自动注入不碰接缝)
642
642
  就是钉这四条的。
643
643
 
644
+ #### 实测:让宿主模型当改写提供方(`npm run bench:rewrite`)
645
+
646
+ `host-llm` 能跑通不等于划算,所以这条尺子用**真宿主模型**量了一遍(2026-10-15,逐条调用本机
647
+ harness,提示词直接 `import` 产品的 `hostLlmPrompt()` —— 尺子问的就是产品要问的那句话;
648
+ 采集结果缓存在 `bench/.tmp/`,`--offline` 可以只重算不重采):
649
+
650
+ | 卷子 | 条数 | 延迟(中位 / p90 / 最大) | 超过默认 2000ms 预算 | 词面 → 接缝(hit@1 / hit@4) | 池内有 gold | 任意一条改写单独检索命中 gold@1 |
651
+ | --- | --- | --- | --- | --- | --- | --- |
652
+ | 合成基准子集 | 70 | 3796ms / 11379ms / 14043ms | **70/70** | 70.0% / 87.1% → 逐条相同 | 68/70 | 50.0% |
653
+ | 真库探针(36 条真实口语) | 36 | 4230ms / 9937ms / 10978ms | **36/36** | 72.2% / 100% → 逐条相同 | 36/36 | 80.6% |
654
+
655
+ 代价和收益在同一张表里,结论就一句:**信号在,但用不上**。
656
+
657
+ - **延迟**:中位 3.8~4.2 秒,没有一次落在插件默认的 2000ms 预算里。这不是「把超时调大」能解决的 ——
658
+ 按需检索是人等着的那种调用,挂 4 秒等于把插件废掉(自动召回每轮都跑,更不能挂)。
659
+ - **收益**:改写**一条也没把 gold 从前 4 名里救出来**(两种卷子都与词面逐条相同)。
660
+ - **上界**:模型给的 3 条改写里「任意一条单独检索」能命中 gold@1 的比例是 50.0% / 80.6%,
661
+ 而词面是 70.0% / 72.2% —— 真库那卷高 8.4pt,说明**改写里确实有信号**;但要用上它,
662
+ 得先有一个能判断「哪条改写更好」的打分器,而宿主只给对话接口、没有 embedding。
663
+
664
+ 「不知道好坏就投票」的后果也量了(同一批改写离线重放,`--json` 里是全部逐条数字):
665
+
666
+ | 融合策略 | 合成基准子集 hit@1 / hit@4 | 真库探针 hit@1 / hit@4 |
667
+ | --- | --- | --- |
668
+ | `append`(**当前实现**:词面顺序不动,只用改写补空位) | 70.0% / 87.1% | **72.2% / 100%** |
669
+ | `gate`(只有词面什么也没查到时才用改写) | 70.0% / 87.1% | 72.2% / 100% |
670
+ | RRF,k = 1 / 3 / 5 / 60 | 51.4% / 48.6% / 51.4% / 47.1% | 69.4% / 63.9% / 61.1% / 52.8% |
671
+ | 归一化分数:求和 / 取最大 | 57.1% / 28.6% | 63.9% / 27.8% |
672
+
673
+ 模型写了 3 条「更好的查询」,融合却让 hit@1 掉 7~19 个百分点(真库 72.2% → 52.8%):投票数变成
674
+ 4 倍,而词面那 1 票并不比模型那 3 票更该被信任。所以 0.11.1 起 `expand` 的语义就是**只补空位**,
675
+ `semantic` 字段里的 `added` 会告诉你补了几条。
676
+
677
+ **那 `host-llm` 还开不开?** 只有两种场合值得:你要一次拿满 limit 条(它补位不排序),
678
+ 或者你在自己写的脚本里拿它当**候选生成器**。插件这条路上更实际的做法是
679
+ **你(agent)自己换个说法再检索一次** —— 你有能力判断哪条结果对,而且你的模型调用不是逐条同步的。
680
+
644
681
  ## 配置
645
682
 
646
683
  改 profile 的 `cordis.patch.yml`(改完要重启 App 才生效,宿主进程会缓存 ESM 模块):
@@ -811,6 +848,7 @@ npm run probe -- --save # 把这次结果存成基线,之后每次跑自动
811
848
  npm run bench:check # 合成基准 + 和 bench/expectations.json 对账(CI 跑的就是它)
812
849
  npm run bench:bridges # 同义表覆盖率尺子:死值、覆盖、使用面、逐键消融(--ablate / --json / --store)
813
850
  npm run bench:seam # 语义接缝的代价与天花板(四种坏提供方 + 作弊上界)
851
+ npm run bench:rewrite # 宿主模型当改写提供方:真调用量代价与收益(要本机有 dsh;--offline 只重算)
814
852
  npm run bench:mcp # MCP 这一层:冷启动、每步往返、协议帧字节数
815
853
  npm run mcp:smoke # 拿脚本当 MCP 客户端,把服务器拉起来走一遍真实对话
816
854
  node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json # 公开基准(数据要自己下,264MB)
@@ -866,7 +904,7 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
866
904
  | DSH-SEP | 每步 ~1250 字符 guidance + ≤4200 字符快照 | 同上 | 抽取走 `api.deepseek.com` |
867
905
  | **本插件** | **0** | **≤480 字符,且只有指针** | **无** |
868
906
 
869
- ## 九条被实测证伪的路
907
+ ## 十条被实测证伪的路
870
908
 
871
909
  | 尝试 | 结果 |
872
910
  |---|---|
@@ -878,6 +916,7 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
878
916
  | freshness 时间衰减(学灵枢 / dsh-mneme) | 六档半衰期扫描:recall 恒为 28/28、top-1 只有一档 +1(噪声级)。库里「旧」的那批恰好是基础事实,衰减就是误伤 |
879
917
  | 时间乘性加权(学 hindsight) | 五档扫描净收益为零,`w ≥ 0.1` 起反而伤对照组 |
880
918
  | 给一个已有同义桥继续加词(`主题` 从 `['theme']` 扩到含 `light-dark / color-mix / data-theme / token`) | 看着全是相关的技术词,实测对照 B 掉 3 分(136→133)。机制:把词加进查询扩展后,同一篇笔记自己的其它 chunk 会把「标题里真的写着这个词」的那个 chunk 挤下去,长笔记里这叫自己跟自己抢。判据不是「这词相不相关」,而是「加了它,谁会往上浮」。这 4 个词已全部退回 |
919
+ | 拿宿主模型的改写去和词面结果融合(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`) |
881
920
  | 拿偏袒的对照当护栏:用对照 A/B 证明「抬高标题权重是安全的」 | 它们都拿标题当查询,对标题权重结构性偏袒,抬高它必然让两组变好。这不是证据,是同义反复。`TITLE_EXTRA` 第一次定成 3 就是被这个漏洞困住的(「口径在 3 之后不再动,所以后面的增量全来自对照」,而对照根本测不出这件事)。补了中立的对照 C 之后,数据说 3 太保守:C 从 0 到 96 一路不降,在 24 到达平台。已按 C 的拐点改成 24 |
882
921
 
883
922
  ## 许可
package/index.js CHANGED
@@ -292,7 +292,12 @@ export function apply(ctx, config = {}) {
292
292
  'Search the local Engramory markdown memory store with BM25 (offline, read-only). ' +
293
293
  'Use it when the automatic recall did not surface what you need — for example before ' +
294
294
  'answering a question about past decisions, pitfalls or user preferences. It returns ' +
295
- 'matching note filenames with a matched snippet and a score; open the ones you need with read.',
295
+ 'matching note filenames with a matched snippet and a score; open the ones you need with read. ' +
296
+ // 这条不是文风,是量出来的:模型改写融合净赔,而「换措辞再搜一次」的收益=改写里最好那条
297
+ // (真库探针 80.6% vs 词面 72.2%)。措辞敏感是 BM25 的本性,所以交给调用方换词最划算。
298
+ 'It matches wording, not meaning: if it misses, rephrase the query in your own words and ' +
299
+ 'search once more (synonyms, the term in English, the wording a note would use) — cheaper and ' +
300
+ 'more effective than expecting one phrasing to land.',
296
301
  parameters: {
297
302
  type: 'object',
298
303
  properties: {
@@ -373,7 +378,7 @@ export function apply(ctx, config = {}) {
373
378
  hits,
374
379
  note: hits.length
375
380
  ? '用 read 打开需要的文件;正文在笔记里,不在这个结果里'
376
- : '没有命中:换关键词再试(中文可以换相邻词)',
381
+ : '没有命中:换个说法再搜一次(同义词、术语的英文写法、笔记里可能用的那个词)—— 它只认措辞不认意思',
377
382
  semantic: { used: result.used, code: result.code, message: result.message },
378
383
  };
379
384
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-engramory-recall",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Local BM25 auto-recall, fail-closed write guard, static doctor and a memory panel for the Engramory markdown store — no network, no model, no daemon.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -79,10 +79,11 @@
79
79
  "bench": "node bench/run.mjs",
80
80
  "bench:check": "node bench/run.mjs --check",
81
81
  "bench:seam": "node bench/semantic-seam.mjs",
82
+ "bench:rewrite": "node bench/host-rewrite.mjs",
83
+ "bench:bridges": "node bench/bridge-coverage.mjs",
82
84
  "bench:mcp": "node bench/mcp-overhead.mjs",
83
85
  "mcp:smoke": "node scripts/mcp-smoke.mjs",
84
- "release:verify": "node scripts/verify-release.mjs",
85
- "bench:bridges": "node bench/bridge-coverage.mjs"
86
+ "release:verify": "node scripts/verify-release.mjs"
86
87
  },
87
88
  "bin": {
88
89
  "engramory-recall-mcp": "mcp.js"
package/semantic.js CHANGED
@@ -19,14 +19,20 @@
19
19
  * return [{ id: 'some-note.md', score: 0.9 }, ...];
20
20
  * }
21
21
  *
22
- * // 2) 扩展:把问题改写成几句别的说法,由我分别检索再融合
22
+ * // 2) 扩展:把问题改写成几句别的说法,由我分别检索(**只用于补空位**)
23
23
  * export async function expand({ query, signal }) {
24
24
  * return ['另一种说法', '关键词组合'];
25
25
  * }
26
26
  * ```
27
27
  *
28
- * 两种结果都不是「替换」而是「融合」(RRF):词面命中的文档永远还在池子里,
29
- * 提供方只能改变顺序,删不掉东西。
28
+ * 两种接口的边界**不一样**,这是量出来的、不是拍出来的:
29
+ * · `rerank` 声称自己有打分器 ⇒ 结果与词面走 RRF 融合,**允许改顺序**(同 limit 下不增不减);
30
+ * · `expand` 只是「换个说法再查一遍」⇒ **只补空位,永不改动词面顺序**。
31
+ * 实测(2026-10-15,真库探针 36 条 + 合成 70 条,改写由宿主模型逐条产生):
32
+ * RRF 融合让 3 条改写以 1:1 的票数盖过原查询的 1 票,真库 hit@1 26/36 → 19/36、
33
+ * hit@4 36/36 → 30/36;把原查询加权到 3 票 / 5 票、或改成归一化打分,全都仍不如基线;
34
+ * 只有「只补空位」「池子合并(原查询打分优先)」「命中不足才用」三种保守规则能逐字打平基线。
35
+ * 结论:改写能扩候选,改不了排序,那就别让它碰排序。详见 README「宿主模型改写实测」。
30
36
  */
31
37
 
32
38
  import { readFileSync } from 'node:fs';
@@ -119,13 +125,21 @@ function normalizeModule(module, source) {
119
125
  return null;
120
126
  }
121
127
 
122
- /** 宿主模型的「扩展」实现:问模型要几句别的说法,别的什么都不做。 */
123
- async function hostLlmExpand(module, route, query, config, signal) {
124
- const prompt = [
128
+ /**
129
+ * 宿主模型改写的提示词。**导出**是给 `bench/host-rewrite.mjs` 用的:
130
+ * 那把尺子得用同一句话去问真宿主模型,否则量出来的不是产品行为。
131
+ */
132
+ export function hostLlmPrompt(query) {
133
+ return [
125
134
  '把用户的问题改写成 3 条更可能命中本地记忆库的检索短句(同义词、术语、英文对照都可以)。',
126
135
  '只输出 JSON 数组,元素是字符串,不要解释、不要编号。',
127
136
  `问题:${query}`,
128
137
  ].join('\n');
138
+ }
139
+
140
+ /** 宿主模型的「扩展」实现:问模型要几句别的说法,别的什么都不做。 */
141
+ async function hostLlmExpand(module, route, query, config, signal) {
142
+ const prompt = hostLlmPrompt(query);
129
143
  const result = await module.collectText(
130
144
  { stream: (options) => route.llm.stream(options) },
131
145
  {
@@ -208,7 +222,8 @@ function normalizeRanking(raw, docs) {
208
222
  return { ids, dropped };
209
223
  }
210
224
 
211
- function normalizeExpansions(raw, query) {
225
+ /** 改写结果的宽松解析(同样导出给尺子复用:解析口径不一致,数字就不可比)。 */
226
+ export function normalizeExpansions(raw, query) {
212
227
  const rows = Array.isArray(raw) ? raw : [];
213
228
  const out = [];
214
229
  const seen = new Set([String(query).trim().toLowerCase()]);
@@ -293,22 +308,32 @@ export async function semanticSearch(options = {}) {
293
308
  if (!expansions.length) {
294
309
  return fail(lexical, started, 'provider-empty', `提供方 ${provider.name} 没给出任何可用的改写,已降级`, { provider: provider.name });
295
310
  }
296
- const lists = [lexical.map((hit) => hit.id)];
311
+ // 词面顺序原样保留:改写只按自己的名次往后补,绝不插队。理由见文件头(实测:融合会以多欺少)。
312
+ const order = lexical.map((hit) => hit.id);
313
+ const seen = new Set(order);
297
314
  const byId = new Map(lexical.map((hit) => [hit.id, hit]));
315
+ const extra = [];
298
316
  for (const text of expansions) {
299
- const hits = runSearch(options.index, text, limit);
300
- for (const hit of hits) if (!byId.has(hit.id)) byId.set(hit.id, hit);
301
- lists.push(hits.map((hit) => hit.id));
317
+ for (const hit of runSearch(options.index, text, limit)) {
318
+ if (seen.has(hit.id)) continue;
319
+ seen.add(hit.id);
320
+ if (!byId.has(hit.id)) byId.set(hit.id, hit);
321
+ extra.push(hit.id);
322
+ }
302
323
  }
303
- const order = mergeRRF(lists, limit);
304
- const hits = order.map((id) => byId.get(id)).filter(Boolean);
324
+ const room = Math.max(0, limit - order.length);
325
+ const filled = extra.slice(0, room);
326
+ const hits = [...order, ...filled].map((id) => byId.get(id)).filter(Boolean);
305
327
  return {
306
328
  hits,
307
329
  used: true,
308
330
  code: 'expanded',
309
- message: `已用 ${expansions.length} 条改写融合(RRF)`,
331
+ message: filled.length
332
+ ? `已用 ${expansions.length} 条改写补位(新增 ${filled.length} 条,词面顺序未动)`
333
+ : `已用 ${expansions.length} 条改写,但没有可补的空位(词面结果已占满 ${limit} 条,顺序未动)`,
310
334
  provider: provider.name,
311
335
  expansions,
336
+ added: filled.length,
312
337
  ms: Date.now() - started,
313
338
  };
314
339
  } catch (error) {