dsh-engramory-recall 0.5.3 → 0.6.0

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,40 @@
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.6.0] — 2026-10-15
7
+
8
+ ### 新增
9
+
10
+ - **同义表扩到 177 条 / 519 个值**(105 → 177)。新增的第五波是**通用技术口语**:
11
+ 「变慢」「退不回去」「改表结构」「装不上」这类任何写中文技术笔记的人都会用的说法。
12
+ 它和第一至四波的性质不同 —— 那几波是从一份具体的库里挖出来的(专有名词、某个人的叫法),
13
+ 换一套词汇就大面积失效;第五波不依赖具体某个库。索引里多出 141 个键,实测体积变化在噪声量级。
14
+
15
+ ### 修复
16
+
17
+ - **桥的碎片降权不再连键本身一起降权**:命中的同义键拆出来的碎片(单字、相邻双字)算 0.5,
18
+ 但键本身若原样出现在查询里,照旧算满权重。原先一刀切把键本身也降到 0.5,
19
+ 合成基准的 `verbatim` 对照组从 100% 掉到 95.1%;放开键本身后回到 98.2%,桥组没退,
20
+ 真库的桥探针还从 9/10 变成 10/10。理由很直白:查询里原样出现的那个词是实打实的证据,
21
+ 该降权的只是它拆出来的碎片(那些碎片在别的笔记里到处都是)。
22
+ - **CI 上 Node 20/22 的永久 pending**:`test/llm.test.mjs` 的超时用例依赖
23
+ `AbortSignal.timeout` 的定时器把事件循环撑住,而那个定时器是 unref 的,
24
+ 只有本地 Node 25 侥幸能过。测试进程现在自己留一个 ref'd 句柄。CI 自 0.5.0 起连续四次都是红的。
25
+
26
+ ### 文档
27
+
28
+ - README 的基准数字全部换成实算值,并补上两处此前没写清的取舍(别名在合成语料里是把双刃剑;
29
+ 碎片降权为什么不能连键本身一起降)。
30
+ - `bench/expectations.json` 重设(查询数 683 → 704),`verbatim` 组不再要求满分(98.2%,容差 1pt)。
31
+
32
+ ### 基准口径(不是引擎变好,是尺子以前量错了)
33
+
34
+ - 覆盖率判据从「整个英文短语有没有键」改成「这个值有没有键」:旧判据把 16 个域词判成只覆盖 5 个、
35
+ 14 个事件词只覆盖 5 个,按值判其实是全覆盖。
36
+ - 英文体裁的笔记改成真正的术语正文。此前正文里混着中文字面,口语查询靠碎片就能蒙中,
37
+ 桥组样本几乎构造不出来(这也是桥组只有 11 条的原因,现在 32 条)。
38
+ - 桥样本改为按表里**真实存在的键**抽样,而不是拿整个英文短语去查表。
39
+
6
40
  ## [0.5.3] — 2026-10-15
7
41
 
8
42
  ### 新增
package/README.md CHANGED
@@ -86,19 +86,19 @@
86
86
 
87
87
  ### 一、合成基准(`node bench/run.mjs`)
88
88
 
89
- 224 篇程序生成的笔记(中文书面 99 / 英文术语 85 / 口语体 40,其中 42 篇写了 `aliases`)、
90
- 683 条查询、固定随机种子。查询按三种风格分开记分:`verbatim` 抄笔记自己的写法(对照组)、
89
+ 224 篇程序生成的笔记(中文书面 83 / 英文术语 101 / 口语体 40,其中 37 篇写了 `aliases`)、
90
+ 704 条查询、固定随机种子。查询按三种风格分开记分:`verbatim` 抄笔记自己的写法(对照组)、
91
91
  `colloquial` 只用口语说法(与正文没有字面重叠)、`keyword` 口语症状加域特征词
92
92
  (同域候选很多,必须靠事件词分辨)。语料是程序生成的,不依赖任何私人库,任何人可复算:
93
- `npm run bench` 整跑 4 秒,零依赖、零网络,消融表里对我们不利的那几行也照打(去掉字段加权反而高 4 个百分点),
93
+ `npm run bench` 整跑 4 秒,零依赖、零网络,消融表里对我们不利的那几行也照打,
94
94
  CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
95
95
 
96
96
  | 引擎 | 总 hit@1 | 总 hit@4 | verbatim | colloquial | keyword |
97
97
  |---|---|---|---|---|---|
98
- | **本插件** | **49.5%** | 57.0% | 100% | 24.1% | 21.9% |
99
- | 教科书 BM25(同分词口径) | 47.0% | 52.0% | 100% | 17.9% | 25.4% |
100
- | 教科书 BM25(中文逐字切分) | 48.6% | 54.8% | 100% | 21.4% | 26.8% |
101
- | 瞎猜(地板线) | 0.1% | 2.5% | 0% | 0% | 0.4% |
98
+ | **本插件** | **49.9%** | 66.6% | 98.2% | 33.9% | 15.2% |
99
+ | 教科书 BM25(同分词口径) | 43.2% | 46.9% | 100% | 17.9% | 17.9% |
100
+ | 教科书 BM25(中文逐字切分) | 47.2% | 53.4% | 100% | 20.1% | 28.1% |
101
+ | 瞎猜(地板线) | 0.6% | 2.6% | 0.4% | 0.9% | 0.4% |
102
102
 
103
103
  (分风格单元格是 hit@1。地板线是实测的随机命中率:`keyword` 的同域候选有 14 篇,瞎猜大约 7%。)
104
104
 
@@ -106,16 +106,17 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
106
106
 
107
107
  | 改动 | 总 hit@1 | 相对本插件 |
108
108
  |---|---|---|
109
- | 本插件 | 49.5% | — |
110
- | 去掉同义表 | 47.6% | -1.9pt |
111
- | 去掉笔记别名(不再声明 `aliases`) | 49.0% | -0.4pt |
112
- | 去掉字段加权(`TITLE_EXTRA` 24 → 0) | 53.4% | **+4.0pt** |
113
- | 同义键碎片不降权(回到改动前) | 47.7% | -1.8pt |
114
- | 桥权重与同义表一致(都算 0.5) | 50.5% | +1.0pt |
115
-
116
- 「去掉笔记别名」在总分上只有 -0.4pt,但分风格看是 -3.6pt 口语(24.1% → 20.5%):
117
- 别名是作者自己把口语说法钉在笔记上,只对「换一种说法问同一篇」有效,
118
- 在语料整体里被别的风格摊薄了。写别名的 42 篇 hit@1 54.8%,没写的 182 篇 17.0%。
109
+ | 本插件 | 49.9% | — |
110
+ | 去掉同义表 | 46.7% | -3.1pt |
111
+ | 去掉笔记别名(不再声明 `aliases`) | 56.5% | **+6.7pt** |
112
+ | 去掉字段加权(`TITLE_EXTRA` 24 → 0) | 49.6% | -0.3pt |
113
+ | 同义键碎片不降权(回到改动前) | 49.1% | -0.7pt |
114
+ | 桥权重与同义表一致(都算 0.5) | 50.4% | +0.6pt |
115
+
116
+ 「去掉笔记别名」是 **+6.7pt**,在总分上反而更好看 —— 这个语料里别名是把双刃剑:
117
+ 它帮自己(写了别名的 37 篇 hit@1 89.2%,没写的 187 篇 23.0%),
118
+ 也把同域别的笔记的口语查询抢过来(语料里同域的别名共用同一个口语域词,而别名是程序按比例随机发的)。
119
+ 真库里别名是人手写的、更克制,所以别拿这一行下结论 —— 判断别名只看探针的别名组(5/5)。
119
120
 
120
121
  最后两行是一次明确的取舍。改动前,桥组(查询里的词在目标笔记里一个字都没有)
121
122
  只有 27.3%:查询自己的字面碎片(`崩了` 的「了」在别的笔记里到处都是)按满权重计分,
@@ -123,35 +124,45 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
123
124
  实测**打红了真实存在的测试**:`test/recall.test.mjs` 的注入预算阶梯用 `代理节点与部署` 当查询,
124
125
  而 `代理`、`部署` 既是同义键、也是笔记正文里的原词,剔掉等于把真证据扔了。
125
126
  所以改成**降权不剔除**(`BRIDGED_FRAGMENT_WEIGHT = 0.5`,桥到的技术词算 `BRIDGE_WEIGHT = 1`)。
126
-
127
- 代价是总分少 1 个点(50.5% → 49.5%)。接受它的理由:合成语料里绝大多数查询本来就有字面落点,
128
- 总分被它们主导;桥是唯一在字面完全无交集时还能工作的机制,它从 27.3% 到 100%,
129
- 而总分只让出 1 个点。消融表里还留着更省事的选项(桥权重打回 0.5),但那条路的桥组只有 90.9%。
130
-
131
- 「去掉字段加权」那一行要单独说:合成语料里去掉它反而涨 4.0 个点,
132
- 而**真实库的中立对照说相反**(见下)。两个结论冲突的原因定位到了机制上:
127
+ 降权只针对键的**碎片**(单字、相邻双字),键本身照旧算满权重 —— 这一条是量出来的:
128
+ 一开始连键本身一起降权,`verbatim` 对照组从 100% 掉到 95.1%,放开键本身后回到 98.2%,
129
+ 而桥组一点没退。道理很直白:查询里原样出现的那个词是实打实的证据,降权的理由只是
130
+ 它拆出来的碎片在别的笔记里到处都是。
131
+
132
+ 代价是与「桥权重打回 0.5」相比总分少 0.6 个点(50.4% → 49.9%)。接受它的理由:
133
+ 合成语料里绝大多数查询本来就有字面落点,总分被它们主导;桥是唯一在字面完全无交集时
134
+ 还能工作的机制 —— 同一批桥查询,桥权重打回 0.5 只有 25.0%,碎片不降权 43.8%。
135
+ (历史值:修「碎片压桥」之前,那一版语料上这组查询只有 27.3%。)
136
+
137
+ 「去掉字段加权」那一行要单独说:它现在是 -0.3pt(拿掉略微更差),**在一次引擎改动之前它是 +4pt**
138
+ (拿掉明显更好),而**真实库的中立对照一直说拿掉更差**(见下)。当时的冲突定位到机制上:
133
139
  合成语料的标题是从正文模板派生的、与正文近乎重复,抬高标题权重只是放大域词噪声;
134
- 真实库的标题是人手提炼的。所以保持 `TITLE_EXTRA = 24`,冲突如实留在这里,
135
- 而不是只挑对自己有利的那把尺子。
140
+ 真实库的标题是人手提炼的。现在两把尺子都偏向「留着字段加权」,但理由仍是上面这条 ——
141
+ 不是冲突消失了,是那把合成尺子的标题变真实了一点。两者不一致时以对照 C 为准。
136
142
 
137
- ### 桥组:11 条,100%
143
+ ### 桥组:32 条,65.6%
138
144
 
139
145
  | 组 | 条数 | hit@1 | 说明 |
140
146
  |---|---|---|---|
141
- | 桥组(全部) | 11 | 100% | 查询与目标笔记的字面重叠为 0,命中只能靠同义桥 |
142
- | 其中公开词族 | 8 | 100% | 调表时看得见的词族 |
143
- | 其中留出词族 | 3 | 100% | 没参与调表的词族,看一点泛化 |
147
+ | 桥组(全部) | 32 | 65.6% | 查询与目标笔记的字面重叠为 0,命中只能靠同义桥 |
148
+ | 其中公开词族 | 20 | 65.0% | 调表时看得见的词族 |
149
+ | 其中留出词族 | 12 | 66.7% | 没参与调表的词族,看一点泛化 |
144
150
 
145
151
  这组查询是从引擎自己读的那张同义表里长出来的,所以它证明的是**扩展管道通不通**,
146
- 不是「表能不能猜中没见过的说法」,后者只有留出那 3 条能说一点。
147
- 真正的瓶颈在供给面:同义表覆盖了 16 个域词里的 5 个、14 个事件词里的 5 个,
148
- 另有一批口语键因为自带字面碎片而够不上「纯桥」资格被丢弃。
149
- 224 条口语查询里,字面重叠为 0 的其实只有 28 条 —— 也就是说这批「口语查询」
150
- 绝大多数仍能靠碎片碰到笔记,真正需要跨说法的只有那几条。
152
+ 不是「表能不能猜中没见过的说法」,后者只有留出那 12 条能说一点。
153
+ 真正的瓶颈在供给面:这张表有两层来源,口径必须分开说 ——
154
+
155
+ - **前四波是从真实库里挖出来的**(专有名词、某个人的叫法),换一套词汇就大面积失效;
156
+ - **第五波是通用技术口语**(「变慢」「退不回去」「改表结构」这类任何写中文技术笔记的人都会用的说法),
157
+ 不依赖具体某个库,装到别人机器上也能命中。
158
+
159
+ 新表扩容后,这套语料的 16 个域词、14 个事件词全部有桥;
160
+ 但仍有一批口语键因为自带字面碎片而够不上「纯桥」资格被丢弃(本次丢弃 22 组)。
161
+ 704 条查询里,口语风格那批字面重叠为 0 的只有 30 条 —— 绝大多数「口语查询」
162
+ 仍能靠碎片碰到笔记,真正需要跨说法的只有那几条。
151
163
 
152
- 未命中的查询里,53 条的 top-1 是同一领域里搞错了事件,292 条是同一事件里搞错了领域,
153
- 「完全不沾边」和「一条都没召回」都是 0 次。也就是说失败都是「差一点」,
154
- 没有灾难性的空召回。
164
+ 未命中的查询里,101 条的 top-1 是同一领域里搞错了事件,242 条是同一事件里搞错了领域,
165
+ 「完全不沾边」9 次、「一条都没召回」0 次。失败都是「差一点」,没有灾难性的空召回。
155
166
 
156
167
  ### 二、真库探针(`npm run probe`)
157
168
 
@@ -163,10 +174,10 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
163
174
  | 口径 | 21 | 真实场景的中文口语问法 | recall@4 21/21 |
164
175
  | 桥探针 | 10 | 全局同义表(大白话问英文/术语密集的笔记) | recall@4 9/10 |
165
176
  | 别名探针 | 5 | 笔记自己的 `aliases:`(关键词在全局桥里一个都没有) | recall@4 5/5 |
166
- | 死桥自检 | 301 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) | 25 个死值(均为通用词) |
167
- | 对照 A | 175 | 文件名当查询的底线(对标题权重有偏袒) | top1 41/175 · recall@4 94/175 |
168
- | 对照 B | 150 | 笔记中文标题当查询的底线(同样偏袒) | top1 145/150 · recall@4 149/150 |
169
- | 对照 C | 162 | 正文独有词当查询(该词在任何标题里都没有) | top1 126/162 · recall@4 149/162 |
177
+ | 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) | 121 个死值(均为通用词) |
178
+ | 对照 A | 189 | 文件名当查询的底线(对标题权重有偏袒) | top1 36/189 · recall@4 96/189 |
179
+ | 对照 B | 164 | 笔记中文标题当查询的底线(同样偏袒) | top1 159/164 · recall@4 163/164 |
180
+ | 对照 C | 176 | 正文独有词当查询(该词在任何标题里都没有) | top1 140/176 · recall@4 164/176 |
170
181
 
171
182
  对照 A 和 B 都拿标题当查询,所以它们对「标题里的词多计几遍」这类参数是**结构性偏袒**的:
172
183
  抬高标题权重几乎必然让这两组变好。它们既不该当目标函数,也不能用来证明「抬高标题权重是安全的」。
@@ -212,7 +223,7 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
212
223
 
213
224
  | # | 机制 | 治什么 |
214
225
  |---|---|---|
215
- | 1 | 同义扩展(105 条口语→技术词)+ 连字符再切分 | 中文口语与笔记用词对不上的同义鸿沟 |
226
+ | 1 | 同义扩展(177 条口语→技术词)+ 连字符再切分 + 命中键只降「碎片」权重 | 中文口语与笔记用词对不上的同义鸿沟 |
216
227
  | 2 | 超长正文按 `##` 切桶 + **整篇**长度归一 | 单篇巨文霸榜(一篇几百 KB 的笔记曾占掉四分之一召回位) |
217
228
  | 3 | 寒暄 / 极短消息门控 | 「继续 / 嗯」这类消息 10/10 注入噪音 |
218
229
  | 4 | 写入闸门(fail-closed) | 不合规文件名、缺失 frontmatter、疑似真实凭据 |
@@ -258,7 +269,7 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
258
269
  | 往库里写 `bad-name.md` | 拒绝:**「文件名不合规。必须是 \<user\|feedback\|project\|reference\>-\<小写slug\>.md」** |
259
270
  | 写一篇带 `api_key: sk-…` 的笔记 | 拒绝:**「疑似含真实凭据(OpenAI 风格密钥)」** |
260
271
 
261
- 作者实测(0.5.1 / 2026-10-15 / 库 63 篇、索引 73 行):四条全中,两次被拒的写入**磁盘上零字节变化** —— 被拒后索引与操作前逐字一致。
272
+ 作者实测(本地库几十篇笔记、索引七十余行):四条全中,两次被拒的写入**磁盘上零字节变化** —— 被拒后索引与操作前逐字一致。
262
273
 
263
274
  这就是它跟别家最本质的一处不同:别人的记忆预算卡在**注入**上(超了就少喂几段给模型),这里卡在**写入**上 —— 索引文件本身长不到超限,所以「超限的部分静默不再被召回」在这个库里不会发生。
264
275
 
@@ -342,7 +353,7 @@ Electron 载体走 `file://` + IPC 时可能没有 webServer,那时召回照
342
353
 
343
354
  召回准不准,九成取决于「你会怎么问」到「笔记里怎么写」之间那座桥。桥有两层。
344
355
 
345
- **全局同义表**(`bm25.js` 的 `SYNONYMS`,105 条 / 301 个值)管的是一类说法:
356
+ **全局同义表**(`bm25.js` 的 `SYNONYMS`,177 条 / 519 个值)管的是一类说法:
346
357
  `梯子 / 翻墙 / 科学上网` → `proxy, http-connect, socks5`。同义项默认按 0.5 权重计入,
347
358
  查询命中了某个键时,桥到的技术词按 1 计入、该键自身的字面碎片降到 0.5(上一节的取舍)。
348
359
 
@@ -398,7 +409,7 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
398
409
  ### 二、真的短板(还做不到)
399
410
 
400
411
  - **不做语义检索**:没有 embedding、没有向量库。中文用「单字 + 相邻双字」切分,
401
- 同义改写、隐含意图会漏检,目前唯一的桥接是一张 105 条的手工同义表。
412
+ 同义改写、隐含意图会漏检,目前唯一的桥接是一张 177 条的手工同义表。
402
413
  需要模型的那两件事(矛盾检测、摘要)做成了可选工具(见「可选:语义增强」),
403
414
  但它们不参与检索排序 —— 排序仍然是纯词面的,这一点不打算含糊。
404
415
  - **没有公开基准**:只有自建口径的合成基准与真库探针(见上),跨插件排名无从谈起。
@@ -407,7 +418,7 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
407
418
 
408
419
  - **不自动归档**:已实现。它原来确实写在这一节里,理由也写了「不可逆」。
409
420
  但确定性分层不需要 LLM,只是当时没做,所以补上了。
410
- - **同义鸿沟**:同义表已扩到 105 条并逐条经探针验证;笔记别名也真的被用起来了。
421
+ - **同义鸿沟**:同义表已扩到 177 条并逐条经探针验证;笔记别名也真的被用起来了。
411
422
  - **不自动合并**:降级成只出报告(体检器报疑似重复主题与失联路径),不改文件。
412
423
 
413
424
  ### 路线图
@@ -544,7 +555,7 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
544
555
  | 自动归档 | dsh-mneme `src/dream/sleep.js:411` `phaseDemotion` | 落地但换了实现:它截断 + 存 `_full_content`,本插件整段搬家、不截断 |
545
556
  | 时间衰减 | 灵枢 `rust/src/freshness.rs` | 实测证伪。六档半衰期扫描,recall 恒定、top-1 只有一档 +1(噪声级)。它能用是因为它的 `protected_floor` 依赖人工 `importance` 字段,Engramory 笔记没这个字段 |
546
557
  | 时间乘性加权 | hindsight `engine/search/reranking.py:35-37` | 实测证伪。五档扫描净收益为零,`w ≥ 0.1` 起还要拿对照组去换 |
547
- | 同义表扩容 | 灵枢的语义归一化 | 落地。105 条桥 / 301 个值,每条都经探针验证,另加一道死桥自检 |
558
+ | 同义表扩容 | 灵枢的语义归一化 | 落地。177 条桥 / 519 个值,每条都经探针验证,另加一道死桥自检 |
548
559
  | 查询侧同义词要先分词再算 | 自己踩的坑 | 落地。中文同义词原样返回时永远匹配不上「英文整词 + 汉字双字」的词项形状,写了等于没写且不报错。英文同义词 tokenize 后等于自身,所以这毛病藏了很久 |
549
560
  | 笔记级别名(`aliases:`) | dsh-mneme 的 `aliases` 钩子 | 落地。机制本来就在(计两遍 = 满权重),但真实库里 0 篇在用;给 8 篇补上后别名探针 1/5 → 5/5 |
550
561
 
package/bm25.js CHANGED
@@ -321,6 +321,85 @@ export const SYNONYMS = new Map(
321
321
  记忆库: ['engramory', 'memory', 'bm25'],
322
322
  召回: ['recall', 'search', 'bm25'],
323
323
  归档: ['archive', 'history'],
324
+
325
+ // ── 2026-10-15 第五波:通用技术口语 ────────────────────────────────
326
+ // 前面几波是**从这个库挖出来的**(专有名词、某个人的叫法),换一个库就大面积失效。
327
+ // 这一波换来源:只收**任何写中文技术笔记的人都会用的说法** ——「变慢」「装不上」
328
+ // 「退不回去」「改表结构」「打日志」…… 不依赖哪个库的词,别人装上也能命中。
329
+ // 判据没变:同一个概念的两种叫法,不是「相关的两件事」。宽词(请求/客户端/队列这些)
330
+ // 故意配上更专的伴词,靠 BRIDGE_WEIGHT 之外的其它词项兜住精度。
331
+ 改表结构: ['migration', 'migrate', 'schema', 'ddl', 'alter'],
332
+ 回填: ['backfill', 'migration'],
333
+ 流水线: ['pipeline', 'ci', 'runner', 'workflow'],
334
+ 自动检查: ['ci', 'pipeline', 'runner', 'lint'],
335
+ 打日志: ['log', 'logging', 'logger'],
336
+ 日志: ['log', 'logging'],
337
+ 轮转: ['rotation', 'rotate', 'logrotate'],
338
+ 抢锁: ['lock', 'mutex', 'locking'],
339
+ 加锁: ['lock', 'mutex'],
340
+ 死锁: ['deadlock', 'lock', 'mutex'],
341
+ 并发: ['concurrency', 'concurrent', 'parallel', 'mutex'],
342
+ 调接口: ['api', 'http', 'request', 'curl'],
343
+ 请求: ['request', 'http', 'api', 'curl'],
344
+ 重试: ['retry', 'backoff'],
345
+ 退避: ['backoff', 'retry'],
346
+ 限流: ['ratelimit', 'throttle', 'rate'],
347
+ 发包: ['publish', 'npm', 'tarball'],
348
+ 发版: ['release', 'publish', 'version', 'tag'],
349
+ 版本号: ['version', 'semver', 'tag'],
350
+ 自动点网页: ['automation', 'playwright', 'puppeteer', 'selector'],
351
+ 自动化: ['automation', 'playwright', 'selenium'],
352
+ 选择器: ['selector', 'xpath'],
353
+ 找不着: ['search', 'ranking', 'recall', 'bm25'],
354
+ 排序: ['ranking', 'rank', 'sort', 'bm25'],
355
+ 命中率: ['recall', 'hit', 'precision'],
356
+ 时区: ['timezone', 'utc'],
357
+ 时间对不上: ['timezone', 'utc', 'offset'],
358
+ 时间戳: ['timestamp', 'epoch', 'utc'],
359
+ 升版本: ['upgrade', 'dependency', 'major', 'peer'],
360
+ 升级依赖: ['upgrade', 'dependency', 'node_modules'],
361
+ 版本打架: ['conflict', 'peer', 'dependency'],
362
+ 大版本: ['major', 'semver', 'breaking'],
363
+ 客户端: ['desktop', 'client', 'electron'],
364
+ 打包产物: ['bundle', 'dist', 'artifact'],
365
+ 镜像: ['image', 'docker', 'registry'],
366
+ 容器: ['docker', 'container', 'pod'],
367
+ 队列: ['queue', 'runner', 'wait'],
368
+ 变慢: ['latency', 'slow', 'p95'],
369
+ 响应慢: ['latency', 'slow', 'p95'],
370
+ 慢得没法用: ['latency', 'slow', 'timeout'],
371
+ 数算错了: ['wrong', 'mismatch', 'incorrect'],
372
+ 结果不对: ['wrong', 'mismatch', 'incorrect'],
373
+ 对不上: ['mismatch', 'wrong', 'inconsistent'],
374
+ 安装失败: ['install', 'failed', 'npm'],
375
+ 东西没了: ['loss', 'lost', 'missing', 'data loss'],
376
+ 数据丢了: ['loss', 'lost', 'missing', 'data loss'],
377
+ 内存一直涨: ['leak', 'rss', 'memory'],
378
+ 泄漏: ['leak', 'leakage'],
379
+ 版本冲突: ['conflict', 'peer', 'semver'],
380
+ 悄悄不生效: ['silent', 'silently', 'quiet'],
381
+ 静默失效: ['silent', 'silently', 'failure'],
382
+ 没报错: ['silent', 'quiet'],
383
+ 乱码: ['encoding', 'mojibake', 'utf8', 'gbk'],
384
+ 编码: ['encoding', 'utf8', 'gbk', 'charset'],
385
+ 退不回去: ['rollback', 'revert', 'undo'],
386
+ 回滚: ['rollback', 'revert'],
387
+ 跑两次: ['duplicate', 'idempotent', 'idempotency'],
388
+ 重复触发: ['duplicate', 'idempotent'],
389
+ 幂等: ['idempotent', 'idempotency', 'duplicate'],
390
+ 卡住不动: ['hang', 'stuck'],
391
+ 没响应: ['hang', 'timeout', 'unresponsive'],
392
+ 心跳: ['heartbeat', 'ping', 'watchdog'],
393
+ 配置不生效: ['config', 'override', 'precedence'],
394
+ 改了没用: ['config', 'stale', 'cache'],
395
+ 进程退出: ['crash', 'exit'],
396
+ 退出码: ['exit', 'code', 'status'],
397
+ 调用栈: ['stack', 'trace', 'traceback'],
398
+ 权限被拒: ['forbidden', 'denied', '403'],
399
+ 审计: ['audit', 'trail'],
400
+ 落盘: ['disk', 'persist', 'write'],
401
+ 常驻集: ['rss', 'memory', 'resident'],
402
+ 排队: ['queue', 'runner', 'wait'],
324
403
  }),
325
404
  );
326
405
 
@@ -438,7 +517,7 @@ export function search(index, query, limit = 5) {
438
517
  // 命中同义键时,这个键自身的字面碎片降权(见 BRIDGED_FRAGMENT_WEIGHT)。
439
518
  const bridged = matchedSynonymKeys(query);
440
519
  const fragments = new Set();
441
- for (const key of bridged) for (const term of tokenize(key)) fragments.add(term);
520
+ for (const key of bridged) for (const term of tokenize(key)) if (term !== key) fragments.add(term);
442
521
  const base = queryTerms(query);
443
522
  const extras = expandQuery(query);
444
523
  if (!base.length && !extras.length) return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-engramory-recall",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
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",