dsh-tool-folder 0.2.5 → 0.3.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/LICENSE +21 -0
- package/README.md +109 -2
- package/lib/bm25.js +207 -127
- package/lib/embed-remote.js +193 -0
- package/lib/expand.js +276 -0
- package/lib/index.js +1261 -983
- package/lib/rerank.js +165 -0
- package/lib/semantic.js +273 -102
- package/lib/static-embed/meta.json +10 -0
- package/lib/static-embed/scales.bin +0 -0
- package/lib/static-embed/vectors.bin +0 -0
- package/lib/static-embed/vocab.json +1 -0
- package/lib/static-embed.js +195 -0
- package/package.json +55 -47
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hfhh32
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -107,7 +107,10 @@ were never unregistered, so nothing else changes.
|
|
|
107
107
|
| include | [] | whitelist: exact name or `prefix*`. Non-empty → only matching tools are injected (meta tools exempt; deny still wins). Execution is NOT gated by include. |
|
|
108
108
|
| toonifyResults | false | compact long JSON results after execution (drop empty fields + truncate strings; only when the text block is >2000 chars and parses as JSON) |
|
|
109
109
|
| maxFoldMs | 50 | hard cap; beyond this keep the full list |
|
|
110
|
-
| semanticEnabled | true |
|
|
110
|
+
| semanticEnabled | true | 语义腿总开关。检索融合顺序:**本地静态嵌入(`lib/static-embed/`)→ 远程 embedding → 本地 Ollama → 纯 BM25**;任一层缺失或失败自动降级,绝不比纯 BM25 更慢或更差 |
|
|
111
|
+
| staticWeight | 0.15 | **v0.3.2**: 本地静态嵌入腿在 RRF 中的权重。实测最优 0.15 —— 静态腿只负责补词法查不到的坑,压过词法会引入噪声(留出集 w=0.15 → recall@3 0.621,w=2 → 0.517) |
|
|
112
|
+
| staticEmbedDir | '' | **v0.3.2**: 静态嵌入目录(含 `vocab.json` / `vectors.bin` / `scales.bin` / `meta.json`)。留空 = 用插件自带的 `lib/static-embed`;目录不存在则该腿自动跳过 |
|
|
113
|
+
| semanticWeight | 3 | **远程** embedding 腿在 RRF 中的权重:1=等权,3=偏语义(默认),0=纯 BM25。与 `staticWeight` 分开配置,因为远程 bge-m3 质量更高 |
|
|
111
114
|
| ollamaBase | http://127.0.0.1:11434 | Ollama endpoint for the semantic leg |
|
|
112
115
|
| embedModel | bge-m3 | embedding model for the semantic leg |
|
|
113
116
|
| feedbackFile | '' | feedback JSON path (default `$DSH_HOME/logs/tool-folder/`) |
|
|
@@ -115,7 +118,11 @@ were never unregistered, so nothing else changes.
|
|
|
115
118
|
|
|
116
119
|
## Files
|
|
117
120
|
|
|
118
|
-
- `lib/bm25.js` — zero-dep BM25 (k1=1.2, b=0.75; CN bigrams + EN tokens)
|
|
121
|
+
- `lib/bm25.js` — zero-dep BM25 (k1=1.2, b=0.75; camelCase split + CN bigrams + EN tokens)
|
|
122
|
+
- `lib/expand.js` — **v0.3.1**: CN/EN dev-intent thesaurus + reverse alias expansion
|
|
123
|
+
- `lib/rerank.js` — **v0.3.1**: body-level rerank (weight 0.45) + optional RRF fusion
|
|
124
|
+
- `lib/static-embed.js` — **v0.3.2**: offline static embedding (lookup + mean-pool + cosine, zero deps)
|
|
125
|
+
- `lib/static-embed/` — **v0.3.2**: 7760 × 256 int8 table, 2.01 MB, built by `tools/build-static-embed.py`
|
|
119
126
|
- `lib/semantic.js` — v0.2.0 semantic leg: local bge-m3 embeddings (Ollama) + RRF fusion, disk-cached
|
|
120
127
|
- `lib/chainguard.js` — ChainGuard firewall: literal patterns + exfil-chain detection + deobfuscation
|
|
121
128
|
- `lib/obfuscation.js` — v0.2.0 anti-obfuscation: concat rebuild, `-enc`/certutil/bitsadmin shapes, homoglyphs
|
|
@@ -149,6 +156,106 @@ were never unregistered, so nothing else changes.
|
|
|
149
156
|
|
|
150
157
|
## Changelog
|
|
151
158
|
|
|
159
|
+
### v0.3.2 — 离线静态嵌入语义腿(不装 Ollama / 不续 Ark 也能有语义,2026-10-31)
|
|
160
|
+
|
|
161
|
+
**动机**:v0.3.0 的语义腿两条路全断——Ark `doubao-embedding` 返回 400
|
|
162
|
+
`InvalidSubscription`(订阅过期),Ollama 未安装(`11434` HTTP 000)。
|
|
163
|
+
在 29 条**留出查询**上实测,`v0.3.1` 的纯词法+词表只能到 **recall@3 = 0.448**,
|
|
164
|
+
剩下 55% 是词表覆盖不到的改写。
|
|
165
|
+
|
|
166
|
+
**做法**(`MinishLab/model2vec` 思路:把 sentence transformer 压成一张查找表):
|
|
167
|
+
|
|
168
|
+
- 源模型 `minishlab/potion-multilingual-128M`(分词器 `BAAI/bge-m3`),
|
|
169
|
+
50 万 token × 256 维 = 512 MB fp32
|
|
170
|
+
- **剪枝**:只保留 ① 领域文本用到的 token ② 全部单字 CJK(7688)→ 7760 行
|
|
171
|
+
- **int8 量化**:每行独立 scale,行先 L2 归一化,平均余弦保真度 **0.99995**
|
|
172
|
+
- 产物 **2.01 MB**(`lib/static-embed/`),运行期只做 查表 + 均值池化 + 余弦
|
|
173
|
+
—— 无推理图、无 onnx、无 WASM、无网络、无外部依赖,单次 embed < 1 ms
|
|
174
|
+
|
|
175
|
+
构建脚本 `tools/build-static-embed.py`(纯本地计算,读本地 safetensors)。
|
|
176
|
+
|
|
177
|
+
**融合权重单列**:新增 `staticWeight`(默认 **0.15**),与远程腿的
|
|
178
|
+
`semanticWeight`(默认 3)分开。整组压测(`eval/sweep-static.mjs`,留出集):
|
|
179
|
+
|
|
180
|
+
| bm25 : static | 1:0 | **1:0.15** | 1:0.3 | 1:0.5 | 1:1 | 1:2 |
|
|
181
|
+
|---|---|---|---|---|---|---|
|
|
182
|
+
| recall@3 | 0.448 | **0.621** | 0.586 | 0.586 | 0.552 | 0.517 |
|
|
183
|
+
|
|
184
|
+
**静态腿的价值是「补词法查不到的坑」,不是压过词法**——权重一高,它的噪声就会
|
|
185
|
+
把本来对的词法结果挤下去。本地腿同时固定 `bmWeight = 1`,不叠加 `adaptiveBm` 的中文降权。
|
|
186
|
+
|
|
187
|
+
**实测(29 条留出查询 / 40 条标注查询)**
|
|
188
|
+
|
|
189
|
+
| 指标 | v0.3.1 | v0.3.2 |
|
|
190
|
+
|---|---|---|
|
|
191
|
+
| 留出集 recall@3 | 0.448 | **0.621** |
|
|
192
|
+
| 留出集 recall@6 | 0.448 | **0.655** |
|
|
193
|
+
| 留出集 MRR | 0.379 | **0.506** |
|
|
194
|
+
| 标注集 recall@3 | 1.000 | 1.000 |
|
|
195
|
+
| 标注集 MRR | 0.888 | **0.904** |
|
|
196
|
+
|
|
197
|
+
**修掉一个会让结果变差的隐患**:查询向量为空时(整句 token 全在词表外)原会照常进融合,
|
|
198
|
+
所有文档余弦都是 0,排序退化为插入顺序,给 topK 塞进一批无关工具。现在直接跳过本地腿。
|
|
199
|
+
|
|
200
|
+
**测过但否掉的方案**(负结果同样重要):等权/高权重 RRF、按词法证据量自适应打折、
|
|
201
|
+
SIF 加权池化 + 去主成分——均无可靠收益,详见 `CHANGELOG-v0.3.2.md`。
|
|
202
|
+
|
|
203
|
+
### v0.3.1 — 分词器 + 意图词表 + 精排权重(2026-10-31)
|
|
204
|
+
|
|
205
|
+
新增 `eval/` 评测基础设施(36 个混合工具 / 40 条查询 + 29 条留出集 /
|
|
206
|
+
`run.mjs` `rerank.mjs` `sweep.mjs`),此后每次调参都有数据依据。
|
|
207
|
+
|
|
208
|
+
1. **`bm25.js` 修分词**:camelCase 拆分原先在 `toLowerCase()` 之后跑,
|
|
209
|
+
`searchNotes` 变成整串 `searchnotes`,查 "notes" 零命中;补 CJK 高频功能词
|
|
210
|
+
bigram 黑名单(`这个/一下/怎么` 等,IDF≈0 却撑大 `avgdl`)。
|
|
211
|
+
2. **`expand.js`(新增)**:约 60 组中英开发意图词表 + 反向别名扩展。
|
|
212
|
+
这是唯一能救「首轮零命中」的手段——标注集 recall@3 0.725 → 1.000。
|
|
213
|
+
3. **`rerank.js`**:修掉权重天花板(w=0.35 时元数据第 0 位底分 0.65、正文满分仅 0.35,
|
|
214
|
+
正文以 0.91 输给 0.00),扫描最优值 **0.45**;新增 `fusion: 'rrf'` 选项。
|
|
215
|
+
4. **`index.js`**:把 `eq` 拆成 `eqStr`(喂语义腿,字符串)与 `eq`(喂 BM25,token 数组),
|
|
216
|
+
修掉一处「语义腿一激活就崩」的隐患。
|
|
217
|
+
|
|
218
|
+
### v0.3.0 — 语义腿真正可用 + 融合修正(2026-10-17)
|
|
219
|
+
|
|
220
|
+
**动机(实测发现的真问题,不是推测)**:本机没装 Ollama(`127.0.0.1:11434` 返回
|
|
221
|
+
HTTP 000),`embedText` 恒为 null → `tools_search` 实际一直是**纯 BM25**。在 43 个
|
|
222
|
+
真实工具(`tools-real.json`)、18 条真实中文意图上测:**Hit@1 只有 6/18(33%)**。
|
|
223
|
+
|
|
224
|
+
1. **远程 embedding 腿(`lib/embed-remote.js` 新增)**:OpenAI 兼容端点,默认火山
|
|
225
|
+
`doubao-embedding-vision`(2048 维),key 读 `DSH_EMBED_KEY` / `OPENVIKING_ARK_KEY`。
|
|
226
|
+
带熔断(连续 3 次失败 → 5 分钟冷却)+ 批量(4 条/批、2 并发、429 退避 0.8/2/5s)。
|
|
227
|
+
纯语义 Hit@1 **11/18(61%)**。
|
|
228
|
+
2. **加权 RRF + 自适应 BM25 权重**:语义腿默认权重 3,BM25 默认 0.25;
|
|
229
|
+
若查询里的 ASCII 词命中 BM25 首条的名称/描述(真实词匹配、工具名、server 名),
|
|
230
|
+
BM25 权重回升到 1。实测 1:1 融合只有 8/18,加权后 **11/18**。
|
|
231
|
+
3. **重复 id 修复(关键)**:语料里同名工具出现两次时,旧 RRF 会把同一 id 的分累加
|
|
232
|
+
两次——`list_sessions` / `get_config` 各两份,直接把正确项从第一顶到第三以外
|
|
233
|
+
(纯语义 11/18 被拉到 2/18)。现在 bm25/语义两腿都按 id 去重,组装侧也先按名去重。
|
|
234
|
+
4. **索引可用性修复**:旧实现要求**全部** doc 成功才落缓存(一次失败就永远重建),
|
|
235
|
+
且 60s 硬截止串行调用;改为批量并发 + **部分成功也落盘** + 覆盖率 ≥80% 即可用。
|
|
236
|
+
5. **缓存路径修正**:`~/.dsh/state/` → 优先 `$DSH_HOME/state/`(DSH_HOME 指向
|
|
237
|
+
`.dsh-community` 时旧实现会把缓存写到另一个目录)。缓存身份键带 provider,
|
|
238
|
+
远程/本地切换不会复用错向量。
|
|
239
|
+
6. **索引不再阻塞**:在 assemble 后台构建,`tools_search` 绝不等待冷启动(旧实现
|
|
240
|
+
首次搜索可能卡 30s+);查询向量有内存缓存 + 15s 节流后台预热。
|
|
241
|
+
7. **正文精排(`lib/rerank.js` 新增,默认关)**:借鉴 SkillRouter 的两阶段检索,
|
|
242
|
+
在候选集内用「完整描述 + 参数名 + 参数描述 + 枚举」重排。**实测在本语料上
|
|
243
|
+
Hit@1 持平、Hit@3 反而 -1**(15→14),所以默认关闭,`bodyRerank: true` 可开。
|
|
244
|
+
8. **语义腿参与自动注入**(`semanticDynamic`,默认开):只用已缓存的查询向量,
|
|
245
|
+
零额外延迟;命中就把结果并进动态段。
|
|
246
|
+
9. `debugDump`:把当前工具语料 dump 成 JSON,供离线评测复现。
|
|
247
|
+
10. 测试 114 → **123 条**,全绿;新增 `scripts/eval-tools.mjs` 离线评测脚本。
|
|
248
|
+
|
|
249
|
+
**实测汇总(43 工具 / 18 中文意图)**
|
|
250
|
+
|
|
251
|
+
| 方案 | Hit@1 | Hit@3 |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| BM25(v0.2.5 实际状态) | 6/18 | 8/18 |
|
|
254
|
+
| RRF 1:1 | 8/18 | 12/18 |
|
|
255
|
+
| 语义单腿(参考上限) | 11/18 | 15/18 |
|
|
256
|
+
| **v0.3.0 默认(语义3 : BM25 0.25 自适应)** | **11/18** | **15/18** |
|
|
257
|
+
| 再加正文精排 0.35 | 11/18 | 14/18 |
|
|
258
|
+
|
|
152
259
|
### v0.2.5 — red-team firewall fixes + retrieval hardening (2026-09-02)
|
|
153
260
|
1. **Exec-tool recognition (red-team F1)** — bare-name matching let MCP-prefixed
|
|
154
261
|
exec tools (`mcp__windows__Cmd`) bypass the high-risk gate; `isExecTool` now
|
package/lib/bm25.js
CHANGED
|
@@ -1,127 +1,207 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* bm25.js — zero-dependency BM25 lexical search for tool retrieval.
|
|
3
|
-
*
|
|
4
|
-
* VENDOR SOURCE (2026-09-02): dsh-skill-folder/lib/bm25.js vendors this file
|
|
5
|
-
* verbatim (comments may diverge per repo). Modify here, then mirror the code
|
|
6
|
-
* change into the skill repo.
|
|
7
|
-
* semantic.js is intentionally DIVERGED (tool has incremental cache/sha1) and
|
|
8
|
-
* is NOT covered by the gate.
|
|
9
|
-
*
|
|
10
|
-
* Design (ported from the xiaowan agent's ToolBM25Index idea; the algorithm is
|
|
11
|
-
* the reference, this code is a clean-room JS implementation):
|
|
12
|
-
* - BM25 with standard k1=1.2, b=0.75 (RAG-MCP / BoR validated the
|
|
13
|
-
* retrieval-first approach; BM25 is the cheap lexical leg).
|
|
14
|
-
* - Tokenizer handles mixed CN/EN: ASCII words + CJK bigrams. No jieba, no
|
|
15
|
-
* network, no dependencies — must run inside the DSH plugin runtime.
|
|
16
|
-
* - Synchronous and fast: indexing ~50 tools is <1ms, a query <1ms.
|
|
17
|
-
*
|
|
18
|
-
* Cordis sandbox note: this module only does string/number math. It never
|
|
19
|
-
* touches fs/net/process, so it is safe in any host or sandbox context.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
/**
|
|
2
|
+
* bm25.js — zero-dependency BM25 lexical search for tool retrieval.
|
|
3
|
+
*
|
|
4
|
+
* VENDOR SOURCE (2026-09-02): dsh-skill-folder/lib/bm25.js vendors this file
|
|
5
|
+
* verbatim (comments may diverge per repo). Modify here, then mirror the code
|
|
6
|
+
* change into the skill repo. Both copies are kept in sync by the maintainer.
|
|
7
|
+
* semantic.js is intentionally DIVERGED (tool has incremental cache/sha1) and
|
|
8
|
+
* is NOT covered by the gate.
|
|
9
|
+
*
|
|
10
|
+
* Design (ported from the xiaowan agent's ToolBM25Index idea; the algorithm is
|
|
11
|
+
* the reference, this code is a clean-room JS implementation):
|
|
12
|
+
* - BM25 with standard k1=1.2, b=0.75 (RAG-MCP / BoR validated the
|
|
13
|
+
* retrieval-first approach; BM25 is the cheap lexical leg).
|
|
14
|
+
* - Tokenizer handles mixed CN/EN: ASCII words + CJK bigrams. No jieba, no
|
|
15
|
+
* network, no dependencies — must run inside the DSH plugin runtime.
|
|
16
|
+
* - Synchronous and fast: indexing ~50 tools is <1ms, a query <1ms.
|
|
17
|
+
*
|
|
18
|
+
* Cordis sandbox note: this module only does string/number math. It never
|
|
19
|
+
* touches fs/net/process, so it is safe in any host or sandbox context.
|
|
20
|
+
*
|
|
21
|
+
* ---- 2026-10-31 升级(v0.3.1)----
|
|
22
|
+
* 1. camelCase / PascalCase 拆分:`searchNotes` 之前的正则在 lowercase 后
|
|
23
|
+
* 拿到的是整串 `searchnotes`,查询 "notes" 零命中;现在先按驼峰拆再降小写。
|
|
24
|
+
* 2. CJK 功能词 bigram 过滤:「这个 / 一下 / 怎么 / 帮我」这类 bigram 几乎每句
|
|
25
|
+
* 都有,IDF 接近 0 却会撑大 avgdl,把真正有区分度的词稀释掉。
|
|
26
|
+
* 3. BM25+ delta 项(可选,默认 0):标准 BM25 对「命中但频次低」的文档给分
|
|
27
|
+
* 偏低;delta 给每个命中词一个下限分,短查询召回更稳。
|
|
28
|
+
* 参考 ratel ADR-0023 的实测:改 b 只重排已召回的(recall 不变、MRR 上升),
|
|
29
|
+
* 而 delta 是真正补召回的手段,两者不冲突。
|
|
30
|
+
* 4. k1 / b 可配置(默认仍 1.2 / 0.75,与 ratel 收敛后的取值一致)。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const K1 = 1.2;
|
|
34
|
+
const B = 0.75;
|
|
35
|
+
|
|
36
|
+
const EN_STOP = new Set([
|
|
37
|
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
|
|
38
|
+
"at", "by", "from", "as", "is", "are", "was", "were", "be", "been",
|
|
39
|
+
"it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
|
|
40
|
+
"they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
|
|
41
|
+
"will", "would", "can", "could", "should", "may", "might", "must",
|
|
42
|
+
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 中文高频功能词 bigram。这些词在几乎每个中文工具描述/查询里都出现,
|
|
47
|
+
* 信息量接近 0,留在索引里只会抬高 avgdl、稀释有区分度的词。
|
|
48
|
+
* 只过滤 bigram 层,不影响单字信息(单字本来就不进索引)。
|
|
49
|
+
*/
|
|
50
|
+
const CJK_STOP_BIGRAM = new Set([
|
|
51
|
+
"这个", "那个", "这些", "那些", "一个", "一下", "一种", "一些",
|
|
52
|
+
"什么", "怎么", "怎样", "如何", "可以", "是否", "然后", "所以",
|
|
53
|
+
"但是", "而且", "或者", "还是", "如果", "因为", "虽然", "已经",
|
|
54
|
+
"我们", "你们", "他们", "自己", "现在", "刚才", "帮我", "给我",
|
|
55
|
+
"看看", "试一下", "用的", "的是", "了的", "了吗", "的话",
|
|
56
|
+
"这里", "那里", "里面", "上面", "下面", "之后", "之前", "时候",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** camelCase / PascalCase / 数字边界拆分:searchNotes -> search, notes */
|
|
60
|
+
function splitCamel(word) {
|
|
61
|
+
if (!/[A-Z]/.test(word)) return [];
|
|
62
|
+
// 在「小写/数字 → 大写」和「连续大写 → 大写+小写」处断
|
|
63
|
+
const parts = word
|
|
64
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
65
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
66
|
+
.split(/\s+/)
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
return parts.length > 1 ? parts : [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Tokenize mixed CN/EN text into lowercase tokens. */
|
|
72
|
+
function tokenize(text) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const raw = String(text || "");
|
|
75
|
+
const lower = raw.toLowerCase();
|
|
76
|
+
|
|
77
|
+
// --- ASCII(在原文上跑,保留大小写信息以便拆驼峰)---
|
|
78
|
+
for (const m of raw.matchAll(/[A-Za-z0-9][A-Za-z0-9_+-]{1,}/g)) {
|
|
79
|
+
const word = m[0];
|
|
80
|
+
const t = word.toLowerCase();
|
|
81
|
+
if (!EN_STOP.has(t)) out.push(t);
|
|
82
|
+
|
|
83
|
+
// 驼峰拆分(v0.3.1 新增):searchNotes -> searchnotes + search + notes
|
|
84
|
+
for (const part of splitCamel(word)) {
|
|
85
|
+
const p = part.toLowerCase();
|
|
86
|
+
if (p.length > 1 && p !== t && !EN_STOP.has(p)) out.push(p);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 连字符/下划线拆分。没有这一步,工具名
|
|
90
|
+
// "mcp__open-design__start_run" 是 ONE term,查 "design" 零分。
|
|
91
|
+
// 整词保留,子词只增加召回,不动精确匹配与既有 IDF。
|
|
92
|
+
if (t.length > 2 && (t.includes("-") || t.includes("_"))) {
|
|
93
|
+
for (const part of t.split(/[-_]+/) ) {
|
|
94
|
+
if (part.length > 1 && part !== t && !EN_STOP.has(part)) out.push(part);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- CJK bigram ---
|
|
100
|
+
const cjk = lower.replace(/[^\u4e00-\u9fff]/g, "");
|
|
101
|
+
if (cjk.length >= 2) {
|
|
102
|
+
for (let i = 0; i + 2 <= cjk.length; i++) {
|
|
103
|
+
const bg = cjk.slice(i, i + 2);
|
|
104
|
+
if (!CJK_STOP_BIGRAM.has(bg)) out.push(bg);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build a BM25 index over an array of docs. Each doc: {id, text, ...}. */
|
|
111
|
+
function buildIndex(docs) {
|
|
112
|
+
const n = docs.length;
|
|
113
|
+
const docTokens = [];
|
|
114
|
+
const df = new Map(); // term -> #docs containing it
|
|
115
|
+
let totalLen = 0;
|
|
116
|
+
|
|
117
|
+
for (const doc of docs) {
|
|
118
|
+
const tokens = tokenize(doc.text);
|
|
119
|
+
docTokens.push(tokens);
|
|
120
|
+
totalLen += tokens.length;
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
for (const t of tokens) {
|
|
123
|
+
if (!seen.has(t)) {
|
|
124
|
+
seen.add(t);
|
|
125
|
+
df.set(t, (df.get(t) || 0) + 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const avgdl = n > 0 ? totalLen / n : 1;
|
|
130
|
+
|
|
131
|
+
return { n, docTokens, df, avgdl };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Score one doc's token list against the query's IDF map.
|
|
136
|
+
* opts.delta 启用 BM25+ 的 TF 下限项(默认 0 = 标准 BM25)。
|
|
137
|
+
*/
|
|
138
|
+
function score(tokens, queryIdf, n, avgdl, opts = {}) {
|
|
139
|
+
const k1 = Number.isFinite(opts.k1) ? opts.k1 : K1;
|
|
140
|
+
const b = Number.isFinite(opts.b) ? opts.b : B;
|
|
141
|
+
const delta = Number.isFinite(opts.delta) ? opts.delta : 0;
|
|
142
|
+
|
|
143
|
+
const tf = new Map();
|
|
144
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
145
|
+
let acc = 0;
|
|
146
|
+
const dl = tokens.length;
|
|
147
|
+
for (const [term, idf] of queryIdf) {
|
|
148
|
+
const f = tf.get(term);
|
|
149
|
+
if (!f) continue;
|
|
150
|
+
const sat = (f * (k1 + 1)) / (f + k1 * (1 - b + (b * dl) / avgdl));
|
|
151
|
+
acc += idf * (sat + delta);
|
|
152
|
+
}
|
|
153
|
+
return acc;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Search an index. Returns up to topK doc ids sorted by BM25 score desc.
|
|
158
|
+
* @param index result of buildIndex
|
|
159
|
+
* @param query raw query string (mixed CN/EN ok) 或已扩展的 token 数组
|
|
160
|
+
* @param topK max results (default 10)
|
|
161
|
+
* @param opts {filter: (i)=>boolean, k1, b, delta}
|
|
162
|
+
*/
|
|
163
|
+
function search(index, query, topK = 10, opts = {}) {
|
|
164
|
+
const { n, docTokens, df, avgdl } = index;
|
|
165
|
+
if (!n || !query) return [];
|
|
166
|
+
|
|
167
|
+
// 支持「已扩展的多词查询」:数组直接当 token 用,字符串走分词
|
|
168
|
+
const qTerms = Array.isArray(query) ? query : tokenize(query);
|
|
169
|
+
if (qTerms.length === 0) return [];
|
|
170
|
+
|
|
171
|
+
const queryIdf = new Map();
|
|
172
|
+
for (const t of qTerms) {
|
|
173
|
+
const d = df.get(t) || 0;
|
|
174
|
+
// BM25+ style: add 1 inside the log to keep IDF positive for unseen terms.
|
|
175
|
+
queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const scored = [];
|
|
179
|
+
for (let i = 0; i < n; i++) {
|
|
180
|
+
const s = score(docTokens[i], queryIdf, n, avgdl, opts);
|
|
181
|
+
if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
|
|
182
|
+
}
|
|
183
|
+
scored.sort((a, b2) => b2[1] - a[1]);
|
|
184
|
+
return scored.slice(0, topK).map(([i]) => i);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 带分数的版本,供 RRM / 融合类算法使用。 */
|
|
188
|
+
function searchScored(index, query, topK = 10, opts = {}) {
|
|
189
|
+
const { n, docTokens, df, avgdl } = index;
|
|
190
|
+
if (!n || !query) return [];
|
|
191
|
+
const qTerms = Array.isArray(query) ? query : tokenize(query);
|
|
192
|
+
if (qTerms.length === 0) return [];
|
|
193
|
+
const queryIdf = new Map();
|
|
194
|
+
for (const t of qTerms) {
|
|
195
|
+
const d = df.get(t) || 0;
|
|
196
|
+
queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
|
|
197
|
+
}
|
|
198
|
+
const out = [];
|
|
199
|
+
for (let i = 0; i < n; i++) {
|
|
200
|
+
const s = score(docTokens[i], queryIdf, n, avgdl, opts);
|
|
201
|
+
if (s > 0 && (!opts.filter || opts.filter(i))) out.push([i, s]);
|
|
202
|
+
}
|
|
203
|
+
out.sort((a, b2) => b2[1] - a[1]);
|
|
204
|
+
return out.slice(0, topK);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export { tokenize, buildIndex, search, searchScored, score, splitCamel, CJK_STOP_BIGRAM };
|