dsh-tool-folder 0.3.0 → 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/README.md +68 -2
- package/lib/bm25.js +101 -21
- package/lib/expand.js +276 -0
- package/lib/index.js +1261 -1167
- package/lib/rerank.js +54 -4
- 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 +56 -56
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,65 @@ 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
|
+
|
|
152
218
|
### v0.3.0 — 语义腿真正可用 + 融合修正(2026-10-17)
|
|
153
219
|
|
|
154
220
|
**动机(实测发现的真问题,不是推测)**:本机没装 Ollama(`127.0.0.1:11434` 返回
|
package/lib/bm25.js
CHANGED
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Cordis sandbox note: this module only does string/number math. It never
|
|
19
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 收敛后的取值一致)。
|
|
20
31
|
*/
|
|
21
32
|
|
|
22
33
|
const K1 = 1.2;
|
|
@@ -31,28 +42,67 @@ const EN_STOP = new Set([
|
|
|
31
42
|
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
32
43
|
]);
|
|
33
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
|
+
|
|
34
71
|
/** Tokenize mixed CN/EN text into lowercase tokens. */
|
|
35
72
|
function tokenize(text) {
|
|
36
73
|
const out = [];
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
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();
|
|
40
81
|
if (!EN_STOP.has(t)) out.push(t);
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
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。
|
|
45
92
|
if (t.length > 2 && (t.includes("-") || t.includes("_"))) {
|
|
46
|
-
for (const part of t.split(/[-_]+/)) {
|
|
93
|
+
for (const part of t.split(/[-_]+/) ) {
|
|
47
94
|
if (part.length > 1 && part !== t && !EN_STOP.has(part)) out.push(part);
|
|
48
95
|
}
|
|
49
96
|
}
|
|
50
97
|
}
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
const cjk =
|
|
98
|
+
|
|
99
|
+
// --- CJK bigram ---
|
|
100
|
+
const cjk = lower.replace(/[^\u4e00-\u9fff]/g, "");
|
|
54
101
|
if (cjk.length >= 2) {
|
|
55
|
-
for (let i = 0; i + 2 <= cjk.length; i++)
|
|
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
|
+
}
|
|
56
106
|
}
|
|
57
107
|
return out;
|
|
58
108
|
}
|
|
@@ -81,8 +131,15 @@ function buildIndex(docs) {
|
|
|
81
131
|
return { n, docTokens, df, avgdl };
|
|
82
132
|
}
|
|
83
133
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
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
|
+
|
|
86
143
|
const tf = new Map();
|
|
87
144
|
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
88
145
|
let acc = 0;
|
|
@@ -90,7 +147,8 @@ function score(tokens, queryIdf, n, avgdl) {
|
|
|
90
147
|
for (const [term, idf] of queryIdf) {
|
|
91
148
|
const f = tf.get(term);
|
|
92
149
|
if (!f) continue;
|
|
93
|
-
|
|
150
|
+
const sat = (f * (k1 + 1)) / (f + k1 * (1 - b + (b * dl) / avgdl));
|
|
151
|
+
acc += idf * (sat + delta);
|
|
94
152
|
}
|
|
95
153
|
return acc;
|
|
96
154
|
}
|
|
@@ -98,14 +156,16 @@ function score(tokens, queryIdf, n, avgdl) {
|
|
|
98
156
|
/**
|
|
99
157
|
* Search an index. Returns up to topK doc ids sorted by BM25 score desc.
|
|
100
158
|
* @param index result of buildIndex
|
|
101
|
-
* @param query raw query string (mixed CN/EN ok)
|
|
159
|
+
* @param query raw query string (mixed CN/EN ok) 或已扩展的 token 数组
|
|
102
160
|
* @param topK max results (default 10)
|
|
103
|
-
* @param opts {filter: (
|
|
161
|
+
* @param opts {filter: (i)=>boolean, k1, b, delta}
|
|
104
162
|
*/
|
|
105
163
|
function search(index, query, topK = 10, opts = {}) {
|
|
106
164
|
const { n, docTokens, df, avgdl } = index;
|
|
107
165
|
if (!n || !query) return [];
|
|
108
|
-
|
|
166
|
+
|
|
167
|
+
// 支持「已扩展的多词查询」:数组直接当 token 用,字符串走分词
|
|
168
|
+
const qTerms = Array.isArray(query) ? query : tokenize(query);
|
|
109
169
|
if (qTerms.length === 0) return [];
|
|
110
170
|
|
|
111
171
|
const queryIdf = new Map();
|
|
@@ -117,11 +177,31 @@ function search(index, query, topK = 10, opts = {}) {
|
|
|
117
177
|
|
|
118
178
|
const scored = [];
|
|
119
179
|
for (let i = 0; i < n; i++) {
|
|
120
|
-
const s = score(docTokens[i], queryIdf, n, avgdl);
|
|
180
|
+
const s = score(docTokens[i], queryIdf, n, avgdl, opts);
|
|
121
181
|
if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
|
|
122
182
|
}
|
|
123
|
-
scored.sort((a,
|
|
183
|
+
scored.sort((a, b2) => b2[1] - a[1]);
|
|
124
184
|
return scored.slice(0, topK).map(([i]) => i);
|
|
125
185
|
}
|
|
126
186
|
|
|
127
|
-
|
|
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 };
|
package/lib/expand.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* expand.js — 查询扩展(零依赖、纯本地)
|
|
3
|
+
*
|
|
4
|
+
* 解决的核心问题(2026-10-31 评测实测):
|
|
5
|
+
* 「上网查一下这个报错怎么解决」→ 期望 web_search,实得 **空**
|
|
6
|
+
* 「谁在调这个方法」 → 期望 find_references,实得 **空**
|
|
7
|
+
* 「把这个配置改成新的值」 → 期望 write/edit,实得 **空**
|
|
8
|
+
*
|
|
9
|
+
* 根因:纯字面 bigram 匹配没有同义桥接。「上网」和工具描述里的「联网」是两个
|
|
10
|
+
* 不相交的 bigram;「方法」和「符号」也是。字面不重合 = 零分 = 召回为 0。
|
|
11
|
+
*
|
|
12
|
+
* 三条互补的扩展路径:
|
|
13
|
+
*
|
|
14
|
+
* 1. 内置开发词表(thesaurus)——覆盖最高频的意图词,中英双向。
|
|
15
|
+
* 这是唯一能救「首轮零命中」的手段:RM3 需要先有结果才能反馈,
|
|
16
|
+
* 零命中时它无从下手(鸡生蛋问题)。
|
|
17
|
+
*
|
|
18
|
+
* 2. 反向别名扩展(alias 反向)——配置里的 aliases 是
|
|
19
|
+
* canonical → [同义词],只用于「工具能被哪些词命中」。这里做反查:
|
|
20
|
+
* 查询里出现某个同义词时,把 canonical 也加进查询。
|
|
21
|
+
*
|
|
22
|
+
* 3. RM3 伪相关反馈(Lavrenko & Croft 2001,Anserini / Lucene 同款思路)——
|
|
23
|
+
* 首轮 top-k 结果里抽高权重的词,与原查询加权拼接后二次检索。
|
|
24
|
+
* 只在首轮有结果时生效,用来扩大召回并纠正漂移。
|
|
25
|
+
*
|
|
26
|
+
* 设计约束:不碰 fs / net,纯字符串与数字运算;单次扩展 < 0.1ms。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 内置中英开发词表。
|
|
31
|
+
* key 是会被用户在查询里打出来的词;value 是要注入查询的扩展词
|
|
32
|
+
* (已小写、已分词形态:英文单词 + 中文 bigram)。
|
|
33
|
+
*/
|
|
34
|
+
const THESAURUS = {
|
|
35
|
+
// ---- 文件读写 ----
|
|
36
|
+
"看看": ["read", "读取", "内容", "查看"],
|
|
37
|
+
"查看": ["read", "读取", "内容"],
|
|
38
|
+
"读取": ["read", "读取"],
|
|
39
|
+
"打开": ["read", "open", "读取"],
|
|
40
|
+
"内容": ["read", "读取", "content"],
|
|
41
|
+
"写入": ["write", "写入", "保存"],
|
|
42
|
+
"保存": ["write", "保存"],
|
|
43
|
+
"改成": ["write", "edit", "修改"],
|
|
44
|
+
"修改": ["edit", "write", "修改", "替换"],
|
|
45
|
+
"替换": ["edit", "替换"],
|
|
46
|
+
"删掉": ["edit", "delete", "删除"],
|
|
47
|
+
"删除": ["delete", "remove", "删除", "移除"],
|
|
48
|
+
"移除": ["remove", "删除"],
|
|
49
|
+
|
|
50
|
+
// ---- 搜索 ----
|
|
51
|
+
"搜": ["search", "搜索", "查找", "grep"],
|
|
52
|
+
"搜索": ["search", "搜索", "查找"],
|
|
53
|
+
"查找": ["search", "find", "查找", "grep"],
|
|
54
|
+
"找": ["find", "search", "查找"],
|
|
55
|
+
"上网": ["web", "联网", "搜索", "search"],
|
|
56
|
+
"联网": ["web", "联网"],
|
|
57
|
+
"网络": ["web", "网络"],
|
|
58
|
+
"网页": ["web", "fetch", "抓取"],
|
|
59
|
+
"抓": ["fetch", "抓取"],
|
|
60
|
+
|
|
61
|
+
// ---- 代码符号 ----
|
|
62
|
+
"方法": ["symbol", "function", "符号", "定义"],
|
|
63
|
+
"函数": ["symbol", "function", "符号"],
|
|
64
|
+
"符号": ["symbol", "符号"],
|
|
65
|
+
"定义": ["symbol", "definition", "定义"],
|
|
66
|
+
"调用": ["references", "caller", "引用"],
|
|
67
|
+
"引用": ["references", "引用"],
|
|
68
|
+
"调": ["references", "call", "引用"],
|
|
69
|
+
"谁": ["references", "caller", "引用"],
|
|
70
|
+
|
|
71
|
+
// ---- Git ----
|
|
72
|
+
"改动": ["diff", "status", "差异", "改动"],
|
|
73
|
+
"改了": ["diff", "edit", "改动"],
|
|
74
|
+
"改": ["edit", "write", "diff", "修改", "改动"],
|
|
75
|
+
"行": ["line", "diff", "行"],
|
|
76
|
+
"哪些行": ["diff", "line"],
|
|
77
|
+
"差异": ["diff", "差异"],
|
|
78
|
+
"提交": ["commit", "提交"],
|
|
79
|
+
"历史": ["log", "history", "历史"],
|
|
80
|
+
"分支": ["branch", "分支"],
|
|
81
|
+
"工作区": ["status", "工作区"],
|
|
82
|
+
|
|
83
|
+
// ---- 安全 ----
|
|
84
|
+
"安全": ["security", "安全", "semgrep", "审计"],
|
|
85
|
+
"漏洞": ["vulnerability", "security", "漏洞"],
|
|
86
|
+
"审计": ["audit", "审计"],
|
|
87
|
+
"扫描": ["scan", "扫描"],
|
|
88
|
+
|
|
89
|
+
// ---- 笔记 / 知识 ----
|
|
90
|
+
"笔记": ["note", "笔记", "notes"],
|
|
91
|
+
"文档": ["note", "document", "文档"],
|
|
92
|
+
"资料": ["document", "资料", "笔记"],
|
|
93
|
+
"记一条": ["note", "create", "新建"],
|
|
94
|
+
|
|
95
|
+
// ---- 交互 / 杂项 ----
|
|
96
|
+
"报错": ["error", "错误", "异常"],
|
|
97
|
+
"错误": ["error", "错误"],
|
|
98
|
+
"异常": ["error", "异常", "exception"],
|
|
99
|
+
"解决": ["error", "fix", "解决"],
|
|
100
|
+
"问": ["ask", "question", "提问"],
|
|
101
|
+
"截屏": ["screenshot", "截图", "screen"],
|
|
102
|
+
"截图": ["screenshot", "截图"],
|
|
103
|
+
"截": ["screenshot", "截屏"],
|
|
104
|
+
"屏": ["screenshot", "screen", "截屏"],
|
|
105
|
+
"个屏": ["screenshot", "截屏"],
|
|
106
|
+
"剪贴板": ["clipboard", "剪贴板"],
|
|
107
|
+
"复制": ["clipboard", "复制"],
|
|
108
|
+
"讲": ["explain", "讲解", "概念"],
|
|
109
|
+
"讲讲": ["explain", "讲解"],
|
|
110
|
+
"概念": ["explain", "concept", "概念"],
|
|
111
|
+
"浏览器": ["browser", "navigate", "浏览器"],
|
|
112
|
+
"网址": ["navigate", "url", "网址"],
|
|
113
|
+
"按钮": ["click", "点击", "按钮"],
|
|
114
|
+
"点击": ["click", "点击"],
|
|
115
|
+
"画布": ["canvas", "frame", "画布"],
|
|
116
|
+
"画框": ["frame", "画框"],
|
|
117
|
+
"技能": ["skill", "技能"],
|
|
118
|
+
"交给": ["delegate", "委派"],
|
|
119
|
+
"委派": ["delegate", "委派"],
|
|
120
|
+
"拆开": ["subagent", "delegate", "并行"],
|
|
121
|
+
"并行": ["subagent", "并行"],
|
|
122
|
+
"跑": ["exec", "run", "执行", "命令"],
|
|
123
|
+
"执行": ["exec", "执行", "命令"],
|
|
124
|
+
"命令": ["exec", "command", "命令"],
|
|
125
|
+
"想想": ["thinking", "reason", "推理"],
|
|
126
|
+
"一步步": ["thinking", "step", "推理"],
|
|
127
|
+
"影响": ["impact", "影响", "波及"],
|
|
128
|
+
"文件": ["file", "文件"],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** 从中文/英文原文里抽候选词(供词表查表用,不做 IDF 计算) */
|
|
132
|
+
function probeTerms(text) {
|
|
133
|
+
const out = new Set();
|
|
134
|
+
const raw = String(text || "");
|
|
135
|
+
const lower = raw.toLowerCase();
|
|
136
|
+
|
|
137
|
+
for (const m of raw.matchAll(/[A-Za-z0-9][A-Za-z0-9_+-]{1,}/g)) {
|
|
138
|
+
out.add(m[0].toLowerCase());
|
|
139
|
+
}
|
|
140
|
+
const cjk = lower.replace(/[^\u4e00-\u9fff]/g, "");
|
|
141
|
+
// 单字 + bigram 都作为探针,词表的 key 以 1~3 字为主
|
|
142
|
+
for (let i = 0; i < cjk.length; i++) {
|
|
143
|
+
out.add(cjk[i]);
|
|
144
|
+
if (i + 1 < cjk.length) out.add(cjk.slice(i, i + 2));
|
|
145
|
+
if (i + 2 < cjk.length) out.add(cjk.slice(i, i + 3));
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 用内置词表 + 反向别名扩展查询。
|
|
152
|
+
* @param {string} query
|
|
153
|
+
* @param {{aliases?: object, extra?: object}} [opts]
|
|
154
|
+
* @returns {string[]} 扩展后的 token 数组(可直接喂 bm25.search)
|
|
155
|
+
*/
|
|
156
|
+
function expandQuery(query, opts = {}) {
|
|
157
|
+
const probes = probeTerms(query);
|
|
158
|
+
const added = new Set();
|
|
159
|
+
|
|
160
|
+
// 1) 内置词表
|
|
161
|
+
for (const p of probes) {
|
|
162
|
+
const syn = THESAURUS[p];
|
|
163
|
+
if (syn) for (const s of syn) added.add(s);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 2) 反向别名:aliases = { canonical: [syn...] }
|
|
167
|
+
// 查询里出现某个 syn → 把 canonical 加进来
|
|
168
|
+
const aliases = opts.aliases || {};
|
|
169
|
+
for (const [canonical, syns] of Object.entries(aliases)) {
|
|
170
|
+
if (!Array.isArray(syns)) continue;
|
|
171
|
+
const canon = String(canonical).toLowerCase();
|
|
172
|
+
for (const s of syns) {
|
|
173
|
+
const syn = String(s).toLowerCase();
|
|
174
|
+
if (probes.has(syn)) {
|
|
175
|
+
added.add(canon);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3) 额外词表(调用方可注入)
|
|
182
|
+
for (const p of probes) {
|
|
183
|
+
const syn = (opts.extra || {})[p];
|
|
184
|
+
if (syn) for (const s of syn) added.add(s);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return [...added];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* RM3 伪相关反馈检索。
|
|
192
|
+
*
|
|
193
|
+
* 首轮用原查询取 top-k,从这些文档里按 tf·idf 抽 top-N 词,
|
|
194
|
+
* 与原查询按 mu 加权融合后二次检索。
|
|
195
|
+
* 首轮零命中时直接返回空(RM3 无从反馈)。
|
|
196
|
+
*
|
|
197
|
+
* @param index bm25.buildIndex 的产物
|
|
198
|
+
* @param query 原始查询字符串
|
|
199
|
+
* @param bm25 bm25 模块(要能拿到 tokenize / score)
|
|
200
|
+
* @param {{topK?:number, fbDocs?:number, fbTerms?:number, mu?:number, expanded?:string[]}} opts
|
|
201
|
+
* @returns {number[]} doc 索引数组
|
|
202
|
+
*/
|
|
203
|
+
function searchWithFeedback(index, query, bm25, opts = {}) {
|
|
204
|
+
const topK = opts.topK ?? 6;
|
|
205
|
+
const fbDocs = opts.fbDocs ?? 5;
|
|
206
|
+
const fbTerms = opts.fbTerms ?? 8;
|
|
207
|
+
const mu = opts.mu ?? 0.65; // 原查询权重
|
|
208
|
+
|
|
209
|
+
const { n, docTokens, df, avgdl } = index;
|
|
210
|
+
if (!n) return [];
|
|
211
|
+
|
|
212
|
+
const orig = bm25.tokenize(query);
|
|
213
|
+
if (!orig.length) return [];
|
|
214
|
+
|
|
215
|
+
// 若调用方已给定扩展词,直接并进原查询
|
|
216
|
+
const baseTerms = orig.concat(opts.expanded || []);
|
|
217
|
+
const first = bm25.searchScored
|
|
218
|
+
? bm25.searchScored(index, baseTerms, topK)
|
|
219
|
+
: bm25.search(index, baseTerms, topK).map(i => [i, 0]);
|
|
220
|
+
|
|
221
|
+
if (!first.length) return [];
|
|
222
|
+
|
|
223
|
+
// ---- 从 top 文档抽反馈词 ----
|
|
224
|
+
const fb = new Map(); // term -> weight
|
|
225
|
+
const topDocs = first.slice(0, fbDocs);
|
|
226
|
+
let totalScore = 0;
|
|
227
|
+
for (const [, s] of topDocs) totalScore += s;
|
|
228
|
+
|
|
229
|
+
for (const [di, s] of topDocs) {
|
|
230
|
+
const docWeight = totalScore > 0 ? s / totalScore : 1 / topDocs.length;
|
|
231
|
+
const tf = new Map();
|
|
232
|
+
for (const t of docTokens[di]) tf.set(t, (tf.get(t) || 0) + 1);
|
|
233
|
+
const seen = new Set(orig);
|
|
234
|
+
for (const [t, f] of tf) {
|
|
235
|
+
if (seen.has(t)) continue; // 原查询已有的词不重复加
|
|
236
|
+
const d = df.get(t) || 0;
|
|
237
|
+
const idf = Math.log((n - d + 0.5) / (d + 0.5) + 1);
|
|
238
|
+
fb.set(t, (fb.get(t) || 0) + docWeight * f * idf);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const expansion = [...fb.entries()]
|
|
243
|
+
.sort((a, b) => b[1] - a[1])
|
|
244
|
+
.slice(0, fbTerms)
|
|
245
|
+
.map(([t]) => t);
|
|
246
|
+
|
|
247
|
+
if (!expansion.length) return first.map(([i]) => i);
|
|
248
|
+
|
|
249
|
+
// ---- 加权融合:原查询词权重 mu,反馈词权重 (1-mu) 均分 ----
|
|
250
|
+
// 注意:不能靠「重复词」加权——search 内部用 Map 按词去重,重复无效。
|
|
251
|
+
// 这里自己构造 queryIdf,把权重直接乘进 idf。
|
|
252
|
+
const queryIdf = new Map();
|
|
253
|
+
const origSet = new Set(baseTerms);
|
|
254
|
+
for (const t of origSet) {
|
|
255
|
+
const d = df.get(t) || 0;
|
|
256
|
+
const idf = Math.log((n - d + 0.5) / (d + 0.5) + 1);
|
|
257
|
+
queryIdf.set(t, idf * mu);
|
|
258
|
+
}
|
|
259
|
+
const perTerm = (1 - mu) / Math.max(1, expansion.length);
|
|
260
|
+
for (const t of expansion) {
|
|
261
|
+
const d = df.get(t) || 0;
|
|
262
|
+
const idf = Math.log((n - d + 0.5) / (d + 0.5) + 1);
|
|
263
|
+
const prev = queryIdf.get(t) || 0;
|
|
264
|
+
queryIdf.set(t, prev + idf * perTerm);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const out = [];
|
|
268
|
+
for (let i = 0; i < n; i++) {
|
|
269
|
+
const s = bm25.score(docTokens[i], queryIdf, n, avgdl);
|
|
270
|
+
if (s > 0) out.push([i, s]);
|
|
271
|
+
}
|
|
272
|
+
out.sort((a, b) => b[1] - a[1]);
|
|
273
|
+
return out.slice(0, topK).map(([i]) => i);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export { expandQuery, searchWithFeedback, probeTerms, THESAURUS };
|