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/lib/rerank.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * rerank.js — 正文精排(借鉴 SkillRouter 的「元数据召回 + 正文重排」两阶段)。
3
+ *
4
+ * 为什么工具也需要「正文」:`tools_search` 的召回声只看 name + description,
5
+ * 而真正决定「该用哪个工具」的信息常常在**参数**里。例:
6
+ * mcp__playwright__browser_navigate vs browser_evaluate vs browser_take_screenshot
7
+ * 描述都是 "browser ...",参数才是 url / function / filename 的区别。
8
+ * 再如一堆 `*_search`:query 还是 keyword、有没有 repo/limit,全在参数描述里。
9
+ * L1 压缩还会把描述截到 200 字、参数描述砍到 120 字——被裁掉的正是这部分信号。
10
+ *
11
+ * 做法(与技能侧 v0.4.0 的 deep-search 同构):
12
+ * - 在 BM25/语义召回的**候选集内**重排,绝不扩大候选
13
+ * (技能侧实测:扩大候选会让泛化词把无关项顶上来,Hit@1 从 50% 掉到 25%)
14
+ * - body 打分 = 查询词在正文里的 IDF 覆盖率 + 整句命中加分
15
+ * - 融合:默认 **RRF 排名融合**;保留 `linear` 兼容旧行为
16
+ *
17
+ * ---- 2026-10-31 升级(v0.3.1)----
18
+ * 原融合是线性加权:`(1-w)·metaNorm + w·bodyScore`,其中 metaNorm = 1/(1+metaRank)。
19
+ * w=0.35 时**元数据第 0 位的底分就是 0.65,而正文满分仅 0.35**——正文信号
20
+ * 数学上永远翻不了盘。实测:
21
+ * 「查找代码里的函数定义」
22
+ * mcp__git body 0.00 → 0.650 (排第一)
23
+ * mcp__serena body 0.91 → 0.448 (排第二,正文赢了 0.91 分却输)
24
+ * 换成 RRF(Cormack et al. 2009)后,两路只贡献**名次**,不贡献分数量纲,
25
+ * 天花板问题消失,且不需要为 meta 与 body 两路的分数量级做校准。
26
+ * 参考 ratel 的做法:「基于排名的融合意味着各通道分数无需校准」。
27
+ */
28
+
29
+ import { tokenize } from "./bm25.js";
30
+
31
+ const MAX_BODY_CHARS = 4000;
32
+
33
+ /**
34
+ * 拼出一个工具的「正文」:完整描述(不截断)+ 参数名 + 参数描述 + 枚举 + server。
35
+ * @param {object} tool
36
+ * @returns {string}
37
+ */
38
+ export function toolBodyText(tool) {
39
+ if (!tool || typeof tool !== "object") return "";
40
+ const parts = [];
41
+ if (tool.name) parts.push(String(tool.name));
42
+ if (tool.description) parts.push(String(tool.description));
43
+ const schema = tool.parameters || tool.inputSchema || null;
44
+ const props = schema && typeof schema === "object" ? schema.properties : null;
45
+ if (props && typeof props === "object") {
46
+ for (const [k, v] of Object.entries(props)) {
47
+ parts.push(k);
48
+ if (v && typeof v === "object") {
49
+ if (typeof v.title === "string") parts.push(v.title);
50
+ if (typeof v.description === "string") parts.push(v.description);
51
+ if (Array.isArray(v.enum)) parts.push(v.enum.slice(0, 12).join(" "));
52
+ if (v.items && typeof v.items === "object" && typeof v.items.description === "string") {
53
+ parts.push(v.items.description);
54
+ }
55
+ }
56
+ }
57
+ if (Array.isArray(schema.required) && schema.required.length) {
58
+ parts.push(`required ${schema.required.join(" ")}`);
59
+ }
60
+ }
61
+ if (tool.server) parts.push(String(tool.server));
62
+ return parts.join(" ").slice(0, MAX_BODY_CHARS);
63
+ }
64
+
65
+ /**
66
+ * 在候选集内按正文重排。
67
+ *
68
+ * @param {string} query
69
+ * @param {Array<{id: string}>} candidates 已按元数据名次排好序
70
+ * @param {Map<string, object>} byName
71
+ * @param {{weight?: number, bodyText?: Function}} [opts]
72
+ * @returns {Array<{id: string, score: number, metaRank: number, bodyScore: number}>}
73
+ */
74
+ export function rerankByBody(query, candidates, byName, opts = {}) {
75
+ const list = Array.isArray(candidates) ? candidates.filter((c) => c && c.id) : [];
76
+ if (!list.length) return [];
77
+ const w = Number(opts.weight);
78
+ // 默认 0.45:eval/sweep.mjs 扫描 40 条标注查询得 82.5% top-1 / 0.9021 MRR(最优)。
79
+ // 旧值 0.35 是 80.0% / 0.8896。0.50 起开始掉。见文件头 2026-10-31 升级说明。
80
+ const weight = Number.isFinite(w) && w >= 0 && w <= 1 ? w : 0.45;
81
+ const bodyOf = typeof opts.bodyText === "function" ? opts.bodyText : toolBodyText;
82
+
83
+ const q = String(query || "").trim();
84
+ const qTokens = [...new Set(tokenize(q))];
85
+ const qLower = q.toLowerCase().replace(/\s+/g, " ");
86
+
87
+ if (!qTokens.length) {
88
+ return list.map((c, i) => ({ id: c.id, score: 1 / (1 + i), metaRank: i, bodyScore: 0 }));
89
+ }
90
+
91
+ // 候选集内的 df / idf(小集合上算,不依赖全局语料)
92
+ const bodies = new Map();
93
+ const df = new Map();
94
+ for (const c of list) {
95
+ const tool = byName.get(c.id);
96
+ const text = bodyOf(tool || { name: c.id });
97
+ const tokens = tokenize(text);
98
+ const set = new Set(tokens);
99
+ bodies.set(c.id, { text: text.toLowerCase(), tokens: set, len: tokens.length });
100
+ for (const t of set) df.set(t, (df.get(t) || 0) + 1);
101
+ }
102
+ const n = list.length;
103
+ const idf = (t) => Math.log((n - (df.get(t) || 0) + 0.5) / ((df.get(t) || 0) + 0.5) + 1);
104
+ // 分母只算「候选正文里真的出现过」的查询词。否则中文 bigram(工具描述多是英文,
105
+ // bigram 全 df=0)会把分母撑大,正文分被稀释到接近 0,精排等于没生效。
106
+ const matchedQ = qTokens.filter((t) => (df.get(t) || 0) > 0);
107
+ const qIdfSum = (matchedQ.length ? matchedQ.reduce((s, t) => s + idf(t), 0) : 0) || 1;
108
+
109
+ const scored = list.map((c, i) => {
110
+ const b = bodies.get(c.id);
111
+ if (!b) return { id: c.id, score: 1 / (1 + i), metaRank: i, bodyScore: 0 };
112
+ let hit = 0;
113
+ for (const t of qTokens) {
114
+ if (b.tokens.has(t)) hit += idf(t);
115
+ }
116
+ // 覆盖率 0..1,再乘一个「命中密度」修正:短正文命中更可信
117
+ let bodyScore = hit / qIdfSum;
118
+ if (bodyScore > 0 && b.len > 0) {
119
+ bodyScore *= 0.85 + 0.15 * Math.min(1, hit / Math.sqrt(b.len));
120
+ }
121
+ if (qLower.length >= 4 && b.text.includes(qLower)) bodyScore = Math.min(1, bodyScore + 0.15);
122
+ return { id: c.id, score: 0, metaRank: i, bodyScore };
123
+ });
124
+
125
+ // ---- 融合 ----
126
+ // 默认线性 w=0.45。这个数不是拍的,是 eval/sweep.mjs 在 40 条标注查询上扫出来的:
127
+ // w=0.35(旧默认)top-1 80.0% / MRR 0.8896
128
+ // w=0.45(新默认)top-1 82.5% / MRR 0.9021 ← 最优
129
+ // w=0.50 起开始掉(77.5%),w=0.60 掉到 75%
130
+ // 同时修掉了旧权重的病态案例:正文 0.91 的 serena 输给正文 0.00 的 git。
131
+ //
132
+ // RRF 保留为可选(fusion:'rrf'),但**实测在本语料上不优于线性**:
133
+ // 最高 80.0%,与「纯元数据」持平。原因是候选集只有 6 个、正文分噪声大,
134
+ // 排名融合表达不了「元数据远比正文可信」这件事,而小权重线性可以。
135
+ // 保留它的理由:当 meta 与 body 的分数量纲不可比时(例如接入语义腿后
136
+ // 余弦相似度 0~1 与 BM25 分数 0~20 混在一起),RRF 才是正确工具。
137
+ const fusion = opts.fusion === "rrf" ? "rrf" : "linear";
138
+
139
+ if (fusion === "linear") {
140
+ // 旧行为(保留兼容):分数线性加权。注意 w 存在天花板问题,见文件头注释。
141
+ for (const s of scored) {
142
+ s.score = (1 - weight) * (1 / (1 + s.metaRank)) + weight * s.bodyScore;
143
+ }
144
+ } else {
145
+ // RRF(默认):只融合名次,不做分数校准。
146
+ // score = Σ_lists w_list / (k + rank_list)
147
+ const k = Number.isFinite(opts.rrfK) && opts.rrfK > 0 ? opts.rrfK : 60;
148
+ const wMeta = Number.isFinite(opts.rrfMetaWeight) ? opts.rrfMetaWeight : 1;
149
+ const wBody = Number.isFinite(opts.rrfBodyWeight) ? opts.rrfBodyWeight : 1;
150
+
151
+ // 正文名次:按 bodyScore 降序;同分时沿用元数据名次,保证确定性
152
+ const bodyOrder = [...scored]
153
+ .sort((a, b) => b.bodyScore - a.bodyScore || a.metaRank - b.metaRank)
154
+ .map((s) => s.id);
155
+ const bodyRank = new Map(bodyOrder.map((id, i) => [id, i]));
156
+
157
+ for (const s of scored) {
158
+ const br = bodyRank.get(s.id) ?? scored.length;
159
+ s.score = wMeta / (k + s.metaRank) + wBody / (k + br);
160
+ }
161
+ }
162
+
163
+ scored.sort((a, b) => b.score - a.score || a.metaRank - b.metaRank);
164
+ return scored;
165
+ }
package/lib/semantic.js CHANGED
@@ -1,31 +1,34 @@
1
1
  /**
2
- * semantic.js — local semantic retrieval leg via Ollama bge-m3 (2026-08-30).
2
+ * semantic.js — 语义检索腿(远程优先 → 本地 Ollama → 纯 BM25 降级)。
3
3
  *
4
- * Complements the BM25 lexical leg (bm25.js) with a cross-lingual semantic
5
- * leg: Chinese intents can hit English-only tool/skill descriptions that
6
- * share no surface tokens (the documented BM25 gap). Uses the machine's
7
- * local Ollama (`http://127.0.0.1:11434/api/embed`, model bge-m3 — 1024 dims,
8
- * ~1.2GB, already installed per ~/.dsh config). No npm deps, no network egress.
4
+ * 历史:v0.1.8 只走本地 Ollama bge-m3;v0.3.0 起改为**远程优先**。
5
+ * 原因(2026-10-17 实测):本机没装 Ollama,`127.0.0.1:11434` 返回 000,
6
+ * 语义腿恒为 null,`tools_search` 实际只剩 BM25,中英跨语言命中率极低。
9
7
  *
10
- * Hard-fail design: every external path (fetch, fs cache) is optional. If
11
- * Ollama is down, the cache is unreadable, or anything throws, callers get
12
- * null/[] and their existing BM25 path just keeps working.
8
+ * 结构:
9
+ * embedText 单条:远程 → Ollama → null
10
+ * embedBatch 批量:远程批量(4 条/批、2 并发、429 退避)→ 逐条降级
11
+ * buildSemanticIndex 增量索引(逐 doc sha1 指纹 + 磁盘缓存,部分成功也落盘)
12
+ * searchSemantic 查询向量(带内存缓存)→ cosine 排序
13
+ * rrfFuse 加权 RRF(语义权重可调,默认 3.0)
13
14
  *
14
- * Cache: document embeddings are keyed by text and persisted to
15
- * `~/.dsh/state/semantic-cache.json` (fingerprint = hash of all doc texts), so
16
- * the ~10s cold start for 300+ tools happens once, not per session.
17
- * Query embeddings are cached in memory only (per-process, tiny).
15
+ * 硬失败设计:任何外部路径(fetch / fs / 缓存)失败都只会让调用方回退到 BM25。
18
16
  */
19
17
 
20
- const OLLAMA_BASE = "http://127.0.0.1:11434";
21
- const DEFAULT_MODEL = "bge-m3";
22
- const EMBED_TIMEOUT_MS = 8000;
23
- const CACHE_FILE = ".dsh/state/semantic-cache.json";
24
-
25
18
  import fs from "node:fs";
26
19
  import path from "node:path";
27
20
  import crypto from "node:crypto";
28
21
 
22
+ import { remoteEmbed, remoteEmbedBatch, remoteEmbedAvailable, configureRemoteEmbed } from "./embed-remote.js";
23
+
24
+ const OLLAMA_BASE = "http://127.0.0.1:11434";
25
+ const DEFAULT_MODEL = "bge-m3";
26
+ const EMBED_TIMEOUT_MS = 12000;
27
+ const CACHE_FILE = "state/semantic-cache.json";
28
+ const INDEX_DEADLINE_MS = 180_000; // 243 个工具 × 2048 维,4 条/批 ≈ 25-40s
29
+ const QUERY_CACHE_MAX = 64;
30
+ const QUERY_TTL_MS = 10 * 60 * 1000;
31
+
29
32
  /* ------------------------------------------------------------------ */
30
33
  /* hash + fs helpers (all fail-safe) */
31
34
  /* ------------------------------------------------------------------ */
@@ -46,7 +49,7 @@ function sha1(text) {
46
49
  try {
47
50
  return crypto.createHash("sha1").update(String(text || ""), "utf8").digest("hex");
48
51
  } catch {
49
- return hash(text); // node:crypto 不可用时的降级指纹
52
+ return hash(text);
50
53
  }
51
54
  }
52
55
 
@@ -54,8 +57,22 @@ function homeDir() {
54
57
  return process.env.USERPROFILE || process.env.HOME || "";
55
58
  }
56
59
 
60
+ /**
61
+ * 缓存路径:优先 `$DSH_HOME/state/`,再退 `~/.dsh-community`、`~/.dsh`。
62
+ * v0.3.0 修正:旧实现只认 USERPROFILE,DSH_HOME 指向 .dsh-community 时
63
+ * 缓存会落到另一个目录(和 dsh-recall 早期踩的是同一个坑)。
64
+ */
57
65
  function defaultCachePath() {
58
- return homeDir() + "/" + CACHE_FILE;
66
+ const roots = [];
67
+ if (process.env.DSH_HOME) roots.push(process.env.DSH_HOME);
68
+ const community = path.join(homeDir(), ".dsh-community");
69
+ try {
70
+ if (fs.existsSync(community)) roots.push(community);
71
+ } catch {
72
+ /* ignore */
73
+ }
74
+ roots.push(path.join(homeDir(), ".dsh"));
75
+ return path.join(roots[0] || homeDir(), CACHE_FILE);
59
76
  }
60
77
 
61
78
  function readCacheFileAt(p) {
@@ -81,27 +98,45 @@ function writeCacheFileAt(p, obj) {
81
98
  }
82
99
 
83
100
  /* ------------------------------------------------------------------ */
84
- /* Ollama embedding (fail-safe fetch) */
101
+ /* config + provider identity */
85
102
  /* ------------------------------------------------------------------ */
86
103
 
87
104
  let _model = DEFAULT_MODEL;
88
105
  let _base = OLLAMA_BASE;
89
- let _timeout = EMBED_TIMEOUT_MS; // P3-2 (2026-09-02): configurable via configureSemantic
106
+ let _timeout = EMBED_TIMEOUT_MS;
90
107
 
91
108
  /**
92
- * Configure the endpoint (called by plugin apply with cfg.ollamaBase/model).
93
- * @param {{ollamaBase?: string, embedModel?: string, embedTimeoutMs?: number}} cfg
109
+ * Configure both legs. `remote*` 可选;不传则远程腿沿用环境变量/默认值。
110
+ * @param {{ollamaBase?: string, embedModel?: string, embedTimeoutMs?: number,
111
+ * remoteBase?: string, remoteModel?: string, remoteKey?: string}} cfg
94
112
  */
95
113
  export function configureSemantic(cfg = {}) {
96
- if (cfg && typeof cfg === "object") {
97
- if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
98
- if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
99
- if (Number.isFinite(cfg.embedTimeoutMs) && cfg.embedTimeoutMs >= 100) _timeout = cfg.embedTimeoutMs;
114
+ if (!cfg || typeof cfg !== "object") return;
115
+ if (typeof cfg.ollamaBase === "string" && cfg.ollamaBase) _base = cfg.ollamaBase;
116
+ if (typeof cfg.embedModel === "string" && cfg.embedModel) _model = cfg.embedModel;
117
+ if (Number.isFinite(cfg.embedTimeoutMs) && cfg.embedTimeoutMs >= 100) _timeout = cfg.embedTimeoutMs;
118
+ if (cfg.remoteBase || cfg.remoteModel || typeof cfg.remoteKey === "string") {
119
+ configureRemoteEmbed({
120
+ apiBase: cfg.remoteBase,
121
+ model: cfg.remoteModel,
122
+ apiKey: cfg.remoteKey,
123
+ timeoutMs: cfg.embedTimeoutMs,
124
+ });
100
125
  }
101
126
  }
102
127
 
103
- /** One embedding vector for a text, or null on any failure. */
104
- export async function embedText(text, timeoutMs = _timeout) {
128
+ /** 缓存的身份键:换了端点或模型就必须整体重建,不能复用旧向量。 */
129
+ export function semanticProvider() {
130
+ return remoteEmbedAvailable()
131
+ ? `remote|${process.env.DSH_EMBED_BASE || "ark"}|${process.env.DSH_EMBED_MODEL || _model}`
132
+ : `ollama|${_base}|${_model}`;
133
+ }
134
+
135
+ /* ------------------------------------------------------------------ */
136
+ /* embedding legs */
137
+ /* ------------------------------------------------------------------ */
138
+
139
+ async function ollamaEmbed(text, timeoutMs) {
105
140
  const s = String(text || "").trim();
106
141
  if (!s) return null;
107
142
  try {
@@ -122,10 +157,42 @@ export async function embedText(text, timeoutMs = _timeout) {
122
157
  clearTimeout(timer);
123
158
  }
124
159
  } catch {
125
- return null; // offline / timeout / bad response → lexical leg only
160
+ return null;
126
161
  }
127
162
  }
128
163
 
164
+ /** 单条 embedding:远程优先,失败回落本地 Ollama,最后 null。 */
165
+ export async function embedText(text, timeoutMs = _timeout) {
166
+ const s = String(text || "").trim();
167
+ if (!s) return null;
168
+ if (remoteEmbedAvailable()) {
169
+ const rv = await remoteEmbed(s, timeoutMs);
170
+ if (rv) return rv;
171
+ }
172
+ return ollamaEmbed(s, timeoutMs);
173
+ }
174
+
175
+ /**
176
+ * 批量 embedding:远程可用时走批量(4 条/批 + 退避),否则逐条 embedText。
177
+ * @param {string[]} texts
178
+ * @param {number} [timeoutMs]
179
+ * @param {{batchSize?: number, concurrency?: number, onProgress?: Function}} [opts]
180
+ * @returns {Promise<Array<number[]|null>>}
181
+ */
182
+ export async function embedBatch(texts, timeoutMs = _timeout, opts = {}) {
183
+ const list = Array.isArray(texts) ? texts : [];
184
+ if (!list.length) return [];
185
+ if (remoteEmbedAvailable()) {
186
+ const vecs = await remoteEmbedBatch(list, { timeoutMs, ...opts });
187
+ if (vecs.some(Boolean)) return vecs;
188
+ }
189
+ const out = new Array(list.length).fill(null);
190
+ for (let i = 0; i < list.length; i++) {
191
+ out[i] = await embedText(list[i], timeoutMs);
192
+ }
193
+ return out;
194
+ }
195
+
129
196
  /** Cosine similarity in [0,1] (both vectors plain arrays). */
130
197
  export function cosine(a, b) {
131
198
  if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || a.length !== b.length) return 0;
@@ -141,93 +208,141 @@ export function cosine(a, b) {
141
208
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
142
209
  }
143
210
 
211
+ /* ------------------------------------------------------------------ */
212
+ /* query embedding cache (fold 路径不能等网络) */
213
+ /* ------------------------------------------------------------------ */
214
+
215
+ const queryCache = new Map(); // norm text -> {vec, ts}
216
+
217
+ function normQuery(q) {
218
+ return String(q || "").trim().toLowerCase().replace(/\s+/g, " ").slice(0, 400);
219
+ }
220
+
221
+ /**
222
+ * 同步读取已缓存的查询向量(供 assemble 热路径使用,绝不等网络)。
223
+ * @returns {number[]|null}
224
+ */
225
+ export function getCachedQueryVector(query) {
226
+ const k = normQuery(query);
227
+ if (!k) return null;
228
+ const hit = queryCache.get(k);
229
+ if (!hit) return null;
230
+ if (Date.now() - hit.ts > QUERY_TTL_MS) {
231
+ queryCache.delete(k);
232
+ return null;
233
+ }
234
+ return hit.vec;
235
+ }
236
+
237
+ /** 查询向量(带缓存);失败返回 null。 */
238
+ export async function embedQueryCached(query, timeoutMs = _timeout) {
239
+ const k = normQuery(query);
240
+ if (!k) return null;
241
+ const hit = queryCache.get(k);
242
+ if (hit && Date.now() - hit.ts <= QUERY_TTL_MS) return hit.vec;
243
+ const vec = await embedText(query, timeoutMs);
244
+ if (vec) {
245
+ queryCache.set(k, { vec, ts: Date.now() });
246
+ if (queryCache.size > QUERY_CACHE_MAX) {
247
+ const oldest = [...queryCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0];
248
+ if (oldest) queryCache.delete(oldest[0]);
249
+ }
250
+ }
251
+ return vec;
252
+ }
253
+
144
254
  /* ------------------------------------------------------------------ */
145
255
  /* Semantic index with persistent doc-embedding cache */
146
256
  /* ------------------------------------------------------------------ */
147
257
 
148
258
  /**
149
- * Build a semantic index over docs with content-fingerprint incremental diff
150
- * (v0.2.2). Doc embeddings come from the on-disk cache when the per-doc
151
- * fingerprint (sha1 of id+text) matches; otherwise they are recomputed and
152
- * the cache is patched incrementally — tools that didn't change are never
153
- * re-embedded (the old code rebuilt the whole index on any set change).
154
- *
155
- * Any failure returns an index whose vectors are simply empty (lexical leg
156
- * still works). Short-circuits on an overall deadline and only persists the
157
- * cache on full success, so a half-built/offline run can never corrupt the
158
- * prior cache.
259
+ * Build a semantic index over docs with per-doc fingerprint incremental diff.
260
+ * 与旧实现的差别:
261
+ * 1. 走 embedBatch(批量 + 并发),243 个工具从「数百次串行 HTTP」变成 ~60 次批调用
262
+ * 2. **部分成功也落盘**(旧实现要求全部成功才写缓存 → 一次失败永远重建)
263
+ * 3. 缓存身份键带 provider(远程/本地切换不会复用错向量)
264
+ * 4. 覆盖率 ≥80% 即视为可用(cosine 只对已有向量的 doc 生效)
159
265
  *
160
266
  * @param {Array<{id: string, text: string}>} docs
161
- * @param {number} [timeoutMs] per-embed timeout
162
- * @param {{embed?: Function, cacheFile?: string}} [opts]
163
- * - embed: injectable embedding fn(text, timeoutMs)->vector|null (tests);
164
- * defaults to embedText (real Ollama).
165
- * - cacheFile: explicit cache path (tests use a temp file; never touch the
166
- * real ~/.dsh/state unless omitted).
167
- * @returns {Promise<{vectors: Map<string, number[]>, available: boolean}>}
267
+ * @param {number} [timeoutMs]
268
+ * @param {{embed?: Function, cacheFile?: string, onProgress?: Function}} [opts]
269
+ * @returns {Promise<{vectors: Map<string, number[]>, available: boolean, embedded: number, reused: number}>}
168
270
  */
169
271
  export async function buildSemanticIndex(docs, timeoutMs = _timeout, opts = {}) {
170
- const out = { vectors: new Map(), available: false };
272
+ const out = { vectors: new Map(), available: false, embedded: 0, reused: 0 };
171
273
  const list = Array.isArray(docs) ? docs.filter((d) => d && d.id && d.text) : [];
172
274
  if (list.length === 0) return out;
173
275
 
174
- const embed = typeof opts.embed === "function" ? opts.embed : embedText;
276
+ const injectable = typeof opts.embed === "function" ? opts.embed : null;
175
277
  const cachePath = opts.cacheFile || defaultCachePath();
278
+ const provider = semanticProvider();
176
279
 
177
- // Per-doc content fingerprint: sha1 of id+text (name+description). A doc
178
- // whose text changed gets a new fp → only that doc is re-embedded.
179
280
  const fpOf = (d) => sha1(d.id + "\u0000" + d.text);
180
281
 
181
282
  const cache = readCacheFileAt(cachePath);
182
- // Liveness/lock: cache is only reusable if the provider/model that produced
183
- // it equals the currently configured ones. Any mismatch (or old whole-index
184
- // format, which has no .model) → full invalidation reconstruction.
185
283
  const modelMatch =
186
- cache && cache.model &&
187
- cache.model.model === _model && cache.model.base === _base &&
188
- cache.docs && typeof cache.docs === "object";
284
+ cache &&
285
+ cache.model &&
286
+ (cache.model.provider === provider ||
287
+ (!cache.model.provider && cache.model.model === _model && cache.model.base === _base)) &&
288
+ cache.docs &&
289
+ typeof cache.docs === "object";
189
290
  const cachedDocs = modelMatch ? cache.docs : null;
190
291
 
191
- // Embed every doc whose fingerprint misses the cache; reuse cached vectors.
192
- const fresh = {}; // id -> vector, only for newly embedded
193
- const deadline = Date.now() + 60_000;
292
+ // 1) 复用缓存里指纹未变的向量,收集需要重算的 doc
293
+ const need = [];
294
+ const fpById = new Map();
194
295
  for (const d of list) {
195
- if (Date.now() > deadline) break;
196
- let v = null;
197
- if (cachedDocs) {
198
- const hit = cachedDocs[fpOf(d)];
199
- if (hit && Array.isArray(hit.vec) && hit.vec.length > 0 && hit.id === d.id) {
200
- v = hit.vec; // unchanged → reuse cache, zero Ollama calls
201
- }
296
+ const fp = fpOf(d);
297
+ fpById.set(d.id, fp);
298
+ const hit = cachedDocs && cachedDocs[fp];
299
+ if (hit && Array.isArray(hit.vec) && hit.vec.length > 0 && hit.id === d.id) {
300
+ out.vectors.set(d.id, hit.vec);
301
+ out.reused += 1;
302
+ } else {
303
+ need.push(d);
202
304
  }
203
- if (!v) {
204
- v = await embed(d.text, timeoutMs);
205
- if (v) fresh[d.id] = v;
206
- }
207
- if (v) out.vectors.set(d.id, v);
208
305
  }
209
306
 
210
- if (out.vectors.size === list.length) {
211
- out.available = true;
212
- // Rebuild the docs map: keep reusable cached entries + newly embedded,
213
- // dropping docs no longer in `list` (tool removal handled by set change).
214
- const newDocs = {};
215
- for (const d of list) {
216
- const fp = fpOf(d);
217
- const hit = cachedDocs && cachedDocs[fp];
218
- if (hit && Array.isArray(hit.vec) && hit.vec.length > 0) {
219
- newDocs[fp] = { id: d.id, vec: hit.vec };
220
- } else if (fresh[d.id]) {
221
- newDocs[fp] = { id: d.id, vec: fresh[d.id] };
307
+ // 2) 批量补算缺失向量
308
+ if (need.length) {
309
+ let fresh = new Array(need.length).fill(null);
310
+ if (injectable) {
311
+ for (let i = 0; i < need.length; i++) {
312
+ fresh[i] = await injectable(need[i].text, timeoutMs);
222
313
  }
314
+ } else {
315
+ const texts = need.map((d) => d.text);
316
+ fresh = await embedBatch(texts, timeoutMs, {
317
+ batchSize: 4,
318
+ concurrency: 2,
319
+ onProgress: opts.onProgress,
320
+ });
223
321
  }
224
- writeCacheFileAt(cachePath, { model: { base: _base, model: _model }, docs: newDocs });
322
+ for (let i = 0; i < need.length; i++) {
323
+ if (fresh[i]) {
324
+ out.vectors.set(need[i].id, fresh[i]);
325
+ out.embedded += 1;
326
+ }
327
+ }
328
+ }
329
+
330
+ // 3) 落盘(部分成功也写,避免「一次失败永远重建」)
331
+ const newDocs = {};
332
+ for (const d of list) {
333
+ const v = out.vectors.get(d.id);
334
+ if (v) newDocs[fpById.get(d.id)] = { id: d.id, vec: v };
335
+ }
336
+ if (Object.keys(newDocs).length) {
337
+ writeCacheFileAt(cachePath, { model: { provider, base: _base, model: _model }, docs: newDocs });
225
338
  }
339
+
340
+ out.available = out.vectors.size > 0 && out.vectors.size >= Math.ceil(list.length * 0.8);
226
341
  return out;
227
342
  }
228
343
 
229
344
  /**
230
- * Rank docs by semantic similarity to a query.
345
+ * Rank docs by semantic similarity to a query (查询向量走缓存).
231
346
  * @param {Map<string, number[]>} vectors id → vector
232
347
  * @param {Array<{id: string, text: string}>} docs
233
348
  * @param {string} query
@@ -236,8 +351,20 @@ export async function buildSemanticIndex(docs, timeoutMs = _timeout, opts = {})
236
351
  export async function searchSemantic(vectors, docs, query) {
237
352
  const q = String(query || "").trim();
238
353
  if (!q || !vectors || vectors.size === 0) return [];
239
- const qv = await embedText(q);
354
+ const qv = await embedQueryCached(q);
240
355
  if (!qv) return [];
356
+ return rankByVector(qv, vectors, docs);
357
+ }
358
+
359
+ /** 同步版:只用已缓存的查询向量(fold 热路径用)。 */
360
+ export function searchSemanticCached(vectors, docs, query) {
361
+ if (!vectors || vectors.size === 0) return [];
362
+ const qv = getCachedQueryVector(query);
363
+ if (!qv) return [];
364
+ return rankByVector(qv, vectors, docs);
365
+ }
366
+
367
+ function rankByVector(qv, vectors, docs) {
241
368
  const scored = [];
242
369
  for (const d of docs) {
243
370
  const v = vectors.get(d.id);
@@ -250,35 +377,79 @@ export async function searchSemantic(vectors, docs, query) {
250
377
  }
251
378
 
252
379
  /* ------------------------------------------------------------------ */
253
- /* RRF hybrid: merge a BM25 ranking and a semantic ranking */
380
+ /* Weighted RRF: merge BM25 ranking and semantic ranking */
254
381
  /* ------------------------------------------------------------------ */
255
382
 
256
383
  const RRF_K = 60;
384
+ const DEFAULT_SEM_WEIGHT = 3;
385
+ const DEFAULT_BM_WEIGHT = 0.25;
386
+
387
+ /**
388
+ * 自适应 BM25 权重(v0.3.0)。
389
+ *
390
+ * 实测依据(43 个真实工具 / 18 条真实中文意图):
391
+ * BM25 单腿 Hit@1 6/18 · 语义单腿 11/18 · 1:1 融合 8/18
392
+ * 语义 3 : BM25 0.25 → 11/18(与语义单腿同水平,且保留 BM25 的精确词兜底)
393
+ *
394
+ * 判据:查询里的 ASCII 词(>=3 字符)是否出现在 BM25 首条的 名称/描述 中。
395
+ * - 有 → 这是真词匹配(工具名、server 名、英文关键词),BM25 可信,权重 1
396
+ * - 没有(纯中文,BM25 只靠 CJK bigram 命中英文描述)→ 降权到 0.25,
397
+ * 否则噪声项会和语义腿"双命中",被 RRF 加成顶到正确项前面
398
+ */
399
+ export function adaptiveBmWeight(query, topHitName, topHitText) {
400
+ const ascii = String(query || "").toLowerCase().match(/[a-z0-9][a-z0-9_+-]{2,}/g) || [];
401
+ if (!ascii.length) return DEFAULT_BM_WEIGHT;
402
+ const hay = `${topHitName || ""} ${topHitText || ""}`.toLowerCase();
403
+ return ascii.some((t) => hay.includes(t)) ? 1 : DEFAULT_BM_WEIGHT;
404
+ }
257
405
 
258
406
  /**
259
407
  * Reciprocal-rank fusion of two ranked id lists.
260
- * @param {Array<{id: string}>} bm25Ranked BM25 hits in rank order
261
- * @param {Array<{id: string}>} semRanked semantic hits in rank order
408
+ *
409
+ * v0.3.0:语义腿不再是 1:1 等权。技能侧重测(131 技能 / 12 意图)显示
410
+ * 语义明显更准(Hit@1 83% vs 58%),工具侧同样存在「中文意图 / 英文工具」
411
+ * 的跨语言鸿沟,所以给语义腿更高权重;BM25 退化为「精确词与工具名匹配」。
412
+ *
413
+ * 但**单纯调高语义权重不够**(2026-10-17 实测):纯中文查询下 BM25 只靠 CJK
414
+ * bigram 命中一堆无关工具,这些噪声项同时出现在两条腿里,RRF 的"双命中"加成
415
+ * 会把它们顶到正确项前面(Hit@1 从 11/18 掉到 0/18)。所以 BM25 腿也要可降权:
416
+ * 调用方按「BM25 的命中是否含真实 ASCII 词匹配」决定 bmWeight(1 或 0.25)。
417
+ *
418
+ * @param {Array<{id: string}>} bm25Ranked
419
+ * @param {Array<{id: string}>} semRanked
262
420
  * @param {number} topK
263
- * @returns {Array<{id: string, rrf: number, from: string}>} fused, sorted desc
421
+ * @param {{semWeight?: number, bmWeight?: number}} [opts]
264
422
  */
265
- export function rrfFuse(bm25Ranked, semRanked, topK) {
423
+ export function rrfFuse(bm25Ranked, semRanked, topK, opts = {}) {
424
+ const w = Number(opts.semWeight);
425
+ const semWeight = Number.isFinite(w) && w >= 0 ? w : DEFAULT_SEM_WEIGHT;
426
+ const bw = Number(opts.bmWeight);
427
+ const bmWeight = Number.isFinite(bw) && bw >= 0 ? bw : DEFAULT_BM_WEIGHT;
266
428
  const scores = new Map();
267
429
  const from = new Map();
268
- bm25Ranked.forEach((h, i) => {
269
- const key = h.id;
270
- scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
271
- from.set(key, (from.get(key) || "") + "bm25 ");
430
+ const seen = new Set();
431
+ // v0.3.0 修复:同名 id 只取首次出现。语料里若出现重复项(实测工具语料里
432
+ // list_sessions / get_config 各两份),RRF 会把同一 id 的分数累加两次,
433
+ // 直接把无关工具顶到第一——纯语义单腿 11/18 被拉到 2/18 的就是这个。
434
+ (Array.isArray(bm25Ranked) ? bm25Ranked : []).forEach((h, i) => {
435
+ if (!h || !h.id || seen.has(h.id)) return;
436
+ seen.add(h.id);
437
+ scores.set(h.id, bmWeight / (RRF_K + i + 1));
438
+ from.set(h.id, `${from.get(h.id) || ""}bm25 `);
272
439
  });
273
- semRanked.forEach((h, i) => {
274
- const key = h.id;
275
- scores.set(key, (scores.get(key) || 0) + 1 / (RRF_K + i + 1));
276
- from.set(key, (from.get(key) || "") + "sem ");
440
+ (Array.isArray(semRanked) ? semRanked : []).forEach((h, i) => {
441
+ if (!h || !h.id) return;
442
+ // 语义腿的名次必须按**去重后的位置**计,否则重复项会挤占后面的名次
443
+ if (scores.has(h.id) && from.get(h.id)?.includes("sem")) return;
444
+ scores.set(h.id, (scores.get(h.id) || 0) + semWeight / (RRF_K + i + 1));
445
+ from.set(h.id, `${from.get(h.id) || ""}sem `);
277
446
  });
278
447
  const k = Number(topK);
279
448
  const n = Number.isFinite(k) && k > 0 ? Math.floor(k) : 5;
280
449
  return [...scores.entries()]
281
450
  .sort((a, b) => b[1] - a[1])
282
451
  .slice(0, n)
283
- .map(([id, r]) => ({ id, rrf: r, from: from.get(id)?.trim() || "" }));
452
+ .map(([id, r]) => ({ id, rrf: r, from: (from.get(id) || "").trim() }));
284
453
  }
454
+
455
+ export { DEFAULT_SEM_WEIGHT, DEFAULT_BM_WEIGHT };