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
|
@@ -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
|
+
}
|
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 };
|