dsh-tool-folder 0.2.5 → 0.3.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/LICENSE +21 -0
- package/README.md +41 -0
- package/lib/bm25.js +127 -127
- package/lib/embed-remote.js +193 -0
- package/lib/index.js +218 -34
- package/lib/rerank.js +115 -0
- package/lib/semantic.js +273 -102
- package/package.json +56 -48
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
|
@@ -149,6 +149,47 @@ were never unregistered, so nothing else changes.
|
|
|
149
149
|
|
|
150
150
|
## Changelog
|
|
151
151
|
|
|
152
|
+
### v0.3.0 — 语义腿真正可用 + 融合修正(2026-10-17)
|
|
153
|
+
|
|
154
|
+
**动机(实测发现的真问题,不是推测)**:本机没装 Ollama(`127.0.0.1:11434` 返回
|
|
155
|
+
HTTP 000),`embedText` 恒为 null → `tools_search` 实际一直是**纯 BM25**。在 43 个
|
|
156
|
+
真实工具(`tools-real.json`)、18 条真实中文意图上测:**Hit@1 只有 6/18(33%)**。
|
|
157
|
+
|
|
158
|
+
1. **远程 embedding 腿(`lib/embed-remote.js` 新增)**:OpenAI 兼容端点,默认火山
|
|
159
|
+
`doubao-embedding-vision`(2048 维),key 读 `DSH_EMBED_KEY` / `OPENVIKING_ARK_KEY`。
|
|
160
|
+
带熔断(连续 3 次失败 → 5 分钟冷却)+ 批量(4 条/批、2 并发、429 退避 0.8/2/5s)。
|
|
161
|
+
纯语义 Hit@1 **11/18(61%)**。
|
|
162
|
+
2. **加权 RRF + 自适应 BM25 权重**:语义腿默认权重 3,BM25 默认 0.25;
|
|
163
|
+
若查询里的 ASCII 词命中 BM25 首条的名称/描述(真实词匹配、工具名、server 名),
|
|
164
|
+
BM25 权重回升到 1。实测 1:1 融合只有 8/18,加权后 **11/18**。
|
|
165
|
+
3. **重复 id 修复(关键)**:语料里同名工具出现两次时,旧 RRF 会把同一 id 的分累加
|
|
166
|
+
两次——`list_sessions` / `get_config` 各两份,直接把正确项从第一顶到第三以外
|
|
167
|
+
(纯语义 11/18 被拉到 2/18)。现在 bm25/语义两腿都按 id 去重,组装侧也先按名去重。
|
|
168
|
+
4. **索引可用性修复**:旧实现要求**全部** doc 成功才落缓存(一次失败就永远重建),
|
|
169
|
+
且 60s 硬截止串行调用;改为批量并发 + **部分成功也落盘** + 覆盖率 ≥80% 即可用。
|
|
170
|
+
5. **缓存路径修正**:`~/.dsh/state/` → 优先 `$DSH_HOME/state/`(DSH_HOME 指向
|
|
171
|
+
`.dsh-community` 时旧实现会把缓存写到另一个目录)。缓存身份键带 provider,
|
|
172
|
+
远程/本地切换不会复用错向量。
|
|
173
|
+
6. **索引不再阻塞**:在 assemble 后台构建,`tools_search` 绝不等待冷启动(旧实现
|
|
174
|
+
首次搜索可能卡 30s+);查询向量有内存缓存 + 15s 节流后台预热。
|
|
175
|
+
7. **正文精排(`lib/rerank.js` 新增,默认关)**:借鉴 SkillRouter 的两阶段检索,
|
|
176
|
+
在候选集内用「完整描述 + 参数名 + 参数描述 + 枚举」重排。**实测在本语料上
|
|
177
|
+
Hit@1 持平、Hit@3 反而 -1**(15→14),所以默认关闭,`bodyRerank: true` 可开。
|
|
178
|
+
8. **语义腿参与自动注入**(`semanticDynamic`,默认开):只用已缓存的查询向量,
|
|
179
|
+
零额外延迟;命中就把结果并进动态段。
|
|
180
|
+
9. `debugDump`:把当前工具语料 dump 成 JSON,供离线评测复现。
|
|
181
|
+
10. 测试 114 → **123 条**,全绿;新增 `scripts/eval-tools.mjs` 离线评测脚本。
|
|
182
|
+
|
|
183
|
+
**实测汇总(43 工具 / 18 中文意图)**
|
|
184
|
+
|
|
185
|
+
| 方案 | Hit@1 | Hit@3 |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| BM25(v0.2.5 实际状态) | 6/18 | 8/18 |
|
|
188
|
+
| RRF 1:1 | 8/18 | 12/18 |
|
|
189
|
+
| 语义单腿(参考上限) | 11/18 | 15/18 |
|
|
190
|
+
| **v0.3.0 默认(语义3 : BM25 0.25 自适应)** | **11/18** | **15/18** |
|
|
191
|
+
| 再加正文精排 0.35 | 11/18 | 14/18 |
|
|
192
|
+
|
|
152
193
|
### v0.2.5 — red-team firewall fixes + retrieval hardening (2026-09-02)
|
|
153
194
|
1. **Exec-tool recognition (red-team F1)** — bare-name matching let MCP-prefixed
|
|
154
195
|
exec tools (`mcp__windows__Cmd`) bypass the high-risk gate; `isExecTool` now
|
package/lib/bm25.js
CHANGED
|
@@ -1,127 +1,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.
|
|
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
|
-
const K1 = 1.2;
|
|
23
|
-
const B = 0.75;
|
|
24
|
-
|
|
25
|
-
const EN_STOP = new Set([
|
|
26
|
-
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
|
|
27
|
-
"at", "by", "from", "as", "is", "are", "was", "were", "be", "been",
|
|
28
|
-
"it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
|
|
29
|
-
"they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
|
|
30
|
-
"will", "would", "can", "could", "should", "may", "might", "must",
|
|
31
|
-
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
/** Tokenize mixed CN/EN text into lowercase tokens. */
|
|
35
|
-
function tokenize(text) {
|
|
36
|
-
const out = [];
|
|
37
|
-
const s = String(text || "").toLowerCase();
|
|
38
|
-
for (const m of s.matchAll(/[a-z0-9][a-z0-9_+-]{1,}/g)) {
|
|
39
|
-
const t = m[0];
|
|
40
|
-
if (!EN_STOP.has(t)) out.push(t);
|
|
41
|
-
// Sub-words of hyphen/underscore compounds. Without this, a tool name like
|
|
42
|
-
// "mcp__open-design__start_run" is ONE term, so querying "design" scores
|
|
43
|
-
// zero. The whole token is kept too, so exact-name matches and existing
|
|
44
|
-
// IDF are unchanged; sub-words only ADD recall.
|
|
45
|
-
if (t.length > 2 && (t.includes("-") || t.includes("_"))) {
|
|
46
|
-
for (const part of t.split(/[-_]+/)) {
|
|
47
|
-
if (part.length > 1 && part !== t && !EN_STOP.has(part)) out.push(part);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
// CJK bigrams: "开源框架" -> "开源","源框","框架". Bigrams beat single
|
|
52
|
-
// chars for short tool descriptions and are dependency-free.
|
|
53
|
-
const cjk = s.replace(/[^\u4e00-\u9fff]/g, "");
|
|
54
|
-
if (cjk.length >= 2) {
|
|
55
|
-
for (let i = 0; i + 2 <= cjk.length; i++) out.push(cjk.slice(i, i + 2));
|
|
56
|
-
}
|
|
57
|
-
return out;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Build a BM25 index over an array of docs. Each doc: {id, text, ...}. */
|
|
61
|
-
function buildIndex(docs) {
|
|
62
|
-
const n = docs.length;
|
|
63
|
-
const docTokens = [];
|
|
64
|
-
const df = new Map(); // term -> #docs containing it
|
|
65
|
-
let totalLen = 0;
|
|
66
|
-
|
|
67
|
-
for (const doc of docs) {
|
|
68
|
-
const tokens = tokenize(doc.text);
|
|
69
|
-
docTokens.push(tokens);
|
|
70
|
-
totalLen += tokens.length;
|
|
71
|
-
const seen = new Set();
|
|
72
|
-
for (const t of tokens) {
|
|
73
|
-
if (!seen.has(t)) {
|
|
74
|
-
seen.add(t);
|
|
75
|
-
df.set(t, (df.get(t) || 0) + 1);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const avgdl = n > 0 ? totalLen / n : 1;
|
|
80
|
-
|
|
81
|
-
return { n, docTokens, df, avgdl };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Score one doc's token list against the query's IDF map. */
|
|
85
|
-
function score(tokens, queryIdf, n, avgdl) {
|
|
86
|
-
const tf = new Map();
|
|
87
|
-
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
88
|
-
let acc = 0;
|
|
89
|
-
const dl = tokens.length;
|
|
90
|
-
for (const [term, idf] of queryIdf) {
|
|
91
|
-
const f = tf.get(term);
|
|
92
|
-
if (!f) continue;
|
|
93
|
-
acc += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * dl) / avgdl)));
|
|
94
|
-
}
|
|
95
|
-
return acc;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Search an index. Returns up to topK doc ids sorted by BM25 score desc.
|
|
100
|
-
* @param index result of buildIndex
|
|
101
|
-
* @param query raw query string (mixed CN/EN ok)
|
|
102
|
-
* @param topK max results (default 10)
|
|
103
|
-
* @param opts {filter: (docId) => boolean}
|
|
104
|
-
*/
|
|
105
|
-
function search(index, query, topK = 10, opts = {}) {
|
|
106
|
-
const { n, docTokens, df, avgdl } = index;
|
|
107
|
-
if (!n || !query) return [];
|
|
108
|
-
const qTerms = tokenize(query);
|
|
109
|
-
if (qTerms.length === 0) return [];
|
|
110
|
-
|
|
111
|
-
const queryIdf = new Map();
|
|
112
|
-
for (const t of qTerms) {
|
|
113
|
-
const d = df.get(t) || 0;
|
|
114
|
-
// BM25+ style: add 1 inside the log to keep IDF positive for unseen terms.
|
|
115
|
-
queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const scored = [];
|
|
119
|
-
for (let i = 0; i < n; i++) {
|
|
120
|
-
const s = score(docTokens[i], queryIdf, n, avgdl);
|
|
121
|
-
if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
|
|
122
|
-
}
|
|
123
|
-
scored.sort((a, b) => b[1] - a[1]);
|
|
124
|
-
return scored.slice(0, topK).map(([i]) => i);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export { tokenize, buildIndex, search, score };
|
|
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
|
+
|
|
22
|
+
const K1 = 1.2;
|
|
23
|
+
const B = 0.75;
|
|
24
|
+
|
|
25
|
+
const EN_STOP = new Set([
|
|
26
|
+
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
|
|
27
|
+
"at", "by", "from", "as", "is", "are", "was", "were", "be", "been",
|
|
28
|
+
"it", "this", "that", "these", "those", "i", "you", "he", "she", "we",
|
|
29
|
+
"they", "do", "does", "did", "have", "has", "had", "not", "no", "yes",
|
|
30
|
+
"will", "would", "can", "could", "should", "may", "might", "must",
|
|
31
|
+
"your", "my", "our", "their", "its", "all", "any", "some", "each",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Tokenize mixed CN/EN text into lowercase tokens. */
|
|
35
|
+
function tokenize(text) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const s = String(text || "").toLowerCase();
|
|
38
|
+
for (const m of s.matchAll(/[a-z0-9][a-z0-9_+-]{1,}/g)) {
|
|
39
|
+
const t = m[0];
|
|
40
|
+
if (!EN_STOP.has(t)) out.push(t);
|
|
41
|
+
// Sub-words of hyphen/underscore compounds. Without this, a tool name like
|
|
42
|
+
// "mcp__open-design__start_run" is ONE term, so querying "design" scores
|
|
43
|
+
// zero. The whole token is kept too, so exact-name matches and existing
|
|
44
|
+
// IDF are unchanged; sub-words only ADD recall.
|
|
45
|
+
if (t.length > 2 && (t.includes("-") || t.includes("_"))) {
|
|
46
|
+
for (const part of t.split(/[-_]+/)) {
|
|
47
|
+
if (part.length > 1 && part !== t && !EN_STOP.has(part)) out.push(part);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// CJK bigrams: "开源框架" -> "开源","源框","框架". Bigrams beat single
|
|
52
|
+
// chars for short tool descriptions and are dependency-free.
|
|
53
|
+
const cjk = s.replace(/[^\u4e00-\u9fff]/g, "");
|
|
54
|
+
if (cjk.length >= 2) {
|
|
55
|
+
for (let i = 0; i + 2 <= cjk.length; i++) out.push(cjk.slice(i, i + 2));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Build a BM25 index over an array of docs. Each doc: {id, text, ...}. */
|
|
61
|
+
function buildIndex(docs) {
|
|
62
|
+
const n = docs.length;
|
|
63
|
+
const docTokens = [];
|
|
64
|
+
const df = new Map(); // term -> #docs containing it
|
|
65
|
+
let totalLen = 0;
|
|
66
|
+
|
|
67
|
+
for (const doc of docs) {
|
|
68
|
+
const tokens = tokenize(doc.text);
|
|
69
|
+
docTokens.push(tokens);
|
|
70
|
+
totalLen += tokens.length;
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const t of tokens) {
|
|
73
|
+
if (!seen.has(t)) {
|
|
74
|
+
seen.add(t);
|
|
75
|
+
df.set(t, (df.get(t) || 0) + 1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const avgdl = n > 0 ? totalLen / n : 1;
|
|
80
|
+
|
|
81
|
+
return { n, docTokens, df, avgdl };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Score one doc's token list against the query's IDF map. */
|
|
85
|
+
function score(tokens, queryIdf, n, avgdl) {
|
|
86
|
+
const tf = new Map();
|
|
87
|
+
for (const t of tokens) tf.set(t, (tf.get(t) || 0) + 1);
|
|
88
|
+
let acc = 0;
|
|
89
|
+
const dl = tokens.length;
|
|
90
|
+
for (const [term, idf] of queryIdf) {
|
|
91
|
+
const f = tf.get(term);
|
|
92
|
+
if (!f) continue;
|
|
93
|
+
acc += idf * ((f * (K1 + 1)) / (f + K1 * (1 - B + (B * dl) / avgdl)));
|
|
94
|
+
}
|
|
95
|
+
return acc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Search an index. Returns up to topK doc ids sorted by BM25 score desc.
|
|
100
|
+
* @param index result of buildIndex
|
|
101
|
+
* @param query raw query string (mixed CN/EN ok)
|
|
102
|
+
* @param topK max results (default 10)
|
|
103
|
+
* @param opts {filter: (docId) => boolean}
|
|
104
|
+
*/
|
|
105
|
+
function search(index, query, topK = 10, opts = {}) {
|
|
106
|
+
const { n, docTokens, df, avgdl } = index;
|
|
107
|
+
if (!n || !query) return [];
|
|
108
|
+
const qTerms = tokenize(query);
|
|
109
|
+
if (qTerms.length === 0) return [];
|
|
110
|
+
|
|
111
|
+
const queryIdf = new Map();
|
|
112
|
+
for (const t of qTerms) {
|
|
113
|
+
const d = df.get(t) || 0;
|
|
114
|
+
// BM25+ style: add 1 inside the log to keep IDF positive for unseen terms.
|
|
115
|
+
queryIdf.set(t, Math.log((n - d + 0.5) / (d + 0.5) + 1));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const scored = [];
|
|
119
|
+
for (let i = 0; i < n; i++) {
|
|
120
|
+
const s = score(docTokens[i], queryIdf, n, avgdl);
|
|
121
|
+
if (s > 0 && (!opts.filter || opts.filter(i))) scored.push([i, s]);
|
|
122
|
+
}
|
|
123
|
+
scored.sort((a, b) => b[1] - a[1]);
|
|
124
|
+
return scored.slice(0, topK).map(([i]) => i);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { tokenize, buildIndex, search, score };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* embed-remote.js — 远程 OpenAI 兼容 embedding(默认火山方舟 doubao-embedding-vision)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(2026-10-17 实测):v0.2.x 的语义腿只走本地 Ollama bge-m3,
|
|
5
|
+
* 而本机 `127.0.0.1:11434` 返回 HTTP 000(Ollama 未安装)→ `embedText` 恒为 null
|
|
6
|
+
* → `tools_search` 实际退化为纯 BM25。中文意图打英文工具描述时经常零命中。
|
|
7
|
+
*
|
|
8
|
+
* 设计要点(与 dsh-skill-folder v0.4.0 的同名模块同源):
|
|
9
|
+
* - 批量:OpenAI 兼容端点支持 `input: string[]`,一次 4 条(火山限流下的稳定值)
|
|
10
|
+
* - 并发:2 路并发,批间 250ms 节流;429/5xx 退避重试(0.8s / 2s / 5s)
|
|
11
|
+
* - 熔断:连续 3 次失败 → 冷却 5 分钟,期间 `remoteEmbedAvailable()` 为 false,
|
|
12
|
+
* 调用方自动回落到 Ollama / 纯 BM25
|
|
13
|
+
* - 任何失败都返回 null/数组中的 null,绝不抛
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DEFAULT_BASE = "https://ark.cn-beijing.volces.com/api/plan/v3";
|
|
17
|
+
const DEFAULT_MODEL = "doubao-embedding-vision";
|
|
18
|
+
const DEFAULT_TIMEOUT = 15000;
|
|
19
|
+
const FAIL_THRESHOLD = 3;
|
|
20
|
+
const COOLDOWN_MS = 5 * 60 * 1000;
|
|
21
|
+
const DEFAULT_BATCH = 4;
|
|
22
|
+
const DEFAULT_CONCURRENCY = 2;
|
|
23
|
+
const BATCH_GAP_MS = 250;
|
|
24
|
+
const BACKOFF_MS = [800, 2000, 5000];
|
|
25
|
+
|
|
26
|
+
let _base = process.env.DSH_EMBED_BASE || DEFAULT_BASE;
|
|
27
|
+
let _model = process.env.DSH_EMBED_MODEL || DEFAULT_MODEL;
|
|
28
|
+
let _key = process.env.DSH_EMBED_KEY || process.env.OPENVIKING_ARK_KEY || "";
|
|
29
|
+
let _timeout = DEFAULT_TIMEOUT;
|
|
30
|
+
let _fails = 0;
|
|
31
|
+
let _disabledUntil = 0;
|
|
32
|
+
|
|
33
|
+
export function configureRemoteEmbed(cfg = {}) {
|
|
34
|
+
if (!cfg || typeof cfg !== "object") return;
|
|
35
|
+
if (typeof cfg.apiBase === "string" && cfg.apiBase) _base = cfg.apiBase;
|
|
36
|
+
if (typeof cfg.model === "string" && cfg.model) _model = cfg.model;
|
|
37
|
+
if (typeof cfg.apiKey === "string") _key = cfg.apiKey; // 允许显式空串禁用(测试用)
|
|
38
|
+
if (Number.isFinite(Number(cfg.timeoutMs)) && Number(cfg.timeoutMs) >= 100) _timeout = Number(cfg.timeoutMs);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function remoteEmbedAvailable() {
|
|
42
|
+
return Boolean(_key) && Date.now() >= _disabledUntil;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function remoteEmbedState() {
|
|
46
|
+
return {
|
|
47
|
+
available: remoteEmbedAvailable(),
|
|
48
|
+
base: _base,
|
|
49
|
+
model: _model,
|
|
50
|
+
fails: _fails,
|
|
51
|
+
disabledUntil: _disabledUntil,
|
|
52
|
+
hasKey: Boolean(_key),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function trip(reason) {
|
|
57
|
+
_fails += 1;
|
|
58
|
+
if (_fails >= FAIL_THRESHOLD && Date.now() >= _disabledUntil) {
|
|
59
|
+
_disabledUntil = Date.now() + COOLDOWN_MS;
|
|
60
|
+
try {
|
|
61
|
+
console.log(`[tool-folder] remote embedding 熔断(${reason}),${COOLDOWN_MS / 1000}s 内只用本地/词法腿`);
|
|
62
|
+
} catch {
|
|
63
|
+
/* 日志失败不影响功能 */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function reset() {
|
|
69
|
+
_fails = 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
73
|
+
|
|
74
|
+
/** 一次 HTTP 调用,input 可以是单条字符串或字符串数组。 */
|
|
75
|
+
async function callOnce(input, timeoutMs) {
|
|
76
|
+
const ctrl = new AbortController();
|
|
77
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`${_base.replace(/\/+$/, "")}/embeddings`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${_key}` },
|
|
82
|
+
body: JSON.stringify({
|
|
83
|
+
model: _model,
|
|
84
|
+
input,
|
|
85
|
+
encoding_format: "float",
|
|
86
|
+
}),
|
|
87
|
+
signal: ctrl.signal,
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const body = await res.text().catch(() => "");
|
|
91
|
+
const err = new Error(`HTTP ${res.status} ${body.slice(0, 120)}`);
|
|
92
|
+
err.retryable = res.status === 429 || res.status >= 500;
|
|
93
|
+
err.status = res.status;
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
const j = await res.json();
|
|
97
|
+
const rows = Array.isArray(j?.data) ? j.data : [];
|
|
98
|
+
if (!rows.length) throw new Error("empty embedding response");
|
|
99
|
+
// 按 index 对齐(端点不保证顺序)
|
|
100
|
+
const out = rows
|
|
101
|
+
.slice()
|
|
102
|
+
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
103
|
+
.map((r) => (Array.isArray(r?.embedding) && r.embedding.length ? r.embedding.map(Number) : null));
|
|
104
|
+
if (!out.some(Boolean)) throw new Error("no usable embedding");
|
|
105
|
+
return out;
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 单条文本 → 向量;失败返回 null(绝不抛)。 */
|
|
112
|
+
export async function remoteEmbed(text, timeoutMs = _timeout) {
|
|
113
|
+
const s = String(text || "").trim();
|
|
114
|
+
if (!s || !remoteEmbedAvailable()) return null;
|
|
115
|
+
for (let attempt = 0; attempt <= BACKOFF_MS.length; attempt++) {
|
|
116
|
+
try {
|
|
117
|
+
const [v] = await callOnce(s.slice(0, 6000) || " ", timeoutMs);
|
|
118
|
+
if (Array.isArray(v) && v.length) {
|
|
119
|
+
reset();
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
trip("empty embedding");
|
|
123
|
+
return null;
|
|
124
|
+
} catch (e) {
|
|
125
|
+
const retryable = e?.retryable || e?.name === "AbortError";
|
|
126
|
+
if (retryable && attempt < BACKOFF_MS.length) {
|
|
127
|
+
await sleep(BACKOFF_MS[attempt]);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
trip(`${e?.status || e?.name || "error"}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 批量 embedding。返回与 texts 等长的数组(失败的槽位是 null)。
|
|
139
|
+
* 批内失败会自动拆成单条重试,尽力保住能拿到的向量。
|
|
140
|
+
*
|
|
141
|
+
* @param {string[]} texts
|
|
142
|
+
* @param {{timeoutMs?: number, batchSize?: number, concurrency?: number, onProgress?: Function}} [opts]
|
|
143
|
+
* @returns {Promise<Array<number[]|null>>}
|
|
144
|
+
*/
|
|
145
|
+
export async function remoteEmbedBatch(texts, opts = {}) {
|
|
146
|
+
const list = Array.isArray(texts) ? texts : [];
|
|
147
|
+
const out = new Array(list.length).fill(null);
|
|
148
|
+
if (!list.length || !remoteEmbedAvailable()) return out;
|
|
149
|
+
|
|
150
|
+
const timeoutMs = Number(opts.timeoutMs) || _timeout;
|
|
151
|
+
const batchSize = Math.min(Math.max(Number(opts.batchSize) || DEFAULT_BATCH, 1), 16);
|
|
152
|
+
const concurrency = Math.min(Math.max(Number(opts.concurrency) || DEFAULT_CONCURRENCY, 1), 4);
|
|
153
|
+
|
|
154
|
+
const batches = [];
|
|
155
|
+
for (let i = 0; i < list.length; i += batchSize) {
|
|
156
|
+
batches.push({ start: i, items: list.slice(i, i + batchSize) });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let cursor = 0;
|
|
160
|
+
const worker = async () => {
|
|
161
|
+
while (cursor < batches.length) {
|
|
162
|
+
const b = batches[cursor++];
|
|
163
|
+
if (!remoteEmbedAvailable()) return; // 熔断后立刻停手
|
|
164
|
+
const payload = b.items.map((t) => String(t || "").trim().slice(0, 6000) || " ");
|
|
165
|
+
try {
|
|
166
|
+
const vecs = await callOnce(payload, timeoutMs);
|
|
167
|
+
reset();
|
|
168
|
+
for (let k = 0; k < vecs.length && k < b.items.length; k++) {
|
|
169
|
+
if (vecs[k]) out[b.start + k] = vecs[k];
|
|
170
|
+
}
|
|
171
|
+
} catch (e) {
|
|
172
|
+
trip(`${e?.status || e?.name || "error"}`);
|
|
173
|
+
// 批失败拆单条,尽量保住部分向量
|
|
174
|
+
for (let k = 0; k < payload.length; k++) {
|
|
175
|
+
if (!remoteEmbedAvailable()) break;
|
|
176
|
+
const v = await remoteEmbed(payload[k], timeoutMs);
|
|
177
|
+
if (v) out[b.start + k] = v;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (typeof opts.onProgress === "function") {
|
|
181
|
+
try {
|
|
182
|
+
opts.onProgress(out.filter(Boolean).length, list.length);
|
|
183
|
+
} catch {
|
|
184
|
+
/* 进度回调失败不影响索引 */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await sleep(BATCH_GAP_MS);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
192
|
+
return out;
|
|
193
|
+
}
|