@kira905/dsh-memory-toolkit 0.1.3 → 0.2.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/CHANGELOG.md +92 -0
- package/README.en.md +225 -2
- package/README.md +180 -2
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +1 -1
|
@@ -1,291 +1,604 @@
|
|
|
1
|
-
//
|
|
1
|
+
// 离线层召回(方案 §3.3)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// 主线:向量余弦 top-20 打底 + FTS5 只补英文/数字 + 词表第二路(默认关)+ RRF(k=60) 融合
|
|
4
|
+
// + 已 pinned 的剔除。触发判定不设「至少 10 字」这种阈值 —— 「退下吧」「整夜干活」这类短暗号
|
|
5
|
+
// 恰恰最需要召回,只把空串、纯符号、单字挡在外面。
|
|
6
|
+
//
|
|
7
|
+
// 三条「别让召回把整轮注入拖死」的口径(本版新增,行为只在失败路径上变化):
|
|
8
|
+
// ① 查询先截断到 `QUERY_CHAR_LIMIT` 个字符 —— 超长输入不该拿去编码,也不该撑爆 FTS 表达式;
|
|
9
|
+
// ② 编码带时间预算:超时 / 抛错 / 空向量 ⇒ **降级成固定 Top-K 兜底**(最近改动的若干条),
|
|
10
|
+
// 绝不返回空、绝不把异常往上抛;
|
|
11
|
+
// ③ 每一轮都能留痕:给 `recallLog` 就写一行 JSON(旁路、失败不影响结果),不给就什么都不做。
|
|
6
12
|
import { loadMatrix } from './vectors-store.mjs';
|
|
13
|
+
import { getLexiconIndex, rankByLexicon, resolveLexiconSwitch } from './lexicon-recall.mjs';
|
|
14
|
+
import { recordRecall } from './recall-log.mjs';
|
|
15
|
+
|
|
16
|
+
const TRIGGER_RULES = [
|
|
17
|
+
{ reason: 'empty', match: (raw) => raw.length === 0 },
|
|
18
|
+
{ reason: 'symbols-only', match: (raw) => countSemantic(raw) === 0 },
|
|
19
|
+
{ reason: 'single-char', match: (raw) => countSemantic(raw) < 2 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
// 数一数字母与数字的个数(中文汉字按字母算,标点不算)
|
|
23
|
+
function countSemantic(text) {
|
|
24
|
+
const hits = text.match(/[\p{L}\p{N}]/gu);
|
|
25
|
+
return hits ? hits.length : 0;
|
|
26
|
+
}
|
|
7
27
|
|
|
8
|
-
|
|
28
|
+
// 触发判定:按上面的规则表逐条试,都不命中就算可召回
|
|
9
29
|
export function shouldTrigger(rawQuery) {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
const raw = String(rawQuery ?? '').trim();
|
|
31
|
+
const blocked = TRIGGER_RULES.find((rule) => rule.match(raw));
|
|
32
|
+
return blocked ? { trigger: false, reason: blocked.reason } : { trigger: true, reason: 'non-empty' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ────────────────── 查询整形 / 编码预算 / 降级兜底 ────────────────── */
|
|
36
|
+
|
|
37
|
+
/** 查询字符上限(按码点算,不是 UTF-16 码元 —— 免得把 emoji 劈成半个代理对)。 */
|
|
38
|
+
export const QUERY_CHAR_LIMIT = 256;
|
|
39
|
+
/** 编码等待预算:本地 ONNX 正常几十毫秒,1.5s 只用来兜住卡死;超了就走降级。 */
|
|
40
|
+
export const ENCODE_TIMEOUT_MS = 1500;
|
|
41
|
+
/** 降级兜底的固定条数。 */
|
|
42
|
+
export const FALLBACK_TOP_K = 8;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 截断查询:留前 `limit` 个字符,并如实报「原来多长、有没有被截」。
|
|
46
|
+
* @param {unknown} rawQuery
|
|
47
|
+
* @param {number} [limit]
|
|
48
|
+
* @returns {{text:string, chars:number, truncated:boolean, limit:number}}
|
|
49
|
+
*/
|
|
50
|
+
export function truncateQuery(rawQuery, limit = QUERY_CHAR_LIMIT) {
|
|
51
|
+
const whole = String(rawQuery ?? '');
|
|
52
|
+
const budget = Math.max(1, Math.trunc(Number(limit) || QUERY_CHAR_LIMIT));
|
|
53
|
+
if (whole.length <= budget) return { text: whole, chars: whole.length, truncated: false, limit: budget };
|
|
54
|
+
const points = Array.from(whole);
|
|
55
|
+
if (points.length <= budget) return { text: whole, chars: whole.length, truncated: false, limit: budget };
|
|
56
|
+
return { text: points.slice(0, budget).join(''), chars: whole.length, truncated: true, limit: budget };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 带时间预算的编码。**永不抛错**:失败只回 `{ vector: null, error: { name, ms } }`。
|
|
61
|
+
* 隐私口径:`error` 里只留错误名与耗时,不存 query 原文、不存 error.message(可能带路径或片段)。
|
|
62
|
+
* @param {{encode:Function}} embedder
|
|
63
|
+
* @param {string} text
|
|
64
|
+
* @param {{timeoutMs?:number}} [options]
|
|
65
|
+
*/
|
|
66
|
+
export async function encodeQuery(embedder, text, options = {}) {
|
|
67
|
+
const startedAt = performance.now();
|
|
68
|
+
const budget = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 0;
|
|
69
|
+
const fail = (err) => ({ vector: null, error: { name: String((err && err.name) || 'Error'), ms: performance.now() - startedAt } });
|
|
70
|
+
// 同步抛也接住;超时后底层 promise 仍可能失败 ⇒ 先挂一个处理者,别变成 unhandled rejection
|
|
71
|
+
const pending = Promise.resolve().then(() => embedder.encode(text));
|
|
72
|
+
pending.catch(() => {});
|
|
73
|
+
let timer = null;
|
|
74
|
+
try {
|
|
75
|
+
let vector = null;
|
|
76
|
+
if (budget > 0) {
|
|
77
|
+
vector = await Promise.race([
|
|
78
|
+
pending,
|
|
79
|
+
new Promise((_, reject) => {
|
|
80
|
+
// ⚠️ 这个定时器**不许 unref**:调用方正在 await 它,把它从事件循环里摘掉等于"预算永不生效"
|
|
81
|
+
// (脚本里没有别的 handle 时进程会直接判定 unsettled top-level await 退出)。
|
|
82
|
+
// 正常路径由 finally 里的 clearTimeout 收走,不会多留一个 handle。
|
|
83
|
+
timer = setTimeout(() => {
|
|
84
|
+
const timeout = new Error('encode-timeout');
|
|
85
|
+
timeout.name = 'TimeoutError';
|
|
86
|
+
reject(timeout);
|
|
87
|
+
}, budget);
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
} else {
|
|
91
|
+
vector = await pending;
|
|
92
|
+
}
|
|
93
|
+
if (!vector || typeof vector.length !== 'number' || vector.length === 0) {
|
|
94
|
+
const empty = new Error('encode-empty-vector');
|
|
95
|
+
empty.name = 'EmptyVectorError';
|
|
96
|
+
throw empty;
|
|
97
|
+
}
|
|
98
|
+
return { vector, error: null, elapsedMs: performance.now() - startedAt };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return { ...fail(err), elapsedMs: performance.now() - startedAt };
|
|
101
|
+
} finally {
|
|
102
|
+
if (timer) clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 降级兜底清单:挑「最近改动」的若干条,保证非空、顺序确定。
|
|
108
|
+
* 三档依次兜:数据源直给的 `recent()` → 元数据按 `updated_at` 降序 → 向量表里 id 最大的几条。
|
|
109
|
+
* @param {{source?:object, index?:object|null, pinned?:Set<string>, take?:number}} [options]
|
|
110
|
+
* @returns {string[]} refId 字符串(已去重、已剔 pinned、已截到 take)
|
|
111
|
+
*/
|
|
112
|
+
export function fallbackCandidateIds(options = {}) {
|
|
113
|
+
const source = options.source || null;
|
|
114
|
+
const index = options.index || null;
|
|
115
|
+
const pinned = options.pinned || new Set();
|
|
116
|
+
const take = Math.max(1, Math.trunc(Number(options.take) || FALLBACK_TOP_K));
|
|
117
|
+
const kept = [];
|
|
118
|
+
const taken = new Set();
|
|
119
|
+
|
|
120
|
+
const admit = (candidate) => {
|
|
121
|
+
if (candidate === null || candidate === undefined) return;
|
|
122
|
+
const id = String(candidate);
|
|
123
|
+
if (!id || id === 'null' || id === 'undefined' || taken.has(id) || pinned.has(id)) return;
|
|
124
|
+
taken.add(id);
|
|
125
|
+
kept.push(id);
|
|
126
|
+
};
|
|
127
|
+
const fill = (rows) => {
|
|
128
|
+
if (!Array.isArray(rows)) return;
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
if (kept.length >= take) return;
|
|
131
|
+
admit(row && typeof row === 'object' ? row.id : row);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
if (source && typeof source.recent === 'function') fill(source.recent(take + pinned.size));
|
|
137
|
+
} catch {
|
|
138
|
+
// 第一档不给就换下一档
|
|
139
|
+
}
|
|
140
|
+
if (kept.length < take) {
|
|
141
|
+
try {
|
|
142
|
+
const rows = source && typeof source.allTags === 'function' ? source.allTags() : null;
|
|
143
|
+
if (Array.isArray(rows)) {
|
|
144
|
+
fill(rows.slice().sort((left, right) => (
|
|
145
|
+
Number(right.updated_at ?? 0) - Number(left.updated_at ?? 0) || Number(right.id ?? 0) - Number(left.id ?? 0)
|
|
146
|
+
)));
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// 元数据也拿不到就算了,走第三档
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (kept.length < take && index && Array.isArray(index.refIds)) {
|
|
153
|
+
fill(index.refIds.slice().reverse().map((refId) => ({ id: refId })));
|
|
154
|
+
}
|
|
155
|
+
return kept.slice(0, take);
|
|
16
156
|
}
|
|
17
157
|
|
|
18
|
-
|
|
158
|
+
// 降级命中的形状:来源标 fallback;分数只为排序/展示(1/(k+rank)),不与融合分混用。
|
|
159
|
+
function fallbackHit(refId, at, sourceName, rrfK) {
|
|
160
|
+
return {
|
|
161
|
+
refId,
|
|
162
|
+
id: sourceName === 'memory' ? refIdToNum(refId) : null,
|
|
163
|
+
source: sourceName,
|
|
164
|
+
score: 1 / (rrfK + at + 1),
|
|
165
|
+
sources: ['fallback'],
|
|
166
|
+
vectorRank: null,
|
|
167
|
+
ftsRank: null,
|
|
168
|
+
lexiconRank: null,
|
|
169
|
+
fallbackRank: at + 1,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 抽 FTS 补位 token:只认英文/数字(中文连续串在 unicode61 下几近不可用)。
|
|
174
|
+
// 单字符噪声太大直接丢;同一条只留一次,凑满 max 就收手。
|
|
19
175
|
export function extractFtsTokens(rawQuery, max = 8) {
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
176
|
+
const text = String(rawQuery ?? '');
|
|
177
|
+
const pattern = /[A-Za-z0-9][A-Za-z0-9_.-]*/g;
|
|
178
|
+
const listed = [];
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
let scan = pattern.exec(text);
|
|
181
|
+
while (scan) {
|
|
182
|
+
const token = scan[0];
|
|
183
|
+
if (token.length >= 2 && !seen.has(token)) {
|
|
184
|
+
seen.add(token);
|
|
185
|
+
listed.push(token);
|
|
186
|
+
if (listed.length >= max) break;
|
|
187
|
+
}
|
|
188
|
+
scan = pattern.exec(text);
|
|
26
189
|
}
|
|
27
|
-
return
|
|
190
|
+
return listed;
|
|
28
191
|
}
|
|
29
192
|
|
|
30
|
-
|
|
193
|
+
// 拼 FTS5 的 MATCH 表达式:每个 token 裹双引号防语法注入,token 之间 OR
|
|
31
194
|
export function buildFtsMatch(tokens) {
|
|
32
|
-
if (!tokens ||
|
|
33
|
-
|
|
195
|
+
if (!Array.isArray(tokens) || tokens.length === 0) return null;
|
|
196
|
+
const quoted = [];
|
|
197
|
+
for (const token of tokens) quoted.push(`"${String(token).replace(/"/g, '')}"`);
|
|
198
|
+
return quoted.join(' OR ');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 每行范数(余弦召回用;建一次索引反复召回)
|
|
202
|
+
function rowNorms(data, count, dim) {
|
|
203
|
+
const norms = new Float32Array(count);
|
|
204
|
+
for (let row = 0; row < count; row += 1) {
|
|
205
|
+
const from = row * dim;
|
|
206
|
+
const slice = data.subarray(from, from + dim);
|
|
207
|
+
let squareSum = 0;
|
|
208
|
+
for (let i = 0; i < dim; i += 1) squareSum += slice[i] * slice[i];
|
|
209
|
+
norms[row] = Math.sqrt(squareSum) || 1;
|
|
210
|
+
}
|
|
211
|
+
return norms;
|
|
34
212
|
}
|
|
35
213
|
|
|
36
|
-
|
|
214
|
+
// 载入向量矩阵 + 每行范数
|
|
37
215
|
export function buildVectorIndex(db, { source = 'memory', model = null } = {}) {
|
|
38
216
|
const matrix = loadMatrix(db, source, model);
|
|
39
|
-
|
|
40
|
-
for (let r = 0; r < matrix.count; r += 1) {
|
|
41
|
-
let n = 0;
|
|
42
|
-
const off = r * matrix.dim;
|
|
43
|
-
for (let i = 0; i < matrix.dim; i += 1) n += matrix.data[off + i] * matrix.data[off + i];
|
|
44
|
-
norms[r] = Math.sqrt(n) || 1;
|
|
45
|
-
}
|
|
46
|
-
return { ...matrix, norms, builtAt: Date.now() };
|
|
217
|
+
return { ...matrix, norms: rowNorms(matrix.data, matrix.count, matrix.dim), builtAt: Date.now() };
|
|
47
218
|
}
|
|
48
219
|
|
|
49
|
-
|
|
220
|
+
// 余弦 top-K(纯内存暴力扫;候选上万也是毫秒级)
|
|
50
221
|
export function cosineTopK(index, qvec, topK) {
|
|
51
222
|
const { data, dim, count, norms } = index;
|
|
52
223
|
if (!dim || !count) return [];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
224
|
+
|
|
225
|
+
let qNorm = 0;
|
|
226
|
+
for (let i = 0; i < dim; i += 1) qNorm += qvec[i] * qvec[i];
|
|
227
|
+
qNorm = Math.sqrt(qNorm) || 1;
|
|
228
|
+
|
|
56
229
|
const scores = new Float32Array(count);
|
|
57
|
-
for (let
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
for (let i = 0; i < dim; i += 1)
|
|
61
|
-
scores[
|
|
230
|
+
for (let row = 0; row < count; row += 1) {
|
|
231
|
+
const from = row * dim;
|
|
232
|
+
let dot = 0;
|
|
233
|
+
for (let i = 0; i < dim; i += 1) dot += data[from + i] * qvec[i];
|
|
234
|
+
scores[row] = dot / (qNorm * norms[row]);
|
|
62
235
|
}
|
|
63
|
-
|
|
64
|
-
order
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
236
|
+
|
|
237
|
+
const order = new Array(count);
|
|
238
|
+
for (let i = 0; i < count; i += 1) order[i] = i;
|
|
239
|
+
order.sort((left, right) => scores[right] - scores[left] || left - right);
|
|
240
|
+
|
|
241
|
+
const take = Math.min(Number(topK) || 0, count);
|
|
242
|
+
const picked = [];
|
|
243
|
+
for (let i = 0; i < take; i += 1) {
|
|
244
|
+
const at = order[i];
|
|
245
|
+
picked.push({ index: at, refId: index.refIds[at], id: index.refIdsNum[at], score: scores[at] });
|
|
70
246
|
}
|
|
71
|
-
return
|
|
247
|
+
return picked;
|
|
72
248
|
}
|
|
73
249
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
250
|
+
// 规范十进制:允许 0,不允许前导零 / 正负号 / 小数点
|
|
251
|
+
function isCanonicalDecimal(text) {
|
|
252
|
+
return /^(0|[1-9]\d*)$/.test(text);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// refId → 数字 id。
|
|
256
|
+
// 只有 memory 源的 id 是整数主键;坑库的 16 位 hex 之类是不透明键 ⇒ 恒给 null,
|
|
257
|
+
// 绝不写 NaN(也顺带躲开「hex 恰好全是数字」跟记忆 id 撞号)。
|
|
80
258
|
export function refIdToNum(refId) {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return Number.isSafeInteger(n) ? n : null;
|
|
259
|
+
const text = String(refId);
|
|
260
|
+
if (!isCanonicalDecimal(text)) return null;
|
|
261
|
+
const value = Number(text);
|
|
262
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
86
263
|
}
|
|
87
264
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
* 两侧都是纯数字串 ⇒ **按数值**比(与旧行为逐字节一致,memory 源不受影响);
|
|
91
|
-
* 任一不是数字(如坑库 hex)⇒ 按**字符串字典序**比(稳定、可复现,不再出现 `NaN - NaN` 的乱序)。
|
|
92
|
-
*/
|
|
265
|
+
// refId 平局裁决:两边都是整数串就按数值比;只要一边不是,就按字符串字典序比
|
|
266
|
+
// (稳定可复现,不会出现 NaN 相减导致的乱序)。
|
|
93
267
|
export function cmpRefId(a, b) {
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
268
|
+
const left = String(a);
|
|
269
|
+
const right = String(b);
|
|
270
|
+
if (/^-?\d+$/.test(left) && /^-?\d+$/.test(right)) return Number(left) - Number(right);
|
|
271
|
+
if (left === right) return 0;
|
|
272
|
+
return left < right ? -1 : 1;
|
|
100
273
|
}
|
|
101
274
|
|
|
102
|
-
|
|
103
|
-
* RRF 融合(k=60):score(d) = Σ_r 1/(k + rank_r(d))
|
|
104
|
-
* @param {Array<{source:string, ids:string[]}>} rankLists 各路排名(已按优劣降序,rank 从 1 起)
|
|
105
|
-
*/
|
|
275
|
+
// RRF 融合:score(d) = Σ_r 1/(k + rank_r(d));rankLists 每路已按优劣降序,rank 从 1 起
|
|
106
276
|
export function rrfFuse(rankLists, k = 60) {
|
|
107
|
-
const
|
|
277
|
+
const board = new Map();
|
|
108
278
|
for (const list of rankLists) {
|
|
109
279
|
if (!list || !list.ids) continue;
|
|
110
|
-
list.ids.
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
if (!
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
280
|
+
for (let pos = 0; pos < list.ids.length; pos += 1) {
|
|
281
|
+
const key = String(list.ids[pos]);
|
|
282
|
+
const rank = pos + 1;
|
|
283
|
+
let entry = board.get(key);
|
|
284
|
+
if (!entry) {
|
|
285
|
+
entry = { id: key, score: 0, sources: [], ranks: {} };
|
|
286
|
+
board.set(key, entry);
|
|
287
|
+
}
|
|
288
|
+
entry.score += 1 / (k + rank);
|
|
289
|
+
if (!entry.sources.includes(list.source)) entry.sources.push(list.source);
|
|
290
|
+
entry.ranks[list.source] = rank;
|
|
291
|
+
}
|
|
118
292
|
}
|
|
119
|
-
return [...
|
|
293
|
+
return [...board.values()].sort((left, right) => right.score - left.score || cmpRefId(left.id, right.id));
|
|
120
294
|
}
|
|
121
295
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
296
|
+
// 剔掉 pinned 再按上限截断(向量路看 refId,FTS 路看 id)
|
|
297
|
+
function excludePinned(rows, pinned, take, idField) {
|
|
298
|
+
const kept = [];
|
|
299
|
+
for (const row of rows) {
|
|
300
|
+
if (!pinned.has(String(row[idField]))) kept.push(row);
|
|
301
|
+
}
|
|
302
|
+
return kept.slice(0, take);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// FTS 补位路:没有 MATCH 表达式或没有来源就整路缺席(不拿零分占位)
|
|
306
|
+
function ftsPass({ source, matchExpr, pinned, take, log, degradePrefix }) {
|
|
307
|
+
if (!matchExpr || !source) return { hits: [], matched: 0 };
|
|
308
|
+
const raw = source.ftsSearch(matchExpr, take + pinned.size + 5);
|
|
309
|
+
if (Array.isArray(raw)) return { hits: excludePinned(raw, pinned, take, 'id'), matched: raw.length };
|
|
310
|
+
if (raw && raw.error) log(`${degradePrefix}${raw.error}`);
|
|
311
|
+
return { hits: [], matched: 0 };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 单源召回结果 → 对外命中形状(字段只增不改:`lexiconRank` 是新增位)
|
|
315
|
+
function hitOf(entry, sourceName) {
|
|
316
|
+
return {
|
|
317
|
+
refId: entry.id,
|
|
318
|
+
id: sourceName === 'memory' ? refIdToNum(entry.id) : null,
|
|
319
|
+
source: sourceName,
|
|
320
|
+
score: entry.score,
|
|
321
|
+
sources: entry.sources,
|
|
322
|
+
vectorRank: entry.ranks.vector ?? null,
|
|
323
|
+
ftsRank: entry.ranks.fts ?? null,
|
|
324
|
+
lexiconRank: entry.ranks.lexicon ?? null,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 召回主入口。三类失败各自就地降级,**永不因为编码失败把整轮注入拖死**:
|
|
329
|
+
// 编码失败 ⇒ 固定 Top-K 兜底;FTS 表达式非法/表缺失 ⇒ 该路为空;词表缺失/建索引失败 ⇒ 该路为空。
|
|
127
330
|
export async function recall({
|
|
128
|
-
query,
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
vectorTopK = 20,
|
|
135
|
-
ftsTopK = 20,
|
|
136
|
-
rrfK = 60,
|
|
137
|
-
resultTopK = 8,
|
|
331
|
+
query, store, source, embedder,
|
|
332
|
+
index = null, sourceName = 'memory',
|
|
333
|
+
vectorTopK = 20, ftsTopK = 20, rrfK = 60, resultTopK = 8,
|
|
334
|
+
queryCharLimit = QUERY_CHAR_LIMIT, encodeTimeoutMs = ENCODE_TIMEOUT_MS, fallbackTopK = FALLBACK_TOP_K,
|
|
335
|
+
lexiconEnabled = null, lexiconTopK = null, lexiconFloor = null, lexiconIndex = null,
|
|
336
|
+
recallLog = null,
|
|
138
337
|
log = () => {},
|
|
139
338
|
}) {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
339
|
+
const startedAt = performance.now();
|
|
340
|
+
const shaped = truncateQuery(query, queryCharLimit);
|
|
341
|
+
const admit = shouldTrigger(shaped.text);
|
|
342
|
+
if (!admit.trigger) {
|
|
343
|
+
return { triggered: false, reason: admit.reason, query: String(query ?? ''), hits: [], timings: { totalMs: 0 } };
|
|
144
344
|
}
|
|
145
345
|
|
|
146
|
-
const
|
|
346
|
+
const vectorsDb = store.db;
|
|
147
347
|
const pinned = new Set((source ? source.pinnedIds() : []).map(String));
|
|
148
348
|
|
|
149
|
-
// ①
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
349
|
+
// ① 编码(带预算;失败/超时/空向量都只是"记下来",不抛)
|
|
350
|
+
const encoded = await encodeQuery(embedder, shaped.text, { timeoutMs: encodeTimeoutMs });
|
|
351
|
+
const encodeMs = encoded.elapsedMs;
|
|
352
|
+
|
|
353
|
+
// ② 向量索引(降级路也要:兜底清单的最后一档从这里取)
|
|
354
|
+
const vectorStart = performance.now();
|
|
355
|
+
const builtIndex = index || buildVectorIndex(vectorsDb, { source: sourceName });
|
|
356
|
+
|
|
357
|
+
if (encoded.error) {
|
|
358
|
+
const ids = fallbackCandidateIds({ source, index: builtIndex, pinned, take: fallbackTopK });
|
|
359
|
+
const hits = ids.map((refId, at) => fallbackHit(refId, at, sourceName, rrfK));
|
|
360
|
+
log(`[recall] 编码降级(${encoded.error.name} / ${Math.round(encoded.error.ms)}ms):本轮改用固定 Top-${hits.length} 兜底`);
|
|
361
|
+
const degradedResult = {
|
|
362
|
+
triggered: true,
|
|
363
|
+
reason: admit.reason,
|
|
364
|
+
query: String(query ?? ''),
|
|
365
|
+
hits,
|
|
366
|
+
degraded: { mode: 'fixed-topk', reason: encoded.error.name, topK: Math.trunc(Number(fallbackTopK) || FALLBACK_TOP_K) },
|
|
367
|
+
encodeError: encoded.error,
|
|
368
|
+
timings: {
|
|
369
|
+
encodeMs, vectorMs: performance.now() - vectorStart, ftsMs: 0, lexiconMs: 0, fuseMs: 0,
|
|
370
|
+
totalMs: performance.now() - startedAt,
|
|
371
|
+
},
|
|
372
|
+
stats: {
|
|
373
|
+
sourceName,
|
|
374
|
+
indexSize: builtIndex.count,
|
|
375
|
+
pinnedExcluded: pinned.size,
|
|
376
|
+
queryChars: shaped.chars,
|
|
377
|
+
queryCharsUsed: shaped.text.length,
|
|
378
|
+
queryTruncated: shaped.truncated,
|
|
379
|
+
queryCharLimit: shaped.limit,
|
|
380
|
+
encodeDegraded: true,
|
|
381
|
+
fallbackHits: hits.length,
|
|
382
|
+
fallbackTopK: Math.trunc(Number(fallbackTopK) || FALLBACK_TOP_K),
|
|
383
|
+
vectorCandidates: 0,
|
|
384
|
+
vectorHits: 0,
|
|
385
|
+
ftsTokens: [],
|
|
386
|
+
ftsMatchExpr: null,
|
|
387
|
+
ftsMatched: 0,
|
|
388
|
+
ftsHits: 0,
|
|
389
|
+
ftsSkipped: true,
|
|
390
|
+
lexiconEnabled: false,
|
|
391
|
+
lexiconStatus: 'skipped-degraded',
|
|
392
|
+
lexiconHits: 0,
|
|
393
|
+
lexiconBuildMs: null,
|
|
394
|
+
lexiconSwitchSource: null,
|
|
395
|
+
fused: 0,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
recordRecall(recallLog, degradedResult);
|
|
399
|
+
return degradedResult;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const vectorCandidates = cosineTopK(builtIndex, encoded.vector, vectorTopK + pinned.size + 5);
|
|
403
|
+
const vecHits = excludePinned(vectorCandidates, pinned, vectorTopK, 'refId');
|
|
404
|
+
const vectorMs = performance.now() - vectorStart;
|
|
405
|
+
|
|
406
|
+
// ③ 词表第二路(默认关;开关 = 配置 / 环境变量 / 显式入参,见 lexicon-recall.mjs)
|
|
407
|
+
const lexiconStart = performance.now();
|
|
408
|
+
const lexiconSwitch = resolveLexiconSwitch({ enabled: lexiconEnabled, topK: lexiconTopK, floor: lexiconFloor });
|
|
409
|
+
const lexHits = [];
|
|
410
|
+
let lexiconStatus = 'off';
|
|
411
|
+
let lexiconBuildMs = null;
|
|
412
|
+
if (lexiconSwitch.enabled) {
|
|
413
|
+
try {
|
|
414
|
+
const builtLexicon = lexiconIndex || getLexiconIndex(source);
|
|
415
|
+
if (!builtLexicon) {
|
|
416
|
+
lexiconStatus = 'warming'; // 后台正在建:这一轮先只走向量路(下一轮自然带上)
|
|
417
|
+
} else {
|
|
418
|
+
lexiconBuildMs = Number.isFinite(builtLexicon.buildMs) ? builtLexicon.buildMs : null;
|
|
419
|
+
for (const hit of rankByLexicon(shaped.text, builtLexicon, { topK: lexiconSwitch.topK, floor: lexiconSwitch.floor })) {
|
|
420
|
+
if (lexHits.length >= lexiconSwitch.topK) break;
|
|
421
|
+
if (pinned.has(String(hit.id))) continue;
|
|
422
|
+
lexHits.push(hit);
|
|
423
|
+
}
|
|
424
|
+
lexiconStatus = 'ok';
|
|
425
|
+
}
|
|
426
|
+
} catch (err) {
|
|
427
|
+
lexiconStatus = 'degraded';
|
|
428
|
+
log(`[recall] 词表通道降级:${String((err && err.name) || err)}`);
|
|
174
429
|
}
|
|
175
430
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
// ④
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
);
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const fuseMs = performance.now() - tFuse;
|
|
198
|
-
|
|
199
|
-
const totalMs = performance.now() - t0;
|
|
200
|
-
return {
|
|
431
|
+
const lexiconMs = performance.now() - lexiconStart;
|
|
432
|
+
|
|
433
|
+
// ④ FTS 补位路(默认仍开;表达式非法/表缺失 ⇒ 空路)
|
|
434
|
+
const ftsStart = performance.now();
|
|
435
|
+
const ftsTokens = extractFtsTokens(shaped.text);
|
|
436
|
+
const ftsQuery = buildFtsMatch(ftsTokens);
|
|
437
|
+
const fts = ftsPass({ source, matchExpr: ftsQuery, pinned, take: ftsTopK, log, degradePrefix: '[recall] FTS 补位降级:' });
|
|
438
|
+
const ftsMs = performance.now() - ftsStart;
|
|
439
|
+
|
|
440
|
+
// ⑤ RRF 融合:按开关拼 2~3 路(关词表时 rankLists 与改动前完全一致)
|
|
441
|
+
const fuseStart = performance.now();
|
|
442
|
+
const rankLists = [
|
|
443
|
+
{ source: 'vector', ids: vecHits.map((hit) => String(hit.refId)) },
|
|
444
|
+
{ source: 'fts', ids: fts.hits.map((hit) => String(hit.id)) },
|
|
445
|
+
];
|
|
446
|
+
if (lexiconSwitch.enabled) rankLists.push({ source: 'lexicon', ids: lexHits.map((hit) => String(hit.id)) });
|
|
447
|
+
const merged = rrfFuse(rankLists, rrfK);
|
|
448
|
+
const hits = merged.slice(0, Number(resultTopK) || 8).map((entry) => hitOf(entry, sourceName));
|
|
449
|
+
const fuseMs = performance.now() - fuseStart;
|
|
450
|
+
|
|
451
|
+
const result = {
|
|
201
452
|
triggered: true,
|
|
202
|
-
reason:
|
|
453
|
+
reason: admit.reason,
|
|
203
454
|
query: String(query ?? ''),
|
|
204
455
|
hits,
|
|
205
|
-
|
|
456
|
+
encodeError: null,
|
|
457
|
+
timings: { encodeMs, vectorMs, ftsMs, lexiconMs, fuseMs, totalMs: performance.now() - startedAt },
|
|
206
458
|
stats: {
|
|
207
459
|
sourceName,
|
|
208
|
-
indexSize:
|
|
460
|
+
indexSize: builtIndex.count,
|
|
209
461
|
pinnedExcluded: pinned.size,
|
|
210
|
-
|
|
462
|
+
queryChars: shaped.chars,
|
|
463
|
+
queryCharsUsed: shaped.text.length,
|
|
464
|
+
queryTruncated: shaped.truncated,
|
|
465
|
+
queryCharLimit: shaped.limit,
|
|
466
|
+
encodeDegraded: false,
|
|
467
|
+
fallbackHits: 0,
|
|
468
|
+
fallbackTopK: Math.trunc(Number(fallbackTopK) || FALLBACK_TOP_K),
|
|
469
|
+
vectorCandidates: vectorCandidates.length,
|
|
211
470
|
vectorHits: vecHits.length,
|
|
212
|
-
ftsTokens
|
|
213
|
-
ftsMatchExpr:
|
|
214
|
-
ftsMatched,
|
|
215
|
-
ftsHits:
|
|
216
|
-
|
|
471
|
+
ftsTokens,
|
|
472
|
+
ftsMatchExpr: ftsQuery,
|
|
473
|
+
ftsMatched: fts.matched,
|
|
474
|
+
ftsHits: fts.hits.length,
|
|
475
|
+
ftsSkipped: false,
|
|
476
|
+
lexiconEnabled: lexiconSwitch.enabled,
|
|
477
|
+
lexiconStatus,
|
|
478
|
+
lexiconHits: lexHits.length,
|
|
479
|
+
lexiconBuildMs,
|
|
480
|
+
lexiconSwitchSource: lexiconSwitch.source,
|
|
481
|
+
fused: merged.length,
|
|
217
482
|
},
|
|
218
483
|
};
|
|
484
|
+
recordRecall(recallLog, result);
|
|
485
|
+
return result;
|
|
219
486
|
}
|
|
220
487
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
488
|
+
// 融合键:`<来源名>:<refId>`(不加来源前缀的话,两源同 id 会互相吃掉)
|
|
489
|
+
function fuseKey(name, refId) {
|
|
490
|
+
return `${name}:${String(refId)}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 多来源召回结果 → 对外命中形状(字段只增不改:`lexiconRank` / `fallbackRank` 是新增位)
|
|
494
|
+
function multiHitOf(entry, info) {
|
|
495
|
+
const label = info.name;
|
|
496
|
+
return {
|
|
497
|
+
key: entry.id,
|
|
498
|
+
source: label,
|
|
499
|
+
refId: info.refId,
|
|
500
|
+
id: label === 'memory' ? refIdToNum(info.refId) : null,
|
|
501
|
+
score: entry.score,
|
|
502
|
+
sources: entry.sources,
|
|
503
|
+
vectorRank: label ? entry.ranks[`vector:${label}`] ?? null : null,
|
|
504
|
+
ftsRank: label ? entry.ranks[`fts:${label}`] ?? null : null,
|
|
505
|
+
lexiconRank: label ? entry.ranks[`lexicon:${label}`] ?? null : null,
|
|
506
|
+
fallbackRank: entry.ranks.fallback ?? null,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 多来源召回:query 只编码一次,逐来源做「向量 top-K + 词表 + FTS 补位」,跨来源再 RRF 融合。
|
|
511
|
+
// 每个命中带 source 归属;某来源没库/没向量/报错 ⇒ 记一行跳过,不影响别的来源。
|
|
512
|
+
// 编码失败与单来源同口径:整轮降级成「每源固定 Top-K」,绝不返回空、绝不抛错。
|
|
229
513
|
export async function recallMulti({
|
|
230
|
-
query,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
rrfK = 60,
|
|
236
|
-
resultTopK = 8,
|
|
514
|
+
query, store, entries, embedder,
|
|
515
|
+
perSourceTopK = 5, rrfK = 60, resultTopK = 8,
|
|
516
|
+
queryCharLimit = QUERY_CHAR_LIMIT, encodeTimeoutMs = ENCODE_TIMEOUT_MS,
|
|
517
|
+
lexiconEnabled = null, lexiconTopK = null, lexiconFloor = null,
|
|
518
|
+
recallLog = null,
|
|
237
519
|
log = () => {},
|
|
238
520
|
}) {
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
|
|
521
|
+
const startedAt = performance.now();
|
|
522
|
+
const shaped = truncateQuery(query, queryCharLimit);
|
|
523
|
+
const admit = shouldTrigger(shaped.text);
|
|
524
|
+
if (!admit.trigger) {
|
|
242
525
|
return {
|
|
243
|
-
triggered: false,
|
|
244
|
-
|
|
245
|
-
query: String(query ?? ''),
|
|
246
|
-
hits: [],
|
|
247
|
-
perSource: {},
|
|
248
|
-
timings: { totalMs: 0 },
|
|
526
|
+
triggered: false, reason: admit.reason, query: String(query ?? ''), hits: [],
|
|
527
|
+
perSource: {}, timings: { totalMs: 0 },
|
|
249
528
|
};
|
|
250
529
|
}
|
|
251
530
|
|
|
252
|
-
const
|
|
253
|
-
const
|
|
254
|
-
const
|
|
255
|
-
const
|
|
531
|
+
const vectorsDb = store.db;
|
|
532
|
+
const encoded = await encodeQuery(embedder, shaped.text, { timeoutMs: encodeTimeoutMs });
|
|
533
|
+
const encodeMs = encoded.elapsedMs;
|
|
534
|
+
const degraded = Boolean(encoded.error);
|
|
256
535
|
|
|
257
|
-
const
|
|
258
|
-
const
|
|
536
|
+
const lexiconSwitch = resolveLexiconSwitch({ enabled: lexiconEnabled, topK: lexiconTopK, floor: lexiconFloor });
|
|
537
|
+
const ftsTokens = extractFtsTokens(shaped.text);
|
|
538
|
+
const ftsQuery = buildFtsMatch(ftsTokens);
|
|
259
539
|
const rankLists = [];
|
|
260
|
-
const
|
|
540
|
+
const origin = new Map();
|
|
261
541
|
const perSource = {};
|
|
262
|
-
const
|
|
542
|
+
const fallbackKeys = [];
|
|
263
543
|
|
|
264
|
-
const
|
|
265
|
-
for (const
|
|
266
|
-
const name = String(
|
|
267
|
-
const src =
|
|
268
|
-
const rec = {
|
|
544
|
+
const vectorStart = performance.now();
|
|
545
|
+
for (const entry of entries || []) {
|
|
546
|
+
const name = String(entry.name);
|
|
547
|
+
const src = entry.source || null;
|
|
548
|
+
const rec = {
|
|
549
|
+
indexSize: 0, pinnedExcluded: 0, vectorHits: 0, ftsHits: 0,
|
|
550
|
+
lexiconHits: 0, lexiconStatus: 'off', fallbackHits: 0, error: null,
|
|
551
|
+
};
|
|
269
552
|
try {
|
|
270
|
-
const
|
|
271
|
-
rec.indexSize =
|
|
553
|
+
const builtIndex = entry.index || buildVectorIndex(vectorsDb, { source: name });
|
|
554
|
+
rec.indexSize = builtIndex.count;
|
|
272
555
|
const pinned = new Set((src ? src.pinnedIds() : []).map(String));
|
|
273
556
|
rec.pinnedExcluded = pinned.size;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
557
|
+
|
|
558
|
+
if (degraded) {
|
|
559
|
+
const ids = fallbackCandidateIds({ source: src, index: builtIndex, pinned, take: perSourceTopK });
|
|
560
|
+
rec.fallbackHits = ids.length;
|
|
561
|
+
for (const refId of ids) {
|
|
562
|
+
const key = fuseKey(name, refId);
|
|
563
|
+
fallbackKeys.push(key);
|
|
564
|
+
origin.set(key, { name, refId });
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
const candidates = cosineTopK(builtIndex, encoded.vector, perSourceTopK + pinned.size + 5);
|
|
568
|
+
const vecHits = excludePinned(candidates, pinned, perSourceTopK, 'refId');
|
|
569
|
+
rec.vectorHits = vecHits.length;
|
|
570
|
+
rankLists.push({ source: `vector:${name}`, ids: vecHits.map((hit) => fuseKey(name, hit.refId)) });
|
|
571
|
+
for (const hit of vecHits) origin.set(fuseKey(name, hit.refId), { name, refId: String(hit.refId) });
|
|
572
|
+
|
|
573
|
+
if (lexiconSwitch.enabled) {
|
|
574
|
+
try {
|
|
575
|
+
const builtLexicon = getLexiconIndex(src);
|
|
576
|
+
if (!builtLexicon) {
|
|
577
|
+
rec.lexiconStatus = 'warming';
|
|
578
|
+
} else {
|
|
579
|
+
const lexHits = rankByLexicon(shaped.text, builtLexicon, { topK: lexiconSwitch.topK, floor: lexiconSwitch.floor })
|
|
580
|
+
.filter((hit) => !pinned.has(String(hit.id)))
|
|
581
|
+
.slice(0, perSourceTopK);
|
|
582
|
+
rec.lexiconHits = lexHits.length;
|
|
583
|
+
rec.lexiconStatus = 'ok';
|
|
584
|
+
rankLists.push({ source: `lexicon:${name}`, ids: lexHits.map((hit) => fuseKey(name, hit.id)) });
|
|
585
|
+
for (const hit of lexHits) origin.set(fuseKey(name, hit.id), { name, refId: String(hit.id) });
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
rec.lexiconStatus = 'degraded';
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (ftsQuery && src) {
|
|
593
|
+
const raw = src.ftsSearch(ftsQuery, perSourceTopK + pinned.size + 5);
|
|
594
|
+
if (Array.isArray(raw)) {
|
|
595
|
+
const ftsHits = excludePinned(raw, pinned, perSourceTopK, 'id');
|
|
596
|
+
rec.ftsHits = ftsHits.length;
|
|
597
|
+
rankLists.push({ source: `fts:${name}`, ids: ftsHits.map((hit) => fuseKey(name, hit.id)) });
|
|
598
|
+
for (const hit of ftsHits) origin.set(fuseKey(name, hit.id), { name, refId: String(hit.id) });
|
|
599
|
+
} else if (raw && raw.error) {
|
|
600
|
+
log(`[recallMulti] ${name} FTS 补位降级:${raw.error}`);
|
|
601
|
+
}
|
|
289
602
|
}
|
|
290
603
|
}
|
|
291
604
|
} catch (err) {
|
|
@@ -294,38 +607,50 @@ export async function recallMulti({
|
|
|
294
607
|
}
|
|
295
608
|
perSource[name] = rec;
|
|
296
609
|
}
|
|
297
|
-
|
|
610
|
+
if (degraded) rankLists.push({ source: 'fallback', ids: fallbackKeys });
|
|
611
|
+
const vectorMs = performance.now() - vectorStart;
|
|
298
612
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
score: h.score,
|
|
309
|
-
sources: h.sources,
|
|
310
|
-
vectorRank: m.name ? h.ranks[`vector:${m.name}`] ?? null : null,
|
|
311
|
-
ftsRank: m.name ? h.ranks[`fts:${m.name}`] ?? null : null,
|
|
312
|
-
};
|
|
613
|
+
if (degraded) {
|
|
614
|
+
log(`[recallMulti] 编码降级(${encoded.error.name} / ${Math.round(encoded.error.ms)}ms):${fallbackKeys.length} 条固定兜底`);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const fuseStart = performance.now();
|
|
618
|
+
const merged = rrfFuse(rankLists, rrfK);
|
|
619
|
+
const hits = merged.slice(0, Number(resultTopK) || 8).map((entry) => {
|
|
620
|
+
const info = origin.get(entry.id) || { name: null, refId: entry.id };
|
|
621
|
+
return multiHitOf(entry, info);
|
|
313
622
|
});
|
|
314
|
-
const fuseMs = performance.now() -
|
|
623
|
+
const fuseMs = performance.now() - fuseStart;
|
|
315
624
|
|
|
316
|
-
|
|
625
|
+
const result = {
|
|
317
626
|
triggered: true,
|
|
318
|
-
reason:
|
|
627
|
+
reason: admit.reason,
|
|
319
628
|
query: String(query ?? ''),
|
|
320
629
|
hits,
|
|
630
|
+
degraded: degraded
|
|
631
|
+
? { mode: 'fixed-topk', reason: encoded.error.name, topK: perSourceTopK, scope: 'per-source' }
|
|
632
|
+
: null,
|
|
633
|
+
encodeError: encoded.error,
|
|
321
634
|
perSource,
|
|
322
|
-
timings: { encodeMs, vectorMs, fuseMs, totalMs: performance.now() -
|
|
635
|
+
timings: { encodeMs, vectorMs, fuseMs, totalMs: performance.now() - startedAt },
|
|
323
636
|
stats: {
|
|
324
|
-
sources: (entries || []).map((
|
|
325
|
-
|
|
326
|
-
|
|
637
|
+
sources: (entries || []).map((entry) => String(entry.name)),
|
|
638
|
+
queryChars: shaped.chars,
|
|
639
|
+
queryCharsUsed: shaped.text.length,
|
|
640
|
+
queryTruncated: shaped.truncated,
|
|
641
|
+
queryCharLimit: shaped.limit,
|
|
642
|
+
encodeDegraded: degraded,
|
|
643
|
+
fallbackHits: fallbackKeys.length,
|
|
644
|
+
lexiconEnabled: lexiconSwitch.enabled,
|
|
645
|
+
lexiconStatus: degraded ? 'skipped-degraded' : (lexiconSwitch.enabled ? 'on' : 'off'),
|
|
646
|
+
lexiconSwitchSource: lexiconSwitch.source,
|
|
647
|
+
ftsTokens,
|
|
648
|
+
ftsMatchExpr: ftsQuery,
|
|
649
|
+
ftsSkipped: degraded,
|
|
327
650
|
rankLists: rankLists.length,
|
|
328
|
-
fused:
|
|
651
|
+
fused: merged.length,
|
|
329
652
|
},
|
|
330
653
|
};
|
|
654
|
+
recordRecall(recallLog, result);
|
|
655
|
+
return result;
|
|
331
656
|
}
|