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/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 —
|
|
2
|
+
* semantic.js — 语义检索腿(远程优先 → 本地 Ollama → 纯 BM25 降级)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
* Ollama
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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);
|
|
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
|
-
|
|
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
|
-
/*
|
|
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;
|
|
106
|
+
let _timeout = EMBED_TIMEOUT_MS;
|
|
90
107
|
|
|
91
108
|
/**
|
|
92
|
-
* Configure
|
|
93
|
-
* @param {{ollamaBase?: string, embedModel?: string, embedTimeoutMs?: number
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
/**
|
|
104
|
-
export
|
|
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;
|
|
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
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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]
|
|
162
|
-
* @param {{embed?: Function, cacheFile?: string}} [opts]
|
|
163
|
-
*
|
|
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
|
|
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 &&
|
|
187
|
-
cache.model
|
|
188
|
-
cache.
|
|
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
|
-
//
|
|
192
|
-
const
|
|
193
|
-
const
|
|
292
|
+
// 1) 复用缓存里指纹未变的向量,收集需要重算的 doc
|
|
293
|
+
const need = [];
|
|
294
|
+
const fpById = new Map();
|
|
194
295
|
for (const d of list) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
261
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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)
|
|
452
|
+
.map(([id, r]) => ({ id, rrf: r, from: (from.get(id) || "").trim() }));
|
|
284
453
|
}
|
|
454
|
+
|
|
455
|
+
export { DEFAULT_SEM_WEIGHT, DEFAULT_BM_WEIGHT };
|