dsh-engramory-recall 0.8.1 → 0.10.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 +78 -0
- package/README.md +177 -23
- package/bm25.js +31 -6
- package/index.js +36 -1
- package/mcp.js +37 -8
- package/package.json +9 -4
- package/semantic.js +322 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,84 @@
|
|
|
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.10.0] — 2026-10-15
|
|
7
|
+
|
|
8
|
+
### 改进
|
|
9
|
+
|
|
10
|
+
- **查询侧再丢掉一类「求助/建议框架词」**(`bm25.js` 的 `EN_REQUEST_STOPWORDS`,14 个:
|
|
11
|
+
`tips / suggest / suggestions / recommend / recommendations / advice / think / thinking /
|
|
12
|
+
idea / ideas / helpful / good / upcoming / weekend`)。这些词是「同一类对话」的框架,不是话题:
|
|
13
|
+
一次求助会话里几乎必有它们,于是任何同流派的会话都会因为共现框架词被抬分 —— 那是 genre 在
|
|
14
|
+
匹配,不是 topic 在匹配;最吃亏的是「先说出偏好、再问建议」的查询,它的实词被框架词稀释成
|
|
15
|
+
「又一场给建议的对话」。
|
|
16
|
+
- **实测(LongMemEval-S 500 题)**:RecallAny@1 **88.4% → 89.2%**、MRR@5 **0.9202 → 0.9251**、
|
|
17
|
+
RecallAny@10 98.8% → 99.0%;最弱的偏好分片 @1 **46.7% → 60.0%**(MRR 0.627 → 0.708),
|
|
18
|
+
其余五个分片逐位不变。
|
|
19
|
+
- **词表只收「在失败样本里真出现过」的写法**,单复数不自动补齐 —— 补了就是没量过的猜测。
|
|
20
|
+
- 逐个消融:没有哪个词单独扛收益(去掉任意一个最多掉 1 题);只留子集与大表同分
|
|
21
|
+
⇒ 收益属于「一类词」,不是一个咒语。
|
|
22
|
+
- 对照实验:换成「按 df 比例自动丢高频查询词」的通用规则反而全线下跌(df<0.35 → @1 87.6%、
|
|
23
|
+
df<0.20 → 85.4%)⇒ 收益来自这一类词的**语义**,不是频次本身。
|
|
24
|
+
- 三道保险保证查询不被清空:单关键词查询不筛;全是虚词的查询退回原样;去掉框架词后为空
|
|
25
|
+
(`any tips` 这种)退回只过虚词的结果。
|
|
26
|
+
- 真库探针与合成基准:同一语料上新旧代码六项数字逐位相同(探针语料本身在此期间被别的会话
|
|
27
|
+
改过,指纹已重设基线);`npm run bench:check` 全部指标在容差内。
|
|
28
|
+
- README 的「八条被实测证伪的路」→「**九条**」,新增一条 **「拿日期当检索信号」**:
|
|
29
|
+
LongMemEval-S 500 题只有 39 题能抽出可用时间窗;硬过滤 11 题救回 / 11 题误伤;
|
|
30
|
+
948 个 gold 的新度百分位平均 0.459 ⇒ 日期侧没有先验;纯按日期排的引擎 @1 仅 3.8%。
|
|
31
|
+
机制写在表里:问题引用的是事件时间,不是 session 时间戳。
|
|
32
|
+
- README 的 LongMemEval 表、分题型表、B 对照表与「三句实话」按新数字更新(2.2pt → 3.0pt)。
|
|
33
|
+
|
|
34
|
+
### 测试
|
|
35
|
+
|
|
36
|
+
- `test/recall.test.mjs` 增加 2 个用例(框架词被丢而实词不丢;全是框架词时退回而不清空),
|
|
37
|
+
合计 213 例。
|
|
38
|
+
|
|
39
|
+
## [0.9.0] — 2026-10-15
|
|
40
|
+
|
|
41
|
+
### 新增
|
|
42
|
+
|
|
43
|
+
- **可选的语义检索接缝(`semantic.js`,默认关闭)**:`semanticProvider` 指一个本地 ESM 模块
|
|
44
|
+
(导出 `rerank({query, docs, limit, signal})` 或 `expand({query, signal})`),或者字面量
|
|
45
|
+
`host-llm`(用宿主模型做查询改写,走 `llm.js` 的动态 import)。四条边界是设计出来的:
|
|
46
|
+
- 结果是 **RRF 融合**:词面命中的文档一条不丢,提供方只能改顺序;它返回候选之外的文件名
|
|
47
|
+
一律丢弃并报数(`dropped`);
|
|
48
|
+
- 超时**硬兜一层 `Promise.race`**:提供方忽略 `AbortSignal` 也收得住;
|
|
49
|
+
- 失败不抛错,降级回词面结果并如实报 `code`(`off` / `not-found` / `unsupported` /
|
|
50
|
+
`provider-error` / `timeout` / `provider-empty`),检索工具返回值里多了个 `semantic` 字段;
|
|
51
|
+
- **只管按需检索工具**,每轮自动注入那条热路径永远纯词面(挂外部件等于给每个用户轮加税)。
|
|
52
|
+
- README 新增「可选:语义检索接缝」小节(含提供方接口示例与上面这张边界表);「短板」两处
|
|
53
|
+
关于「无语义检索」的说法改成准确表述:开箱仍是纯词面,接缝不替你选模型、不替你出网。
|
|
54
|
+
|
|
55
|
+
### 测试
|
|
56
|
+
|
|
57
|
+
- `test/semantic.test.mjs` 11 例(自建假提供方模块):抛错 / 卡住 / 返回垃圾 / 读不到模块 /
|
|
58
|
+
编造文件名 / 空结果都降级且报原因;`expand` 带进来的新文档进结果、词面第一名不丢;
|
|
59
|
+
默认关闭时用**副作用文件**证明连 import 都没发生;`module` 形式不静态 import 宿主包与 `llm.js`。
|
|
60
|
+
- `test/semantic-wiring.test.mjs` 5 例:关着时不打标不 import、开着时真的融合、超时仍返回词面结果、
|
|
61
|
+
**每轮自动注入不碰接缝**、`index.js` 静态依赖里仍然没有 `llm.js`。
|
|
62
|
+
- `test/semantic-corpus.test.mjs` 2 例:在**真语料**(合成基准 704 条查询抽 117 条)上钉住结构性承诺 ——
|
|
63
|
+
抛错 / 卡住 / 返回垃圾 / 编造文件名四种坏提供方,命中与词面路径**逐字相同**;
|
|
64
|
+
真打分的提供方只能重排(同一 limit 下输出是同一批文档),不能凭空增删。
|
|
65
|
+
- `npm run bench:seam`(`bench/semantic-seam.mjs`,可复算、零模型调用):关闭 1.3 ms / 抛错 1.4 ms /
|
|
66
|
+
卡住 53.0 ms(预算 50 ms,证明硬兜生效)/ 编造 id 1.4 ms —— 前四种命中的 hit@1 50.4%、hit@4 67.5%
|
|
67
|
+
与词面**完全一致**;离线字符三元组重排 47.4%/65.5%(**赔** 1.5~3.0pp);按 gold 重排的作弊上界
|
|
68
|
+
86.6%/86.6%,而 20 个候选里含 gold 的查询只占 610/704 = 86.6% —— 接缝的天花板由提供方质量决定,
|
|
69
|
+
「默认关闭」因此不是保守,是准确。
|
|
70
|
+
- 合计 211 例全绿;`bench:check` 与期望值一致(两把中文尺子的数字不受影响 —— 接缝默认关闭)。
|
|
71
|
+
|
|
72
|
+
### 工程
|
|
73
|
+
|
|
74
|
+
- 新增 `.github/workflows/release.yml`:推 `v*` tag 即发版,**不用任何长期 token**(npm 的
|
|
75
|
+
Trusted Publisher / GitHub OIDC + `--provenance`)。发版前跑与 CI 完全相同的三条门槛
|
|
76
|
+
(测试 / 基准数字不漂 / 包能打出来),tag 与 `package.json` 版本不一致直接报错,
|
|
77
|
+
同版本已发布则跳过。
|
|
78
|
+
- 新增 `npm run release:verify`:发布成功的判定**不看退出码**,而是直查版本端点
|
|
79
|
+
(packument 有分钟级缓存,会骗人),并把 registry 上的 `dist.shasum` / `dist.integrity`
|
|
80
|
+
与本机 `npm pack` 的逐字比对 —— 证明「发上去的就是工作区这一份」。
|
|
81
|
+
- 起因:2026-10-15 人肉发版被 npm 的口令机制卡住(bypass token 被限制、普通 token 要 OTP),
|
|
82
|
+
而 npm 的计划正是 **bypass 2FA 的 token 直接发布 2027-01 失效** —— 与其等它到期,不如现在改道。
|
|
83
|
+
|
|
6
84
|
## [0.8.1] — 2026-10-15
|
|
7
85
|
|
|
8
86
|
### 修复
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
| **三年后还能跑** | 不需要编译原生模块、不需要常驻服务 | 带 `better-sqlite3` 编译、rust 工具链、独立 daemon 的方案,会在某次系统升级后让你回来看这篇 README |
|
|
36
36
|
|
|
37
37
|
四问都过了,才轮到比功能。功能这一层,本插件的取舍是**把不确定性变成可测量的东西**:
|
|
38
|
-
|
|
38
|
+
新机制必须在库上跑出数字,跑不出来就不上(文末九条被证伪的路就是这么留下的)。
|
|
39
39
|
|
|
40
40
|
## 跟同类插件对照
|
|
41
41
|
|
|
@@ -77,9 +77,9 @@
|
|
|
77
77
|
- **公开基准只跑了一半**:LongMemEval-S 500 题跑出 RecallAny@5 98.2%(见下),但那只测英文词面
|
|
78
78
|
检索;`thoth-mem` 公布的 470 题版本(RecallAny@5 432/470、Recall@5 84.8%、NDCG@10 0.8165)
|
|
79
79
|
用的是别的 harness,两边数字不可相减。真正的「跨插件排名」仍然不存在。
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
- **自带路径上没有语义检索**:没有内置 embedding、没有向量库,开箱就是纯词面;同义改写要靠
|
|
81
|
+
手工同义表覆盖,没覆盖到的就是漏检。想接语义得自己指一个提供方(`semanticProvider`,见下),
|
|
82
|
+
插件本身不替你选模型、不替你出网。
|
|
83
83
|
- **只有 DSH 里是「全功能」**:0.8.0 起带一个零依赖的 MCP 服务器(见下),任何支持 MCP
|
|
84
84
|
的宿主都能检索与读笔记;但写入闸门、归档、体检、沉淀提醒、面板这些**只在家用版(DSH 插件)
|
|
85
85
|
里有** —— 别的宿主拿到的是只读视图。`claude-mem` 覆盖 7 个宿主、`thoth-mem` 覆盖 4 个,
|
|
@@ -152,6 +152,8 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
|
|
|
152
152
|
|---|---|---|
|
|
153
153
|
| 这份合成卷子 | hit@1 49.9% · hit@4 66.6% | hit@1 50.4% · hit@4 67.5% |
|
|
154
154
|
| LongMemEval-S 500 题 | @1 87.8% · @5 97.4% · 覆盖 91.5% | @1 88.4% · @5 98.2% · 覆盖 92.5% |
|
|
155
|
+
|
|
156
|
+
(这张表单独量 B。查询侧后来又加了一类框架词过滤,@1 从 88.4% 再涨到 **89.2%** —— 见 LongMemEval 一节的第 1 条实话。)
|
|
155
157
|
| 真库对照 C(中立那把) | top1 141/181 · recall@4 168/181 | top1 150/181 · recall@4 177/181 |
|
|
156
158
|
|
|
157
159
|
机制:同一个词在长会话、长笔记里出现得更多,`len/avgLen` 这个惩罚就变成「谁长谁吃亏」,
|
|
@@ -188,15 +190,18 @@ CI 里再跑一遍 `npm run bench:check` 跟 `bench/expectations.json` 对账。
|
|
|
188
190
|
跑在自己的记忆库上,分三组 query 各测一层机制。数字随库变化,每次跑都打印语料指纹,
|
|
189
191
|
指纹变了会直接警告「分数不可直接比较」。
|
|
190
192
|
|
|
193
|
+
最近一次:**66 篇 / 200 个 chunk**(指纹 `cbe8d93f9d67`)。这组数字的用处是「别掉分」,
|
|
194
|
+
不是跨库比较 —— 换一台机器、库里多写一篇笔记,绝对值都会动(下表的条数就是当时的语料规模)。
|
|
195
|
+
|
|
191
196
|
| 组 | 条数 | 测什么 | 最近一次 |
|
|
192
197
|
|---|---|---|---|
|
|
193
198
|
| 口径 | 21 | 真实场景的中文口语问法 | recall@4 21/21 |
|
|
194
199
|
| 桥探针 | 10 | 全局同义表(大白话问英文/术语密集的笔记) | recall@4 10/10 |
|
|
195
200
|
| 别名探针 | 5 | 笔记自己的 `aliases:`(关键词在全局桥里一个都没有) | recall@4 5/5 |
|
|
196
|
-
| 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) |
|
|
197
|
-
| 对照 A |
|
|
198
|
-
| 对照 B |
|
|
199
|
-
| 对照 C |
|
|
201
|
+
| 死桥自检 | 519 值 | 同义桥的每个值是否真的存在于语料(不存在等于白写) | 117 个死值(均为通用词) |
|
|
202
|
+
| 对照 A | 200 | 文件名当查询的底线(对标题权重有偏袒) | top1 47/200 · recall@4 100/200 |
|
|
203
|
+
| 对照 B | 175 | 笔记中文标题当查询的底线(同样偏袒) | top1 170/175 · recall@4 174/175 |
|
|
204
|
+
| 对照 C | 187 | 正文独有词当查询(该词在任何标题里都没有) | top1 155/187 · recall@4 182/187 |
|
|
200
205
|
|
|
201
206
|
对照 A 和 B 都拿标题当查询,所以它们对「标题里的词多计几遍」这类参数是**结构性偏袒**的:
|
|
202
207
|
抬高标题权重几乎必然让这两组变好。它们既不该当目标函数,也不能用来证明「抬高标题权重是安全的」。
|
|
@@ -237,26 +242,28 @@ haystack,题目分六类。
|
|
|
237
242
|
|
|
238
243
|
| 指标 | 全部 500 题 | 教科书 BM25(同分词口径) |
|
|
239
244
|
| --- | --- | --- |
|
|
240
|
-
| RecallAny@1 | **
|
|
245
|
+
| RecallAny@1 | **89.2%** | 86.2% |
|
|
241
246
|
| RecallAny@5 | **98.2%** | 97.0% |
|
|
242
247
|
| Recall@5(gold 覆盖率) | **92.5%** | 90.7% |
|
|
243
|
-
| MRR@5 | **0.
|
|
244
|
-
| RecallAny@10 | **
|
|
248
|
+
| MRR@5 | **0.9251** | 0.9056 |
|
|
249
|
+
| RecallAny@10 | **99.0%** | 98.2% |
|
|
245
250
|
|
|
246
251
|
| 题型 | 题数 | RecallAny@1 | RecallAny@5 | Recall@5 | MRR@5 |
|
|
247
252
|
| --- | --- | --- | --- | --- | --- |
|
|
248
253
|
| knowledge-update | 78 | 98.7% | 100.0% | 99.4% | 0.994 |
|
|
249
254
|
| multi-session | 133 | 88.0% | 98.5% | 86.0% | 0.920 |
|
|
250
255
|
| single-session-assistant | 56 | 100.0% | 100.0% | 100.0% | 1.000 |
|
|
251
|
-
| single-session-preference | 30 |
|
|
256
|
+
| single-session-preference | 30 | 60.0% | 90.0% | 90.0% | 0.708 |
|
|
252
257
|
| single-session-user | 70 | 92.9% | 100.0% | 100.0% | 0.950 |
|
|
253
258
|
| temporal-reasoning | 133 | 85.0% | 97.0% | 88.5% | 0.894 |
|
|
254
259
|
|
|
255
260
|
三句必须说在前面的实话:
|
|
256
261
|
|
|
257
|
-
1. **这
|
|
258
|
-
查询侧丢掉英文虚词(`what / is / the / of / did / how
|
|
259
|
-
|
|
262
|
+
1. **这 3.0 个点的来源要说清楚。** 本插件比教科书 BM25 高 3.0pt @1,差的那部分**全部**来自三处通用改动:
|
|
263
|
+
查询侧丢掉英文虚词(`what / is / the / of / did / how…`);查询侧再丢掉一类「求助/建议框架词」
|
|
264
|
+
(`tips / suggest / recommend / advice / think / good / weekend…` 共 14 个,这一处单独值
|
|
265
|
+
**+0.8pt @1**、MRR +0.005,最弱的偏好分片 @1 从 46.7% 抬到 60.0%);以及把长度归一常数从教科书的
|
|
266
|
+
0.75 调到 0.4。三处都与中文无关 —— 是我们自己的机制(同义表、别名、字段加权、中文分词)在这份数据上
|
|
260
267
|
没被激活:LongMemEval 是英文的,session 没有标题、没有别名、没有中文口语,它们在这里没事可做。
|
|
261
268
|
B 的取值不是在这份数据上单独挑的(挑法见上:三把尺子一致,真库中立对照 +9/+9)。所以这张表
|
|
262
269
|
证明的是「英文词面检索不输、且基础卫生做到位」,**不是**「本插件的独门设计更好」。机制值多少,
|
|
@@ -265,8 +272,10 @@ haystack,题目分六类。
|
|
|
265
272
|
我们用的是 cleaned 版 500 题 + 自己的 harness,题集版本、命中口径、读不读日期都未必一样,
|
|
266
273
|
相减没有意义。能担保的是:harness 与逐题结果都在仓库里(`bench/longmemeval.mjs`、
|
|
267
274
|
`bench/longmemeval-results.json`),可以逐题核。
|
|
268
|
-
3.
|
|
269
|
-
|
|
275
|
+
3. **最差的仍是偏好题**(60.0%@1,改动前 46.7%):「我喜欢什么」这类问题本身没什么词面抓手,
|
|
276
|
+
而且它的 16 个 miss 里有 11 个掉在第 2~4 名 —— 全是「另一场给建议的对话」,是同流派干扰而不是
|
|
277
|
+
够不着。丢掉求助框架词能把 @1 抬 13.3pt,剩下的差距才是零模型路线的边界:偏好类想做得更好,
|
|
278
|
+
要么开可选的 `llm.js`,要么就老老实实把偏好写进标题。
|
|
270
279
|
|
|
271
280
|
复算:
|
|
272
281
|
|
|
@@ -482,10 +491,10 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
|
|
|
482
491
|
|
|
483
492
|
### 二、真的短板(还做不到)
|
|
484
493
|
|
|
485
|
-
-
|
|
494
|
+
- **不替你上语义**:没有内置 embedding、没有向量库。中文用「单字 + 相邻双字」切分,
|
|
486
495
|
同义改写、隐含意图会漏检,目前唯一的桥接是一张 177 条的手工同义表。
|
|
487
|
-
|
|
488
|
-
|
|
496
|
+
0.9.0 起留了接缝(`semanticProvider`,见下):你可以挂本地模块或宿主模型来重排 / 改写,
|
|
497
|
+
但**默认那一条路径仍然是纯词面的**,我们不会偷偷替你发一个请求出去。
|
|
489
498
|
- **没有跨插件排名**:LongMemEval-S 跑得了(见上),但那是一个英文词面基准,且各家 harness 不同;
|
|
490
499
|
合成基准与真库探针都是自建口径,只能自己跟自己比。
|
|
491
500
|
|
|
@@ -507,7 +516,7 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
|
|
|
507
516
|
| 自动摘要 | 走宿主 `ctx.llm`,**默认关闭** | 已实现(`semanticSummary`) |
|
|
508
517
|
| 跨宿主分发 | 零依赖 stdio MCP 服务器,只读 | 已实现(`mcp.js`) |
|
|
509
518
|
| 跨会话覆盖 | 「结果要多样」的选桶护栏(MMR / 覆盖优先) | 量过了,**被证伪**(见「被实测证伪的路」),收益改在长度归一 `B` 上拿到 |
|
|
510
|
-
| 可选语义检索 | 可插拔 seam
|
|
519
|
+
| 可选语义检索 | 可插拔 seam(向量 / 本地模型 / 远端 API / 宿主模型都行),默认关闭 | 已实现(`semantic.js`) |
|
|
511
520
|
|
|
512
521
|
后两条默认关闭,是为了让「零模型调用」这条基线保持可复现。开启是用户的显式选择,
|
|
513
522
|
而不是装完就生效:一个会自己调模型改写你笔记的插件不该默认打开。走 `ctx.llm`
|
|
@@ -538,6 +547,75 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
|
|
|
538
547
|
不假装全看过。模型编造的文件名一律丢弃(只认本批真实文件名),
|
|
539
548
|
丢弃的条数也会报出来 —— 宁可显示「这次有 2 处输出不可信」,也不拿它冒充发现。
|
|
540
549
|
|
|
550
|
+
### 可选:语义检索接缝(`semantic.js`,默认关闭)
|
|
551
|
+
|
|
552
|
+
同义表和别名覆盖不到的说法,只有语义能救。但**默认路径不许因此变慢**,所以这里做的是一个
|
|
553
|
+
接缝(seam)而不是内置向量库:用户自己指一个提供方,指了才生效,指错了也不会把检索弄坏。
|
|
554
|
+
|
|
555
|
+
```yaml
|
|
556
|
+
semanticProvider: '' # 留空 = 纯词面(默认)。可填本地模块路径,或字面量 host-llm
|
|
557
|
+
semanticTimeoutMs: 2000 # 单次预算;超时/抛错/返回垃圾一律降级回词面结果
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
提供方就是一个本地 ESM 模块,导出两个函数里的任意一个:
|
|
561
|
+
|
|
562
|
+
```js
|
|
563
|
+
// 1) 重排:给候选,还顺序(可以用 embedding、可以用本地小模型、可以调远端 API,随你)
|
|
564
|
+
export async function rerank({ query, docs, limit, signal }) {
|
|
565
|
+
// docs: [{ id, snippet, matched, lexicalRank, lexicalScore }]
|
|
566
|
+
return [{ id: 'reference-publish-plugin-to-npm.md', score: 0.91 }];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 2) 扩展:把问题改写成几句别的说法,由插件分别检索再融合
|
|
570
|
+
export async function expand({ query, signal }) {
|
|
571
|
+
return ['npm 发布 token 403', '怎么把包发到 npm'];
|
|
572
|
+
}
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
`host-llm` 是内置的第二种:它用宿主已经配好的模型做**查询改写**(`expand`),走 `llm.js`
|
|
576
|
+
的动态 import —— 与上面两个开关同一条纪律,静态依赖图里没有它。
|
|
577
|
+
|
|
578
|
+
四件事是刻意设计的,不是随口承诺:
|
|
579
|
+
|
|
580
|
+
| 行为 | 为什么 |
|
|
581
|
+
|---|---|
|
|
582
|
+
| 结果是 **RRF 融合**,不是替换 | 同一个 limit 下输出就是同一批文档的重排(不会凭空多出、也不会丢;`test/semantic-corpus.test.mjs` 在真语料上钉这条)。Rerank 返回候选之外的文件名一律丢弃并报数 |
|
|
583
|
+
| 超时**硬兜一层** `Promise.race` | 提供方可以完全忽略 `AbortSignal`(第三方代码什么都干得出来),只靠 abort 会让检索永远等下去 |
|
|
584
|
+
| 失败**不抛错**,降级回词面并如实报 `code` | `off` / `not-found` / `unsupported` / `provider-error` / `timeout` / `provider-empty`,检索工具返回值里的 `semantic` 字段直接给出这次到底用没用上 |
|
|
585
|
+
| **只管按需检索工具**,不碰每轮自动注入 | 自动召回每个用户轮都跑;挂一个要 200ms 的外部件等于给每一轮加税。热路径永远纯词面 |
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
#### 实测:接缝的代价与天花板(`npm run bench:seam`)
|
|
589
|
+
|
|
590
|
+
`bench/semantic-seam.mjs` 用合成基准的 704 条查询跑同一批检索,只换提供方,全程离线、零模型调用
|
|
591
|
+
(这条命令你自己就能复算):
|
|
592
|
+
|
|
593
|
+
| 场景 | hit@1 | hit@4 | 平均耗时 | 返回码 |
|
|
594
|
+
| --- | --- | --- | --- | --- |
|
|
595
|
+
| 关闭(默认) | 50.4% | 67.5% | 1.3 ms | `off`×704 |
|
|
596
|
+
| 提供方抛错 | 50.4% | 67.5% | 1.4 ms | `provider-error`×704 |
|
|
597
|
+
| 提供方卡住不返回 | 50.4% | 67.5% | **53.0 ms**(预算 50ms) | `timeout`×704 |
|
|
598
|
+
| 提供方返回编造的 id | 50.4% | 67.5% | 1.4 ms | `provider-empty`×704 |
|
|
599
|
+
| 离线字符三元组重排(真打分) | 47.4% | 65.5% | 1.4 ms | `reranked`×704 |
|
|
600
|
+
|
|
601
|
+
(候选池 = 20,评测看前 4 名。`node bench/semantic-seam.mjs --pool 4` 是另一种用法:池子等于输出量时
|
|
602
|
+
RRF 只能在池内重排,所以 hit@4 一动都不动(67.5%),坏的提供方只赔 hit@1。两种都量过。)
|
|
603
|
+
| 按 gold 重排(**作弊上界**,只验管线) | 86.6% | 86.6% | 1.4 ms | `reranked`×610 |
|
|
604
|
+
|
|
605
|
+
三个数字值得单独说:
|
|
606
|
+
|
|
607
|
+
- **默认零成本**:关着的时候与词面路径是同一条同步路径,1.3 ms,没有任何 import。
|
|
608
|
+
- **失败不改变结果**:三种坏提供方下命中与词面**逐字相同** —— 降级是「结果不变 + 如实报码」,
|
|
609
|
+
不是「返回半个答案」;卡住的那种被硬兜在 53 ms(提供方完全可以无视 `AbortSignal`,所以兜的是 `Promise.race`)。
|
|
610
|
+
- **接缝本身不产生收益,收益上限 100% 取决于提供方**:随手写的词法变体反而赔 1.5~3.0pp
|
|
611
|
+
(安全上限是作弊上界,不是期望值);而上界本身由候选池决定 —— 20 个候选里有 gold 的查询只占
|
|
612
|
+
**86.6%**(610/704),所以「完美提供方」也只能把 hit@1 从 50.4% 抬到 86.6%,那 36.2pp 的缺口
|
|
613
|
+
要靠**换真正不同的信号**(比如真 embedding、跨编码器)去补,插件不替你选。
|
|
614
|
+
|
|
615
|
+
`test/semantic.test.mjs`(假提供方模块:抛错、卡住、返回垃圾、编造文件名)与
|
|
616
|
+
`test/semantic-wiring.test.mjs`(关着时连 import 都不发生、开着时真的融合、自动注入不碰接缝)
|
|
617
|
+
就是钉这四条的。
|
|
618
|
+
|
|
541
619
|
## 配置
|
|
542
620
|
|
|
543
621
|
改 profile 的 `cordis.patch.yml`(改完要重启 App 才生效,宿主进程会缓存 ESM 模块):
|
|
@@ -567,11 +645,69 @@ aliases: 梯子, 翻墙, 科学上网, 节点, 代理
|
|
|
567
645
|
indexMaxLines: 200 # 索引 MEMORY.md 的行数上限
|
|
568
646
|
indexMaxBytes: 25600 # 索引的字节上限
|
|
569
647
|
registerSkill: true # 注册 engramory 技能(写入纪律的正文)
|
|
648
|
+
# 语义检索接缝(默认关闭):本地模块路径,或 host-llm(走宿主模型做查询改写)
|
|
649
|
+
semanticProvider: ''
|
|
650
|
+
semanticTimeoutMs: 2000
|
|
570
651
|
```
|
|
571
652
|
|
|
572
653
|
`autoArchive` 打开后归档会在两处自动跑:每轮 pre-step 兜底(10 分钟 TTL,不会每轮扫库),
|
|
573
654
|
以及写完记忆库的那一刻(触发源记为 `write`)。它仍然先备份、仍然过写入闸门,只是不再等你点按钮。
|
|
574
655
|
|
|
656
|
+
## 发版(打 tag 就发,不用长期 token)
|
|
657
|
+
|
|
658
|
+
```bash
|
|
659
|
+
# 1. 改 package.json 的 version + 写 CHANGELOG
|
|
660
|
+
# 2. 本地三条门槛全过(CI 里跑的是同样三条)
|
|
661
|
+
npm test && npm run bench:check && npm pack --dry-run
|
|
662
|
+
# 3. 提交、打 tag、推上去 —— 剩下的交给 GitHub Actions
|
|
663
|
+
git commit -am "release: 0.9.1" && git tag -a v0.9.1 -m "0.9.1" && git push origin main --tags
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
`.github/workflows/release.yml` 会在 tag 推上去后跑:校验 tag 与 `package.json` 一致 → 测试与基准
|
|
667
|
+
→ `npm publish --provenance` → `npm run release:verify`(**直查版本端点**,把 registry 上的
|
|
668
|
+
`dist.shasum` 与本地 `npm pack` 的逐字比对 —— 退出码不算数,这才算数)。tag 早打过、当时发不出去
|
|
669
|
+
的情况用 `gh workflow run release.yml` 手动触发,版本以 `package.json` 为准。
|
|
670
|
+
|
|
671
|
+
它**不用任何长期 token**:走 npm 的 Trusted Publisher(GitHub OIDC)。npm 包的
|
|
672
|
+
Settings → Trusted Publisher → GitHub Actions 要填四个字段,一个都不能错:
|
|
673
|
+
|
|
674
|
+
| 字段 | 填什么 |
|
|
675
|
+
| --- | --- |
|
|
676
|
+
| Organization or user | `yffo` |
|
|
677
|
+
| Repository | `dsh-engramory-recall` |
|
|
678
|
+
| Workflow filename | `release.yml`(只写文件名,别写 `.github/workflows/`) |
|
|
679
|
+
| Environment | 留空(workflow 里没有 `environment`,填了就必然对不上) |
|
|
680
|
+
|
|
681
|
+
为什么非要走这条路:长期 token 正在被 npm 关掉(bypass 2FA 的 token 直接发布 2027-01 起失效),
|
|
682
|
+
而普通 token 每次发布都要 6 位 OTP —— 人肉发版迟早卡在口令上(2026-10-15 就卡过一次,`EOTP`)。
|
|
683
|
+
|
|
684
|
+
### 发不出去时,先看这两行日志
|
|
685
|
+
|
|
686
|
+
2026-10-15 的 0.9.0 首发布连挂两次,两次的报错都是**假线索**,真因只在日志里:
|
|
687
|
+
|
|
688
|
+
| 日志里出现 | 真因 | 修法 |
|
|
689
|
+
| --- | --- | --- |
|
|
690
|
+
| `404 Not Found - PUT https://registry.npmjs.org/<pkg>`,紧挨着一句 bypass-2FA 提示 | `actions/setup-node` 的 `registry-url` 会写一行 `_authToken=${NODE_AUTH_TOKEN}` 并塞占位值 `XXXXX-XXXXX-XXXXX-XXXXX`;npm 只要在配置里看到 token 就**不再走 OIDC**,于是拿假 token 去 PUT | 去掉 `registry-url`;workflow 里已加一步断言 `_authToken` 为空(只打有无、不打值) |
|
|
691
|
+
| `ENEEDAUTH need auth ... npm adduser` | npm 把 OIDC 交换的失败**全吞掉**(`lib/utils/oidc.js` 只写一行 verbose 就返回 undefined),所以「没绑定」和「字段不符」长得一模一样 | 看预检步的 `交换接口 HTTP …`(见下);或给 publish 加 `--loglevel verbose`,日志里会出现 `Failed token exchange request with body message: …` |
|
|
692
|
+
| `交换接口 HTTP 404` + `OIDC token exchange error - package not found` | 这个包在 npm 上**没有任何 Trusted Publisher 绑定**(注意它不说「没绑定」,说的是「包找不到」——registry 惯用的模糊话术) | 去 Settings → Trusted Publisher → GitHub Actions 绑上那四个字段 |
|
|
693
|
+
| `交换接口 HTTP 401` + `OIDC token exchange error - unauthorized` | id-token 本身没拿到或被拒(`permissions: id-token: write` 缺失、或请求里带的是假 token) | 检查 workflow 的 `permissions` |
|
|
694
|
+
| `交换接口 HTTP 201` + `{"token": …}` | **这就是成功** —— registry 用 `201 Created` 表示新建了一个临时 token(不是 200)。预检的判据是「回包里有没有 `token`」,不是 HTTP 码 | 什么都不用做,往后走 |
|
|
695
|
+
|
|
696
|
+
工作流里那步「**预检 OIDC 交换**」就是为这张表服务的:它自己发一次与 `npm publish` 内部完全相同的
|
|
697
|
+
交换请求(`POST /-/npm/v1/oidc/token/exchange/package/<name>`),把 registry 的原话打印出来,
|
|
698
|
+
并且排在测试之前 —— 缺绑定时 30 秒就失败,不必先烧两分钟测试。
|
|
699
|
+
|
|
700
|
+
还有一条同类的假线索:runner 上 Node 22 自带的 npm 是 10.x,而 Trusted Publisher 要
|
|
701
|
+
**npm ≥ 11.5.1**,所以 workflow 第一步就 `npm install -g 'npm@^11.5.1'` —— 少了这步也会报
|
|
702
|
+
ENEEDAUTH,看起来像「Trusted Publisher 没配好」,其实只是 npm 太老。
|
|
703
|
+
|
|
704
|
+
还没配 Trusted Publisher 时可以手动发(临时文件里放 token,用完删):
|
|
705
|
+
|
|
706
|
+
```bash
|
|
707
|
+
npm publish --access public --userconfig=/tmp/npmrc-<名字>
|
|
708
|
+
npm run release:verify # 发完必须验证
|
|
709
|
+
```
|
|
710
|
+
|
|
575
711
|
## 安装 / 卸载
|
|
576
712
|
|
|
577
713
|
```bash
|
|
@@ -618,8 +754,23 @@ dsh plugin --profile <profile> remove dsh-engramory-recall
|
|
|
618
754
|
# 装了这个包就有了一个可直接调用的命令
|
|
619
755
|
engramory-recall-mcp --store ~/memory/engramory # 库路径也可以用环境变量 ENGRAMORY_STORE
|
|
620
756
|
npm run mcp:smoke # 自证:拉起来走一遍完整对话,打印收发记录
|
|
757
|
+
npm run bench:mcp # 这一层的数字:冷启动、每步往返、协议帧多大
|
|
621
758
|
```
|
|
622
759
|
|
|
760
|
+
这一层的代价(本机实测,64 篇的库、5 次冷启动取中位数):
|
|
761
|
+
|
|
762
|
+
| 指标 | 值 | 说明 |
|
|
763
|
+
|---|---|---|
|
|
764
|
+
| 冷启动 | 39 ms | 含 Node 启动、加载索引、`initialize` 往返 |
|
|
765
|
+
| `tools/list` | < 1 ms | 清单是静态的 |
|
|
766
|
+
| `engramory_search` | 74 ms | 首次检索要为 66 篇建一次索引,之后同进程走缓存 |
|
|
767
|
+
| `engramory_status` | 3 ms | 只读索引头 |
|
|
768
|
+
| 协议帧合计 | 3025 字节 | 一次完整对话(initialize + list + search + status)的 stdout 总量 |
|
|
769
|
+
| 依赖 | 0 | 不装任何东西,进程起来就能说 MCP |
|
|
770
|
+
|
|
771
|
+
延迟随机器变化,能跨机复算的是**帧的字节数与依赖性**,以及 `npm run bench:mcp` 里的那条硬断言:
|
|
772
|
+
stdout 上只准有 JSON-RPC 帧,混进任何一行非 JSON,这次运行就直接失败(顺带守住「日志只走 stderr」)。
|
|
773
|
+
|
|
623
774
|
**为什么它是只读的**:写入闸门、归档、体检这些能力依赖宿主工具层的钩子(`tools/pre-execute`),
|
|
624
775
|
别的宿主没有这套接口;硬做一个「谁都能写」的版本,等于把 fail-closed 的闸门换成一句口头约定。
|
|
625
776
|
所以跨宿主给的是**看**的能力,治理留在家用版里。它也不 import 任何宿主包 ——
|
|
@@ -633,6 +784,8 @@ npm run check # 接线自检:配置键认不认、库路径对不对、装
|
|
|
633
784
|
npm run probe # 召回质量探针:3 组 query + 三道对照,跑在真实记忆库上
|
|
634
785
|
npm run probe -- --save # 把这次结果存成基线,之后每次跑自动比对
|
|
635
786
|
npm run bench:check # 合成基准 + 和 bench/expectations.json 对账(CI 跑的就是它)
|
|
787
|
+
npm run bench:seam # 语义接缝的代价与天花板(四种坏提供方 + 作弊上界)
|
|
788
|
+
npm run bench:mcp # MCP 这一层:冷启动、每步往返、协议帧字节数
|
|
636
789
|
npm run mcp:smoke # 拿脚本当 MCP 客户端,把服务器拉起来走一遍真实对话
|
|
637
790
|
node bench/longmemeval.mjs --data bench/.tmp/longmemeval_s_cleaned.json # 公开基准(数据要自己下,264MB)
|
|
638
791
|
```
|
|
@@ -687,12 +840,13 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
|
|
|
687
840
|
| DSH-SEP | 每步 ~1250 字符 guidance + ≤4200 字符快照 | 同上 | 抽取走 `api.deepseek.com` |
|
|
688
841
|
| **本插件** | **0** | **≤480 字符,且只有指针** | **无** |
|
|
689
842
|
|
|
690
|
-
##
|
|
843
|
+
## 九条被实测证伪的路
|
|
691
844
|
|
|
692
845
|
| 尝试 | 结果 |
|
|
693
846
|
|---|---|
|
|
694
847
|
| 拿「结果要多样」当选桶护栏: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 名,属于「差一点」,需要的是语义不是重排 |
|
|
695
|
-
| df 下限(把出现在超过一半文档里的词直接扔掉) | LongMemEval-S 上扫描 keep6 / keep12 / 0.7 三档:@1 与基线持平或更差(80.5% vs 81.5%),@5 一点不涨。英文虚词已经由查询侧停用词处理掉,索引侧再删一遍只会顺手动掉 `len
|
|
848
|
+
| 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 条实话) |
|
|
849
|
+
| 拿日期当检索信号(解析查询里的时间词做时间窗过滤) | `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%) |
|
|
696
850
|
| 伪相关反馈查询扩展(拿 top-1 的高频词再搜一遍,RM3 那一路) | 在 LongMemEval-S 500 题上前 120 题实测:RecallAny@1 从 89.2% 掉到 **48.3%**。机制:扩出来的词全是 top-1 自己的词汇,等于把「已经赢了的文档」又投了一遍票,稍有偏差的第一次命中会被自己的回声推到第一。Recall@5 只涨 0.4pt,@1 腰斩 —— 这种改动在只看 top-5 的评测里会被误认为「有效」 |
|
|
697
851
|
| 提高分数门槛 / IDF 门槛过滤误召回 | 正例最低分 4.59、反例最高分 5.54,区间重叠切不开;IDF 门槛会把 recall 从 28/28 打到 13/28 |
|
|
698
852
|
| freshness 时间衰减(学灵枢 / dsh-mneme) | 六档半衰期扫描:recall 恒为 28/28、top-1 只有一档 +1(噪声级)。库里「旧」的那批恰好是基础事实,衰减就是误伤 |
|
package/bm25.js
CHANGED
|
@@ -467,16 +467,41 @@ const EN_STOPWORDS = new Set([
|
|
|
467
467
|
]);
|
|
468
468
|
|
|
469
469
|
/**
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
470
|
+
* 求助/建议类通用词(查询侧)。这些词是「同一类对话」的框架,不是话题:
|
|
471
|
+
* 一次求助会话里几乎必有 tips / suggest / advice / thinking,于是任何一次同流派会话都会
|
|
472
|
+
* 因为共现这几个框架词而被抬分 —— 那是 genre(同一类对话)在匹配,不是 topic 在匹配。
|
|
473
|
+
* 最吃亏的是「说出偏好再问建议」的查询:它的实词(巧克力曲奇、鸡尾酒、卧室家具)才是信息,
|
|
474
|
+
* 却被一堆框架词稀释成「又一场给建议的对话」。
|
|
475
|
+
*
|
|
476
|
+
* 实测(LongMemEval-S 500 题,2026-10-15,`bench/.tmp/recon-queryclean.mjs`):
|
|
477
|
+
* · 查询侧去掉这一类词 → Any@1 88.4% → 89.2%、MRR 0.9202 → 0.9251;
|
|
478
|
+
* preference 分片 @1 46.7% → 60.0%,其余五个分片逐位不变。
|
|
479
|
+
* · 换成「按 df 比例自动丢高频查询词」的通用规则则全线下跌(df<0.35 → @1 87.6%、
|
|
480
|
+
* df<0.20 → 85.4%)⇒ 收益来自这一类词的**语义**,不是频次本身(补上一条更早的证伪:
|
|
481
|
+
* df 下限同样无效)。
|
|
482
|
+
* · 14 个词逐个消融:没有哪个词单独扛收益(去掉任意一个最多掉 1 题),15 词子集与
|
|
483
|
+
* 35 词大表同分 ⇒ 收益是「一类词」,不是一个咒语。
|
|
484
|
+
* 只收「在失败样本里真出现过」的写法:单复数不自动补齐(补了就是没量过的猜测)。
|
|
485
|
+
*/
|
|
486
|
+
const EN_REQUEST_STOPWORDS = new Set([
|
|
487
|
+
'tips', 'suggest', 'suggestions', 'recommend', 'recommendations', 'advice',
|
|
488
|
+
'think', 'thinking', 'idea', 'ideas', 'helpful', 'good', 'upcoming', 'weekend',
|
|
489
|
+
]);
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 查询词项:去重 + 丢掉英文虚词 + 丢掉求助/建议类框架词。
|
|
493
|
+
* 三道保险都保留原样,宁可留噪声也不把查询清空:
|
|
494
|
+
* · 只剩一个词项的查询(很可能那个词就是全部信息,「tips」这种就别筛了);
|
|
495
|
+
* · 全是虚词的查询(「what about this」这种),过滤后为空等于一条都召不回;
|
|
496
|
+
* · 去掉框架词后为空(「any tips?」)→ 退回只过虚词的结果,而不是清空。
|
|
474
497
|
*/
|
|
475
498
|
export function queryTerms(text) {
|
|
476
499
|
const terms = [...new Set(tokenize(text))];
|
|
477
500
|
if (terms.length < 2) return terms;
|
|
478
|
-
const
|
|
479
|
-
|
|
501
|
+
const withoutFunction = terms.filter((term) => !EN_STOPWORDS.has(term));
|
|
502
|
+
const base = withoutFunction.length ? withoutFunction : terms;
|
|
503
|
+
const kept = base.filter((term) => !EN_REQUEST_STOPWORDS.has(term));
|
|
504
|
+
return kept.length ? kept : base;
|
|
480
505
|
}
|
|
481
506
|
|
|
482
507
|
/** 查询里命中的同义键(按原文字面,大小写不敏感)。 */
|
package/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { checkIndexWrite, DEFAULT_MAX_INDEX_BYTES, DEFAULT_MAX_INDEX_LINES } fro
|
|
|
26
26
|
import { registerSkill, skillStatus } from './skill.js';
|
|
27
27
|
import { appendAudit, readAudit, GUARD_EPOCH } from './audit.js';
|
|
28
28
|
import { readDocs } from './store.js';
|
|
29
|
+
import { describeSeam, semanticSearch } from './semantic.js';
|
|
29
30
|
import { auditStore } from './doctor.js';
|
|
30
31
|
import { planArchive, applyArchive } from './archive.js';
|
|
31
32
|
|
|
@@ -163,6 +164,17 @@ const DEFAULTS = {
|
|
|
163
164
|
llmReasoningEffort: '',
|
|
164
165
|
/** 单次模型调用的超时预算(毫秒)。 */
|
|
165
166
|
llmTimeoutMs: 30000,
|
|
167
|
+
/**
|
|
168
|
+
* 语义接缝(默认关闭):留空 = 纯词面,连 import 都不发生。
|
|
169
|
+
*
|
|
170
|
+
* 取值:本地模块路径(导出 `rerank({query, docs, limit, signal})` 或
|
|
171
|
+
* `expand({query, signal})`),或者字面量 `host-llm`(走宿主模型,动态 import llm.js)。
|
|
172
|
+
* 它只影响**按需检索工具**;每轮自动注入那条路径永远是纯词面 ——
|
|
173
|
+
* 自动召回每轮都跑,挂一个需要 300ms 的外部件等于给每个用户轮加税。
|
|
174
|
+
*/
|
|
175
|
+
semanticProvider: '',
|
|
176
|
+
/** 接缝的单次超时预算(毫秒)。超时/抛错/返回垃圾一律降级回词面结果。 */
|
|
177
|
+
semanticTimeoutMs: 2000,
|
|
166
178
|
};
|
|
167
179
|
|
|
168
180
|
/** 只在用户句子里出现明确的「长期记住」意图时才追加一行提示(默认开)。 */
|
|
@@ -315,6 +327,16 @@ export function apply(ctx, config = {}) {
|
|
|
315
327
|
},
|
|
316
328
|
},
|
|
317
329
|
note: { type: 'string' },
|
|
330
|
+
semantic: {
|
|
331
|
+
type: 'object',
|
|
332
|
+
properties: {
|
|
333
|
+
used: { type: 'boolean' },
|
|
334
|
+
code: { type: 'string' },
|
|
335
|
+
message: { type: 'string' },
|
|
336
|
+
},
|
|
337
|
+
required: ['used', 'code', 'message'],
|
|
338
|
+
additionalProperties: false,
|
|
339
|
+
},
|
|
318
340
|
},
|
|
319
341
|
required: ['store', 'hits', 'note'],
|
|
320
342
|
additionalProperties: false,
|
|
@@ -329,7 +351,19 @@ export function apply(ctx, config = {}) {
|
|
|
329
351
|
return { store: storePath, hits: [], note: '需要 storePath 配置与非空 query' };
|
|
330
352
|
}
|
|
331
353
|
const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20);
|
|
332
|
-
|
|
354
|
+
// 接缝关着(默认)就走原来那条同步路径:不 await、不 import、零额外开销。
|
|
355
|
+
// 开着也只是「融合」:语义提供方插进来的顺序与词面顺序做 RRF,词面命中的文档一条不丢。
|
|
356
|
+
const seam = describeSeam({ provider: settings.semanticProvider });
|
|
357
|
+
const result = seam.enabled
|
|
358
|
+
? await semanticSearch({
|
|
359
|
+
index: indexOf(storePath),
|
|
360
|
+
query,
|
|
361
|
+
limit,
|
|
362
|
+
config: { provider: settings.semanticProvider, timeoutMs: settings.semanticTimeoutMs },
|
|
363
|
+
runtime: llmRuntime(),
|
|
364
|
+
})
|
|
365
|
+
: { hits: search(indexOf(storePath), query, limit), used: false, code: 'off', message: '语义接缝未启用(默认)' };
|
|
366
|
+
const hits = result.hits.map((hit) => ({
|
|
333
367
|
file: hit.id,
|
|
334
368
|
score: Number(hit.score.toFixed(3)),
|
|
335
369
|
snippet: clip(hit.snippet, 140),
|
|
@@ -340,6 +374,7 @@ export function apply(ctx, config = {}) {
|
|
|
340
374
|
note: hits.length
|
|
341
375
|
? '用 read 打开需要的文件;正文在笔记里,不在这个结果里'
|
|
342
376
|
: '没有命中:换关键词再试(中文可以换相邻词)',
|
|
377
|
+
semantic: { used: result.used, code: result.code, message: result.message },
|
|
343
378
|
};
|
|
344
379
|
},
|
|
345
380
|
});
|
package/mcp.js
CHANGED
|
@@ -24,6 +24,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
24
24
|
|
|
25
25
|
import { buildIndex, search } from './bm25.js';
|
|
26
26
|
import { countLines, DEFAULT_MAX_INDEX_BYTES, DEFAULT_MAX_INDEX_LINES, measureIndex } from './index-guard.js';
|
|
27
|
+
import { describeSeam, semanticSearch } from './semantic.js';
|
|
27
28
|
import { corpusFingerprint, readDocs } from './store.js';
|
|
28
29
|
|
|
29
30
|
export const SERVER_NAME = 'dsh-engramory-recall';
|
|
@@ -107,7 +108,15 @@ export function resolveStorePath(raw) {
|
|
|
107
108
|
|
|
108
109
|
export function createSession(options = {}) {
|
|
109
110
|
const raw = options.store ?? process.env.ENGRAMORY_STORE ?? DEFAULT_STORE;
|
|
110
|
-
|
|
111
|
+
const semantic = String(options.semantic ?? process.env.ENGRAMORY_SEMANTIC_PROVIDER ?? '').trim();
|
|
112
|
+
return {
|
|
113
|
+
store: resolveStorePath(raw),
|
|
114
|
+
storeRaw: String(raw ?? '').trim() || DEFAULT_STORE,
|
|
115
|
+
semantic,
|
|
116
|
+
semanticTimeoutMs: Number(options.semanticTimeoutMs) || 2000,
|
|
117
|
+
index: null,
|
|
118
|
+
fingerprint: '',
|
|
119
|
+
};
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
/** 索引按语料指纹缓存:库被改过就重建,没改过就复用(readDocs 自己也有 mtime 缓存)。 */
|
|
@@ -158,7 +167,7 @@ function readNote(session, file) {
|
|
|
158
167
|
return text;
|
|
159
168
|
}
|
|
160
169
|
|
|
161
|
-
function toolSearch(session, args) {
|
|
170
|
+
async function toolSearch(session, args) {
|
|
162
171
|
const query = String(args?.query ?? '').trim();
|
|
163
172
|
if (!query) throw new McpError('需要非空的 query。');
|
|
164
173
|
const raw = Number(args?.limit);
|
|
@@ -167,7 +176,12 @@ function toolSearch(session, args) {
|
|
|
167
176
|
if (!docs.length) {
|
|
168
177
|
throw new McpError(`库是空的或不存在:${session.store}。用 --store <路径> 或环境变量 ENGRAMORY_STORE 指定。`);
|
|
169
178
|
}
|
|
170
|
-
|
|
179
|
+
// 语义接缝在 MCP 侧同样可用,但只支持「本地模块」提供方:`host-llm` 需要 DSH 宿主运行时,
|
|
180
|
+
// 这里没有(semanticSearch 会明确回 no-host 并降级,不假装成功)。
|
|
181
|
+
const seam = session.semantic || '';
|
|
182
|
+
const { hits } = seam
|
|
183
|
+
? await semanticSearch({ index: indexFor(session), query, limit, config: { provider: seam, timeoutMs: session.semanticTimeoutMs } })
|
|
184
|
+
: { hits: searchLexical(indexFor(session), query, limit) };
|
|
171
185
|
const header = `库:${session.store}(${notesOf(session).length} 篇)· 查询:${query}`;
|
|
172
186
|
if (!hits.length) return `${header}\n没有命中。换个词,或先用 engramory_status 确认库对不对。`;
|
|
173
187
|
const lines = hits.map((hit, i) => {
|
|
@@ -200,6 +214,11 @@ function toolStatus(session) {
|
|
|
200
214
|
].join('\n');
|
|
201
215
|
}
|
|
202
216
|
|
|
217
|
+
/** 关着(默认)时用的同步词面检索 —— 与插件里那条路径一字不差。 */
|
|
218
|
+
function searchLexical(index, query, limit) {
|
|
219
|
+
return buildIndex ? search(index, query, limit) : [];
|
|
220
|
+
}
|
|
221
|
+
|
|
203
222
|
const HANDLERS = {
|
|
204
223
|
engramory_search: toolSearch,
|
|
205
224
|
engramory_read: (session, args) => readNote(session, args?.file),
|
|
@@ -213,7 +232,10 @@ export async function callTool(session, params) {
|
|
|
213
232
|
return { content: [{ type: 'text', text: `没有这个工具:${name}。可用:${Object.keys(HANDLERS).join(' / ')}` }], isError: true };
|
|
214
233
|
}
|
|
215
234
|
try {
|
|
216
|
-
|
|
235
|
+
// 处理器可以是 async(搜索走语义接缝时就是):这里必须 await,
|
|
236
|
+
// 否则 String(Promise) 会把结果变成一行 "[object Promise]" —— 0.9.0 开发时真踩过。
|
|
237
|
+
const value = await handler(session, params?.arguments ?? {});
|
|
238
|
+
return { content: [{ type: 'text', text: String(value) }], isError: false };
|
|
217
239
|
} catch (error) {
|
|
218
240
|
const text = error instanceof McpError ? error.message : `工具 ${name} 出错:${error?.message ?? error}`;
|
|
219
241
|
return { content: [{ type: 'text', text }], isError: true };
|
|
@@ -326,18 +348,22 @@ export async function serveStdio(session, options = {}) {
|
|
|
326
348
|
await Promise.all(pending);
|
|
327
349
|
}
|
|
328
350
|
|
|
329
|
-
export const USAGE = `用法:node mcp.js [--store <路径>] [--print-config] [--version] [--help]
|
|
351
|
+
export const USAGE = `用法:node mcp.js [--store <路径>] [--semantic <本地模块路径>] [--print-config] [--version] [--help]
|
|
330
352
|
|
|
331
353
|
零依赖、只读的 MCP stdio 服务器:把 Engramory markdown 记忆库暴露成
|
|
332
354
|
engramory_search / engramory_read / engramory_status 三个工具。
|
|
333
355
|
库路径优先级:--store > 环境变量 ENGRAMORY_STORE > ${DEFAULT_STORE}
|
|
356
|
+
语义接缝(可选、默认关闭):--semantic > 环境变量 ENGRAMORY_SEMANTIC_PROVIDER,
|
|
357
|
+
只支持本地模块提供方(host-llm 需要 DSH 宿主运行时,这里会降级为词面)。
|
|
334
358
|
日志一律写 stderr;stdout 只有 JSON-RPC 帧。`;
|
|
335
359
|
|
|
336
360
|
export function parseArgs(argv) {
|
|
337
|
-
const args = { store: '', help: false, version: false, printConfig: false };
|
|
361
|
+
const args = { store: '', semantic: '', help: false, version: false, printConfig: false };
|
|
338
362
|
for (let i = 0; i < argv.length; i += 1) {
|
|
339
363
|
const arg = argv[i];
|
|
340
364
|
if (arg === '--store') args.store = argv[++i] ?? '';
|
|
365
|
+
else if (arg === '--semantic') args.semantic = argv[++i] ?? '';
|
|
366
|
+
else if (arg.startsWith('--semantic=')) args.semantic = arg.slice('--semantic='.length);
|
|
341
367
|
else if (arg.startsWith('--store=')) args.store = arg.slice('--store='.length);
|
|
342
368
|
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
343
369
|
else if (arg === '--version' || arg === '-v') args.version = true;
|
|
@@ -361,12 +387,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
361
387
|
process.stdout.write(`${SERVER_VERSION}\n`);
|
|
362
388
|
return 0;
|
|
363
389
|
}
|
|
364
|
-
const session = createSession({ store: args.store || undefined });
|
|
390
|
+
const session = createSession({ store: args.store || undefined, semantic: args.semantic || undefined });
|
|
365
391
|
if (args.printConfig) {
|
|
366
392
|
process.stdout.write(`${JSON.stringify({ name: SERVER_NAME, version: SERVER_VERSION, store: session.store })}\n`);
|
|
367
393
|
return 0;
|
|
368
394
|
}
|
|
369
|
-
|
|
395
|
+
const seam = describeSeam({ provider: session.semantic });
|
|
396
|
+
process.stderr.write(
|
|
397
|
+
`[${SERVER_NAME}] 库:${session.store} · 只读 · 语义接缝:${seam.enabled ? seam.spec : '关闭(默认)'} · 日志只走 stderr\n`,
|
|
398
|
+
);
|
|
370
399
|
await serveStdio(session);
|
|
371
400
|
return 0;
|
|
372
401
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engramory-recall",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"./skill": "./skill.js",
|
|
39
39
|
"./llm": "./llm.js",
|
|
40
40
|
"./index": "./index.js",
|
|
41
|
-
"./mcp": "./mcp.js"
|
|
41
|
+
"./mcp": "./mcp.js",
|
|
42
|
+
"./semantic": "./semantic.js"
|
|
42
43
|
},
|
|
43
44
|
"files": [
|
|
44
45
|
"index.js",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"index-guard.js",
|
|
53
54
|
"skill.js",
|
|
54
55
|
"llm.js",
|
|
56
|
+
"semantic.js",
|
|
55
57
|
"mcp.js",
|
|
56
58
|
"CHANGELOG.md",
|
|
57
59
|
"cordis.patch.yml",
|
|
@@ -76,9 +78,12 @@
|
|
|
76
78
|
"probe": "node scripts/recall-probe.mjs",
|
|
77
79
|
"bench": "node bench/run.mjs",
|
|
78
80
|
"bench:check": "node bench/run.mjs --check",
|
|
79
|
-
"
|
|
81
|
+
"bench:seam": "node bench/semantic-seam.mjs",
|
|
82
|
+
"bench:mcp": "node bench/mcp-overhead.mjs",
|
|
83
|
+
"mcp:smoke": "node scripts/mcp-smoke.mjs",
|
|
84
|
+
"release:verify": "node scripts/verify-release.mjs"
|
|
80
85
|
},
|
|
81
86
|
"bin": {
|
|
82
|
-
"engramory-recall-mcp": "
|
|
87
|
+
"engramory-recall-mcp": "mcp.js"
|
|
83
88
|
}
|
|
84
89
|
}
|
package/semantic.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可选的「语义检索」接缝(seam)。
|
|
3
|
+
*
|
|
4
|
+
* 立场先说清楚:本插件的默认路径是纯词面检索,**一个模型调用都不发**。
|
|
5
|
+
* 这个文件不改变那条基线,它只是给「用户自己愿意挂一个语义/重排提供方」留一个口子,
|
|
6
|
+
* 三条纪律把口子的边界钉死:
|
|
7
|
+
*
|
|
8
|
+
* 1. **默认关闭**:`provider` 为空时,这里连 `import()` 都不会执行;
|
|
9
|
+
* 2. **不 import 任何宿主包**:提供方只能是「本地模块路径」(用户在配置里写),
|
|
10
|
+
* 或者内置的 `host-llm` —— 后者也走 `llm.js` 的动态 import,静态依赖图里没有它;
|
|
11
|
+
* 3. **挂掉的提供方不许影响检索**:超时、抛错、返回垃圾形状、编造不存在的文件名,
|
|
12
|
+
* 一律降级回词面结果,并把原因如实放进返回值(`code` / `message`)。
|
|
13
|
+
*
|
|
14
|
+
* 提供方模块的两种接口(导出哪个就用哪个,两个都导出优先用 `rerank`):
|
|
15
|
+
*
|
|
16
|
+
* ```js
|
|
17
|
+
* // 1) 重排:给我候选,还我一个顺序
|
|
18
|
+
* export async function rerank({ query, docs, limit, signal }) {
|
|
19
|
+
* return [{ id: 'some-note.md', score: 0.9 }, ...];
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* // 2) 扩展:把问题改写成几句别的说法,由我分别检索再融合
|
|
23
|
+
* export async function expand({ query, signal }) {
|
|
24
|
+
* return ['另一种说法', '关键词组合'];
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* 两种结果都不是「替换」而是「融合」(RRF):词面命中的文档永远还在池子里,
|
|
29
|
+
* 提供方只能改变顺序,删不掉东西。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { homedir } from 'node:os';
|
|
34
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
35
|
+
import { pathToFileURL } from 'node:url';
|
|
36
|
+
|
|
37
|
+
import { search as lexicalSearch } from './bm25.js';
|
|
38
|
+
|
|
39
|
+
/** RRF 的平滑常数:教科书取值 60,小语料上够用。 */
|
|
40
|
+
export const RRF_K = 60;
|
|
41
|
+
export const DEFAULT_TIMEOUT_MS = 2000;
|
|
42
|
+
export const MAX_EXPANSIONS = 8;
|
|
43
|
+
export const MAX_EXPANSION_CHARS = 120;
|
|
44
|
+
export const HOST_LLM = 'host-llm';
|
|
45
|
+
|
|
46
|
+
/** 配置里能写的提供方:空 = 关闭;`host-llm` = 走宿主模型(llm.js);其余当本地模块路径。 */
|
|
47
|
+
export function describeSeam(config = {}) {
|
|
48
|
+
const spec = String(config.provider ?? '').trim();
|
|
49
|
+
if (!spec) return { enabled: false, kind: 'off', spec: '', message: '语义接缝未启用(默认)' };
|
|
50
|
+
if (spec === HOST_LLM) {
|
|
51
|
+
return { enabled: true, kind: 'host-llm', spec, message: '语义接缝指向宿主模型(llm.js,动态 import)' };
|
|
52
|
+
}
|
|
53
|
+
return { enabled: true, kind: 'module', spec, message: `语义接缝指向本地模块:${spec}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function expandHome(value) {
|
|
57
|
+
if (value === '~') return homedir();
|
|
58
|
+
return value.startsWith('~/') ? join(homedir(), value.slice(2)) : value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveProviderPath(spec, baseDir = process.cwd()) {
|
|
62
|
+
const text = expandHome(String(spec ?? '').trim());
|
|
63
|
+
if (!text) return '';
|
|
64
|
+
return isAbsolute(text) ? text : resolve(baseDir, text);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 加载提供方。任何失败都返回 `{ ok: false }` —— 调用方负责降级,这里绝不抛。 */
|
|
68
|
+
export async function loadProvider(config = {}, deps = {}) {
|
|
69
|
+
const seam = describeSeam(config);
|
|
70
|
+
if (!seam.enabled) return { ok: false, code: 'off', message: seam.message, seam };
|
|
71
|
+
try {
|
|
72
|
+
if (seam.kind === 'host-llm') {
|
|
73
|
+
const runtime = deps.runtime;
|
|
74
|
+
if (!runtime) return { ok: false, code: 'no-host', message: 'host-llm 提供方需要宿主运行时(runtime)', seam };
|
|
75
|
+
const module = await import('./llm.js');
|
|
76
|
+
const route = module.resolveRoute(runtime.ctx, runtime.config);
|
|
77
|
+
if (!route) return { ok: false, code: 'no-route', message: '宿主没有可用的默认模型,语义接缝降级', seam };
|
|
78
|
+
const provider = {
|
|
79
|
+
name: HOST_LLM,
|
|
80
|
+
kind: 'expand',
|
|
81
|
+
expand: ({ query, signal }) => hostLlmExpand(module, route, query, config, signal),
|
|
82
|
+
};
|
|
83
|
+
return { ok: true, provider, kind: 'expand', code: 'host-llm', message: seam.message, seam };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const full = resolveProviderPath(seam.spec, deps.baseDir);
|
|
87
|
+
if (!full) return { ok: false, code: 'empty-path', message: '提供方路径是空的', seam };
|
|
88
|
+
try {
|
|
89
|
+
readFileSync(full);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, code: 'not-found', message: `读不到提供方模块:${full}(${error?.code ?? error?.message})`, seam, path: full };
|
|
92
|
+
}
|
|
93
|
+
const module = await import(pathToFileURL(full).href);
|
|
94
|
+
const provider = normalizeModule(module, full);
|
|
95
|
+
if (!provider) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
code: 'unsupported',
|
|
99
|
+
message: `提供方模块必须导出 rerank 或 expand 函数:${full}`,
|
|
100
|
+
seam,
|
|
101
|
+
path: full,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { ok: true, provider, kind: provider.kind, code: 'module', message: seam.message, seam, path: full };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { ok: false, code: 'load-error', message: `加载提供方失败:${error?.message ?? error}`, seam };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeModule(module, source) {
|
|
111
|
+
const rerank = module?.rerank;
|
|
112
|
+
const expand = module?.expand;
|
|
113
|
+
if (typeof rerank === 'function') {
|
|
114
|
+
return { name: String(module.name ?? source.split('/').pop()), kind: 'rerank', rerank, source };
|
|
115
|
+
}
|
|
116
|
+
if (typeof expand === 'function') {
|
|
117
|
+
return { name: String(module.name ?? source.split('/').pop()), kind: 'expand', expand, source };
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 宿主模型的「扩展」实现:问模型要几句别的说法,别的什么都不做。 */
|
|
123
|
+
async function hostLlmExpand(module, route, query, config, signal) {
|
|
124
|
+
const prompt = [
|
|
125
|
+
'把用户的问题改写成 3 条更可能命中本地记忆库的检索短句(同义词、术语、英文对照都可以)。',
|
|
126
|
+
'只输出 JSON 数组,元素是字符串,不要解释、不要编号。',
|
|
127
|
+
`问题:${query}`,
|
|
128
|
+
].join('\n');
|
|
129
|
+
const result = await module.collectText(
|
|
130
|
+
{ stream: (options) => route.llm.stream(options) },
|
|
131
|
+
{
|
|
132
|
+
provider: route.provider,
|
|
133
|
+
model: route.model,
|
|
134
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
135
|
+
maxTokens: 200,
|
|
136
|
+
signal,
|
|
137
|
+
},
|
|
138
|
+
config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
139
|
+
);
|
|
140
|
+
const text = String(result?.text ?? '').trim();
|
|
141
|
+
const start = text.indexOf('[');
|
|
142
|
+
const end = text.lastIndexOf(']');
|
|
143
|
+
if (start === -1 || end <= start) return [];
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
146
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
147
|
+
} catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 带超时的执行:超时既用 AbortController 通知提供方,也 `Promise.race` 硬兜一层 ——
|
|
154
|
+
* 提供方完全可以忽略 signal(第三方代码什么都干得出来),只靠 abort 会让检索永远等下去。
|
|
155
|
+
*/
|
|
156
|
+
export async function withTimeout(run, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
157
|
+
const ms = Math.max(1, Number(timeoutMs) || DEFAULT_TIMEOUT_MS);
|
|
158
|
+
const controller = new AbortController();
|
|
159
|
+
let timer;
|
|
160
|
+
const expired = new Promise((_, reject) => {
|
|
161
|
+
timer = setTimeout(() => {
|
|
162
|
+
controller.abort(new Error('语义接缝超时'));
|
|
163
|
+
const error = new Error(`提供方超过 ${ms}ms 没回`);
|
|
164
|
+
error.name = 'AbortError';
|
|
165
|
+
reject(error);
|
|
166
|
+
}, ms);
|
|
167
|
+
});
|
|
168
|
+
try {
|
|
169
|
+
return await Promise.race([run(controller.signal), expired]);
|
|
170
|
+
} finally {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 倒数排名融合:列表里名次越靠前分越高;不同列表的分数不可比,所以按名次来。 */
|
|
176
|
+
export function mergeRRF(lists, limit) {
|
|
177
|
+
const scores = new Map();
|
|
178
|
+
for (const list of lists) {
|
|
179
|
+
list.forEach((id, index) => {
|
|
180
|
+
if (!id) return;
|
|
181
|
+
const gain = 1 / (RRF_K + index + 1);
|
|
182
|
+
scores.set(id, (scores.get(id) ?? 0) + gain);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return [...scores.entries()]
|
|
186
|
+
.sort((a, b) => (b[1] - a[1]) || String(a[0]).localeCompare(String(b[0])))
|
|
187
|
+
.slice(0, Math.max(1, limit))
|
|
188
|
+
.map(([id]) => id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeRanking(raw, docs) {
|
|
192
|
+
const known = new Set(docs.map((doc) => doc.id));
|
|
193
|
+
const seen = new Set();
|
|
194
|
+
const ids = [];
|
|
195
|
+
let dropped = 0;
|
|
196
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
197
|
+
for (const row of rows) {
|
|
198
|
+
const id = typeof row === 'string' ? row : String(row?.id ?? '');
|
|
199
|
+
if (!id) continue;
|
|
200
|
+
if (!known.has(id)) {
|
|
201
|
+
dropped += 1; // 模型/提供方编出来的文件名一律丢弃:宁可少一条,也不许凭空造文档
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (seen.has(id)) continue;
|
|
205
|
+
seen.add(id);
|
|
206
|
+
ids.push(id);
|
|
207
|
+
}
|
|
208
|
+
return { ids, dropped };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeExpansions(raw, query) {
|
|
212
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
213
|
+
const out = [];
|
|
214
|
+
const seen = new Set([String(query).trim().toLowerCase()]);
|
|
215
|
+
for (const row of rows) {
|
|
216
|
+
const text = String(typeof row === 'string' ? row : row?.query ?? '').trim().slice(0, MAX_EXPANSION_CHARS);
|
|
217
|
+
if (!text) continue;
|
|
218
|
+
const key = text.toLowerCase();
|
|
219
|
+
if (seen.has(key)) continue;
|
|
220
|
+
seen.add(key);
|
|
221
|
+
out.push(text);
|
|
222
|
+
if (out.length >= MAX_EXPANSIONS) break;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const fail = (hits, started, code, message, extra = {}) => ({
|
|
228
|
+
hits,
|
|
229
|
+
used: false,
|
|
230
|
+
code,
|
|
231
|
+
message,
|
|
232
|
+
ms: Date.now() - started,
|
|
233
|
+
...extra,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 词面检索 + 可选语义接缝。
|
|
238
|
+
*
|
|
239
|
+
* 返回值里 `used` 说明接缝这次到底有没有真的用上(false 时 `code`/`message` 给出原因),
|
|
240
|
+
* `ms` 是整段耗时 —— 这样调用方与测试都能看见「开着但没用上」和「关着」的区别。
|
|
241
|
+
*/
|
|
242
|
+
export async function semanticSearch(options = {}) {
|
|
243
|
+
const started = Date.now();
|
|
244
|
+
const runSearch = options.searchFn ?? lexicalSearch;
|
|
245
|
+
const limit = Math.max(1, Number(options.limit) || 5);
|
|
246
|
+
const query = String(options.query ?? '').trim();
|
|
247
|
+
const config = options.config ?? {};
|
|
248
|
+
const lexical = runSearch(options.index, query, limit);
|
|
249
|
+
if (!query) return fail([], started, 'empty-query', '查询为空');
|
|
250
|
+
|
|
251
|
+
const seam = describeSeam(config);
|
|
252
|
+
if (!seam.enabled) return fail(lexical, started, 'off', seam.message);
|
|
253
|
+
|
|
254
|
+
const loaded = await loadProvider(config, { runtime: options.runtime, baseDir: options.baseDir });
|
|
255
|
+
if (!loaded.ok) return fail(lexical, started, loaded.code, loaded.message);
|
|
256
|
+
|
|
257
|
+
const timeoutMs = Number(config.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
258
|
+
const provider = loaded.provider;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
if (provider.kind === 'rerank') {
|
|
262
|
+
const docs = lexical.map((hit, index) => ({
|
|
263
|
+
id: hit.id,
|
|
264
|
+
snippet: hit.snippet,
|
|
265
|
+
matched: hit.matched,
|
|
266
|
+
lexicalRank: index + 1,
|
|
267
|
+
lexicalScore: hit.score,
|
|
268
|
+
}));
|
|
269
|
+
const raw = await withTimeout((signal) => provider.rerank({ query, docs, limit, signal }), timeoutMs);
|
|
270
|
+
const ranking = normalizeRanking(raw, docs);
|
|
271
|
+
if (!ranking.ids.length) {
|
|
272
|
+
return fail(lexical, started, 'provider-empty', `提供方 ${provider.name} 没给出任何可用排序,已降级`, { provider: provider.name });
|
|
273
|
+
}
|
|
274
|
+
const order = mergeRRF([lexical.map((hit) => hit.id), ranking.ids], limit);
|
|
275
|
+
const byId = new Map(lexical.map((hit) => [hit.id, hit]));
|
|
276
|
+
const hits = order.map((id) => byId.get(id)).filter(Boolean);
|
|
277
|
+
// 词面没排进前 limit、却被提供方拉进来的文档不在池子里 —— 如实报数,别假装融合了。
|
|
278
|
+
const missing = order.filter((id) => !byId.has(id)).length;
|
|
279
|
+
return {
|
|
280
|
+
hits,
|
|
281
|
+
used: true,
|
|
282
|
+
code: 'reranked',
|
|
283
|
+
message: `已按 ${provider.name} 的顺序融合(RRF)`,
|
|
284
|
+
provider: provider.name,
|
|
285
|
+
dropped: ranking.dropped,
|
|
286
|
+
missing,
|
|
287
|
+
ms: Date.now() - started,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const raw = await withTimeout((signal) => provider.expand({ query, signal }), timeoutMs);
|
|
292
|
+
const expansions = normalizeExpansions(raw, query);
|
|
293
|
+
if (!expansions.length) {
|
|
294
|
+
return fail(lexical, started, 'provider-empty', `提供方 ${provider.name} 没给出任何可用的改写,已降级`, { provider: provider.name });
|
|
295
|
+
}
|
|
296
|
+
const lists = [lexical.map((hit) => hit.id)];
|
|
297
|
+
const byId = new Map(lexical.map((hit) => [hit.id, hit]));
|
|
298
|
+
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));
|
|
302
|
+
}
|
|
303
|
+
const order = mergeRRF(lists, limit);
|
|
304
|
+
const hits = order.map((id) => byId.get(id)).filter(Boolean);
|
|
305
|
+
return {
|
|
306
|
+
hits,
|
|
307
|
+
used: true,
|
|
308
|
+
code: 'expanded',
|
|
309
|
+
message: `已用 ${expansions.length} 条改写融合(RRF)`,
|
|
310
|
+
provider: provider.name,
|
|
311
|
+
expansions,
|
|
312
|
+
ms: Date.now() - started,
|
|
313
|
+
};
|
|
314
|
+
} catch (error) {
|
|
315
|
+
const aborted = error?.name === 'AbortError' || /超时/.test(String(error?.message ?? ''));
|
|
316
|
+
const code = aborted ? 'timeout' : 'provider-error';
|
|
317
|
+
const message = aborted
|
|
318
|
+
? `提供方 ${provider.name} 超过 ${timeoutMs}ms 没回,已降级为词面结果`
|
|
319
|
+
: `提供方 ${provider.name} 出错(${error?.message ?? error}),已降级为词面结果`;
|
|
320
|
+
return fail(lexical, started, code, message, { provider: provider.name });
|
|
321
|
+
}
|
|
322
|
+
}
|