@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.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 +32 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +294 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 召回(方案 §3.3)
|
|
2
|
+
//
|
|
3
|
+
// 向量为主(余弦 top-20)+ FTS5 仅补标签/英文/数字 + RRF(k=60) 融合 + 排除 pinned
|
|
4
|
+
// 触发:非空即触发(不用 ≥10 字阈值——短暗号「退下吧」「整夜干活」恰恰最需要记忆);
|
|
5
|
+
// 纯符号 / 单字跳过。
|
|
6
|
+
import { loadMatrix } from './vectors-store.mjs';
|
|
7
|
+
|
|
8
|
+
/** 触发判定(方案 §3.3 Z16 已吸收) */
|
|
9
|
+
export function shouldTrigger(rawQuery) {
|
|
10
|
+
const q = String(rawQuery ?? '').trim();
|
|
11
|
+
if (!q) return { trigger: false, reason: 'empty' };
|
|
12
|
+
const semantic = q.match(/[\p{L}\p{N}]/gu) || [];
|
|
13
|
+
if (semantic.length === 0) return { trigger: false, reason: 'symbols-only' };
|
|
14
|
+
if (semantic.length < 2) return { trigger: false, reason: 'single-char' };
|
|
15
|
+
return { trigger: true, reason: 'non-empty' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 抽 FTS 补位 token:只取英文/数字(A4:中文连续串在 unicode61 下几近不可用) */
|
|
19
|
+
export function extractFtsTokens(rawQuery, max = 8) {
|
|
20
|
+
const m = String(rawQuery ?? '').match(/[A-Za-z0-9][A-Za-z0-9_.-]*/g) || [];
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const t of m) {
|
|
23
|
+
if (t.length < 2) continue; // 单字母/单数字噪声大
|
|
24
|
+
if (!out.includes(t)) out.push(t);
|
|
25
|
+
if (out.length >= max) break;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 构造 FTS5 MATCH 表达式:每个 token 加双引号(防语法注入),OR 连接 */
|
|
31
|
+
export function buildFtsMatch(tokens) {
|
|
32
|
+
if (!tokens || !tokens.length) return null;
|
|
33
|
+
return tokens.map((t) => `"${String(t).replace(/"/g, '')}"`).join(' OR ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 载入向量矩阵 + 行范数(余弦召回用;一次构建,多次召回) */
|
|
37
|
+
export function buildVectorIndex(db, { source = 'memory', model = null } = {}) {
|
|
38
|
+
const matrix = loadMatrix(db, source, model);
|
|
39
|
+
const norms = new Float32Array(matrix.count);
|
|
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() };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 余弦 top-K(纯内存暴力;万级候选毫秒级,方案 §3.3) */
|
|
50
|
+
export function cosineTopK(index, qvec, topK) {
|
|
51
|
+
const { data, dim, count, norms } = index;
|
|
52
|
+
if (!dim || !count) return [];
|
|
53
|
+
let qn = 0;
|
|
54
|
+
for (let i = 0; i < dim; i += 1) qn += qvec[i] * qvec[i];
|
|
55
|
+
qn = Math.sqrt(qn) || 1;
|
|
56
|
+
const scores = new Float32Array(count);
|
|
57
|
+
for (let r = 0; r < count; r += 1) {
|
|
58
|
+
let s = 0;
|
|
59
|
+
const off = r * dim;
|
|
60
|
+
for (let i = 0; i < dim; i += 1) s += data[off + i] * qvec[i];
|
|
61
|
+
scores[r] = s / (qn * norms[r]);
|
|
62
|
+
}
|
|
63
|
+
const order = Array.from({ length: count }, (_, i) => i);
|
|
64
|
+
order.sort((a, b) => scores[b] - scores[a] || a - b);
|
|
65
|
+
const k = Math.min(Number(topK) || 0, count);
|
|
66
|
+
const out = [];
|
|
67
|
+
for (let i = 0; i < k; i += 1) {
|
|
68
|
+
const idx = order[i];
|
|
69
|
+
out.push({ index: idx, refId: index.refIds[idx], id: index.refIdsNum[idx], score: scores[idx] });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* refId → 数字 id。
|
|
76
|
+
* ⚠️ 语义定稿(方案 C1 + 2026-10-10 落地):**数字 `id` 只是 memory 源的兼容字段**
|
|
77
|
+
* (`memories.id` 是整数主键);其它来源(坑库 16 位 hex、未来 policy)的 `ref_id` 是**不透明键**
|
|
78
|
+
* ⇒ 一律给 `null`,绝不写 `NaN`(也避免 hex 全数字时与记忆 id 撞号)。
|
|
79
|
+
*/
|
|
80
|
+
export function refIdToNum(refId) {
|
|
81
|
+
const s = String(refId);
|
|
82
|
+
// 前导零视为不透明键(记忆 id 是自增整数,不会带前导零)
|
|
83
|
+
if (!/^(0|[1-9]\d*)$/.test(s)) return null;
|
|
84
|
+
const n = Number(s);
|
|
85
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* refId 平局裁决比较器(方案 C1):
|
|
90
|
+
* 两侧都是纯数字串 ⇒ **按数值**比(与旧行为逐字节一致,memory 源不受影响);
|
|
91
|
+
* 任一不是数字(如坑库 hex)⇒ 按**字符串字典序**比(稳定、可复现,不再出现 `NaN - NaN` 的乱序)。
|
|
92
|
+
*/
|
|
93
|
+
export function cmpRefId(a, b) {
|
|
94
|
+
const sa = String(a);
|
|
95
|
+
const sb = String(b);
|
|
96
|
+
const na = /^-?\d+$/.test(sa);
|
|
97
|
+
const nb = /^-?\d+$/.test(sb);
|
|
98
|
+
if (na && nb) return Number(sa) - Number(sb);
|
|
99
|
+
return sa < sb ? -1 : sa > sb ? 1 : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* RRF 融合(k=60):score(d) = Σ_r 1/(k + rank_r(d))
|
|
104
|
+
* @param {Array<{source:string, ids:string[]}>} rankLists 各路排名(已按优劣降序,rank 从 1 起)
|
|
105
|
+
*/
|
|
106
|
+
export function rrfFuse(rankLists, k = 60) {
|
|
107
|
+
const acc = new Map();
|
|
108
|
+
for (const list of rankLists) {
|
|
109
|
+
if (!list || !list.ids) continue;
|
|
110
|
+
list.ids.forEach((id, i) => {
|
|
111
|
+
const rank = i + 1;
|
|
112
|
+
const cur = acc.get(String(id)) || { id: String(id), score: 0, sources: [], ranks: {} };
|
|
113
|
+
cur.score += 1 / (k + rank);
|
|
114
|
+
if (!cur.sources.includes(list.source)) cur.sources.push(list.source);
|
|
115
|
+
cur.ranks[list.source] = rank;
|
|
116
|
+
acc.set(String(id), cur);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return [...acc.values()].sort((a, b) => b.score - a.score || cmpRefId(a.id, b.id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 召回主入口。
|
|
124
|
+
* fail-open:任何异常都由调用方兜住(本函数内部对 FTS 异常降级为空路)。
|
|
125
|
+
* @returns {Promise<object>}
|
|
126
|
+
*/
|
|
127
|
+
export async function recall({
|
|
128
|
+
query,
|
|
129
|
+
store,
|
|
130
|
+
source,
|
|
131
|
+
embedder,
|
|
132
|
+
index = null,
|
|
133
|
+
sourceName = 'memory',
|
|
134
|
+
vectorTopK = 20,
|
|
135
|
+
ftsTopK = 20,
|
|
136
|
+
rrfK = 60,
|
|
137
|
+
resultTopK = 8,
|
|
138
|
+
log = () => {},
|
|
139
|
+
}) {
|
|
140
|
+
const t0 = performance.now();
|
|
141
|
+
const gate = shouldTrigger(query);
|
|
142
|
+
if (!gate.trigger) {
|
|
143
|
+
return { triggered: false, reason: gate.reason, query: String(query ?? ''), hits: [], timings: { totalMs: 0 } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const db = store.db;
|
|
147
|
+
const pinned = new Set((source ? source.pinnedIds() : []).map(String));
|
|
148
|
+
|
|
149
|
+
// ① 查询编码
|
|
150
|
+
const tEnc = performance.now();
|
|
151
|
+
const qvec = await embedder.encode(query);
|
|
152
|
+
const encodeMs = performance.now() - tEnc;
|
|
153
|
+
|
|
154
|
+
// ② 向量路
|
|
155
|
+
const tVec = performance.now();
|
|
156
|
+
const idx = index || buildVectorIndex(db, { source: sourceName });
|
|
157
|
+
const rawVec = cosineTopK(idx, qvec, vectorTopK + pinned.size + 5);
|
|
158
|
+
const vecHits = rawVec.filter((h) => !pinned.has(String(h.refId))).slice(0, vectorTopK);
|
|
159
|
+
const vectorMs = performance.now() - tVec;
|
|
160
|
+
|
|
161
|
+
// ③ FTS 补位路(仅英文/数字;缺失时不参与,不做零分占位)
|
|
162
|
+
const tFts = performance.now();
|
|
163
|
+
let ftsHits = [];
|
|
164
|
+
let ftsMatched = 0;
|
|
165
|
+
const tokens = extractFtsTokens(query);
|
|
166
|
+
const matchExpr = buildFtsMatch(tokens);
|
|
167
|
+
if (matchExpr && source) {
|
|
168
|
+
const r = source.ftsSearch(matchExpr, ftsTopK + pinned.size + 5);
|
|
169
|
+
if (Array.isArray(r)) {
|
|
170
|
+
ftsMatched = r.length;
|
|
171
|
+
ftsHits = r.filter((h) => !pinned.has(String(h.id))).slice(0, ftsTopK);
|
|
172
|
+
} else if (r && r.error) {
|
|
173
|
+
log(`[recall] FTS 补位降级:${r.error}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const ftsMs = performance.now() - tFts;
|
|
177
|
+
|
|
178
|
+
// ④ RRF 融合
|
|
179
|
+
const tFuse = performance.now();
|
|
180
|
+
const fused = rrfFuse(
|
|
181
|
+
[
|
|
182
|
+
{ source: 'vector', ids: vecHits.map((h) => String(h.refId)) },
|
|
183
|
+
{ source: 'fts', ids: ftsHits.map((h) => String(h.id)) },
|
|
184
|
+
],
|
|
185
|
+
rrfK,
|
|
186
|
+
);
|
|
187
|
+
const hits = fused.slice(0, Number(resultTopK) || 8).map((h) => ({
|
|
188
|
+
refId: h.id,
|
|
189
|
+
// 数字 id 只对 memory 源有意义(见 refIdToNum 注释);其它来源恒为 null
|
|
190
|
+
id: sourceName === 'memory' ? refIdToNum(h.id) : null,
|
|
191
|
+
source: sourceName,
|
|
192
|
+
score: h.score,
|
|
193
|
+
sources: h.sources,
|
|
194
|
+
vectorRank: h.ranks.vector ?? null,
|
|
195
|
+
ftsRank: h.ranks.fts ?? null,
|
|
196
|
+
}));
|
|
197
|
+
const fuseMs = performance.now() - tFuse;
|
|
198
|
+
|
|
199
|
+
const totalMs = performance.now() - t0;
|
|
200
|
+
return {
|
|
201
|
+
triggered: true,
|
|
202
|
+
reason: gate.reason,
|
|
203
|
+
query: String(query ?? ''),
|
|
204
|
+
hits,
|
|
205
|
+
timings: { encodeMs, vectorMs, ftsMs, fuseMs, totalMs },
|
|
206
|
+
stats: {
|
|
207
|
+
sourceName,
|
|
208
|
+
indexSize: idx.count,
|
|
209
|
+
pinnedExcluded: pinned.size,
|
|
210
|
+
vectorCandidates: rawVec.length,
|
|
211
|
+
vectorHits: vecHits.length,
|
|
212
|
+
ftsTokens: tokens,
|
|
213
|
+
ftsMatchExpr: matchExpr,
|
|
214
|
+
ftsMatched,
|
|
215
|
+
ftsHits: ftsHits.length,
|
|
216
|
+
fused: fused.length,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* **多来源召回**(方案 §3 A.2 第 5 行新增入口):query 只编码一次,逐来源各做「向量 top-K + FTS 补位」,
|
|
223
|
+
* 跨来源用 RRF 融合;每个命中带 `source` 归属,融合键 = `<source名>:<refId>`(**否则两源同 id 会互相吃掉**)。
|
|
224
|
+
*
|
|
225
|
+
* 逐来源 fail-open:某来源无库/无向量/报错 ⇒ 记一行、跳过该来源,**不影响其他来源**。
|
|
226
|
+
* @param {object} p
|
|
227
|
+
* @param {Array<{name:string, source?:object, index?:object}>} p.entries 来源列表(name 即 `vectors.source`)
|
|
228
|
+
*/
|
|
229
|
+
export async function recallMulti({
|
|
230
|
+
query,
|
|
231
|
+
store,
|
|
232
|
+
entries,
|
|
233
|
+
embedder,
|
|
234
|
+
perSourceTopK = 5,
|
|
235
|
+
rrfK = 60,
|
|
236
|
+
resultTopK = 8,
|
|
237
|
+
log = () => {},
|
|
238
|
+
}) {
|
|
239
|
+
const t0 = performance.now();
|
|
240
|
+
const gate = shouldTrigger(query);
|
|
241
|
+
if (!gate.trigger) {
|
|
242
|
+
return {
|
|
243
|
+
triggered: false,
|
|
244
|
+
reason: gate.reason,
|
|
245
|
+
query: String(query ?? ''),
|
|
246
|
+
hits: [],
|
|
247
|
+
perSource: {},
|
|
248
|
+
timings: { totalMs: 0 },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const db = store.db;
|
|
253
|
+
const tEnc = performance.now();
|
|
254
|
+
const qvec = await embedder.encode(query);
|
|
255
|
+
const encodeMs = performance.now() - tEnc;
|
|
256
|
+
|
|
257
|
+
const tokens = extractFtsTokens(query);
|
|
258
|
+
const matchExpr = buildFtsMatch(tokens);
|
|
259
|
+
const rankLists = [];
|
|
260
|
+
const meta = new Map();
|
|
261
|
+
const perSource = {};
|
|
262
|
+
const keyOf = (name, refId) => `${name}:${String(refId)}`;
|
|
263
|
+
|
|
264
|
+
const tVec = performance.now();
|
|
265
|
+
for (const e of entries || []) {
|
|
266
|
+
const name = String(e.name);
|
|
267
|
+
const src = e.source || null;
|
|
268
|
+
const rec = { indexSize: 0, pinnedExcluded: 0, vectorHits: 0, ftsHits: 0, error: null };
|
|
269
|
+
try {
|
|
270
|
+
const idx = e.index || buildVectorIndex(db, { source: name });
|
|
271
|
+
rec.indexSize = idx.count;
|
|
272
|
+
const pinned = new Set((src ? src.pinnedIds() : []).map(String));
|
|
273
|
+
rec.pinnedExcluded = pinned.size;
|
|
274
|
+
const rawVec = cosineTopK(idx, qvec, perSourceTopK + pinned.size + 5);
|
|
275
|
+
const vecHits = rawVec.filter((h) => !pinned.has(String(h.refId))).slice(0, perSourceTopK);
|
|
276
|
+
rec.vectorHits = vecHits.length;
|
|
277
|
+
rankLists.push({ source: `vector:${name}`, ids: vecHits.map((h) => keyOf(name, h.refId)) });
|
|
278
|
+
for (const h of vecHits) meta.set(keyOf(name, h.refId), { name, refId: String(h.refId) });
|
|
279
|
+
|
|
280
|
+
if (matchExpr && src) {
|
|
281
|
+
const r = src.ftsSearch(matchExpr, perSourceTopK + pinned.size + 5);
|
|
282
|
+
if (Array.isArray(r)) {
|
|
283
|
+
const ftsHits = r.filter((h) => !pinned.has(String(h.id))).slice(0, perSourceTopK);
|
|
284
|
+
rec.ftsHits = ftsHits.length;
|
|
285
|
+
rankLists.push({ source: `fts:${name}`, ids: ftsHits.map((h) => keyOf(name, h.id)) });
|
|
286
|
+
for (const h of ftsHits) meta.set(keyOf(name, h.id), { name, refId: String(h.id) });
|
|
287
|
+
} else if (r && r.error) {
|
|
288
|
+
log(`[recallMulti] ${name} FTS 补位降级:${r.error}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} catch (err) {
|
|
292
|
+
rec.error = String(err && err.message ? err.message : err);
|
|
293
|
+
log(`[recallMulti] 来源 ${name} 跳过:${rec.error}`);
|
|
294
|
+
}
|
|
295
|
+
perSource[name] = rec;
|
|
296
|
+
}
|
|
297
|
+
const vectorMs = performance.now() - tVec;
|
|
298
|
+
|
|
299
|
+
const tFuse = performance.now();
|
|
300
|
+
const fused = rrfFuse(rankLists, rrfK);
|
|
301
|
+
const hits = fused.slice(0, Number(resultTopK) || 8).map((h) => {
|
|
302
|
+
const m = meta.get(h.id) || { name: null, refId: h.id };
|
|
303
|
+
return {
|
|
304
|
+
key: h.id,
|
|
305
|
+
source: m.name,
|
|
306
|
+
refId: m.refId,
|
|
307
|
+
id: m.name === 'memory' ? refIdToNum(m.refId) : null,
|
|
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
|
+
};
|
|
313
|
+
});
|
|
314
|
+
const fuseMs = performance.now() - tFuse;
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
triggered: true,
|
|
318
|
+
reason: gate.reason,
|
|
319
|
+
query: String(query ?? ''),
|
|
320
|
+
hits,
|
|
321
|
+
perSource,
|
|
322
|
+
timings: { encodeMs, vectorMs, fuseMs, totalMs: performance.now() - t0 },
|
|
323
|
+
stats: {
|
|
324
|
+
sources: (entries || []).map((e) => String(e.name)),
|
|
325
|
+
ftsTokens: tokens,
|
|
326
|
+
ftsMatchExpr: matchExpr,
|
|
327
|
+
rankLists: rankLists.length,
|
|
328
|
+
fused: fused.length,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 启动自检(方案 §8.3 交付物 #9)
|
|
2
|
+
//
|
|
3
|
+
// 四判据:模型可用性 / 维度一致 / 表存在性(缺则重建)/ hostId 校验。
|
|
4
|
+
// 自检失败绝不阻断启动:返回结构化结果,由调用方决定是否降级。
|
|
5
|
+
import { SCHEMA_VERSION } from './config.mjs';
|
|
6
|
+
import { META_KEYS, getMeta, getMetaFor, setMeta, countVectors } from './vectors-store.mjs';
|
|
7
|
+
|
|
8
|
+
export async function runSelfcheck({
|
|
9
|
+
store,
|
|
10
|
+
source = null,
|
|
11
|
+
sources = null,
|
|
12
|
+
sourceName = 'memory',
|
|
13
|
+
embedder,
|
|
14
|
+
config,
|
|
15
|
+
allowHostMismatch = true,
|
|
16
|
+
}) {
|
|
17
|
+
const db = store.db;
|
|
18
|
+
const checks = [];
|
|
19
|
+
const add = (name, status, detail, extra = {}) => {
|
|
20
|
+
checks.push({ name, status, detail, ...extra });
|
|
21
|
+
return status;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ① 表存在性(缺则重建)
|
|
25
|
+
const t = store.schema.tables;
|
|
26
|
+
if (t.vectors && t.meta) {
|
|
27
|
+
add(
|
|
28
|
+
'表存在性',
|
|
29
|
+
'ok',
|
|
30
|
+
store.schema.created.length
|
|
31
|
+
? `缺表已重建:${store.schema.created.join(', ')}`
|
|
32
|
+
: 'vectors / meta 均在位',
|
|
33
|
+
{ created: store.schema.created },
|
|
34
|
+
);
|
|
35
|
+
} else {
|
|
36
|
+
add('表存在性', 'fail', 'vectors 或 meta 表缺失且重建失败');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ② schemaVersion
|
|
40
|
+
const sv = getMeta(db, META_KEYS.schemaVersion);
|
|
41
|
+
add(
|
|
42
|
+
'schemaVersion',
|
|
43
|
+
sv === SCHEMA_VERSION ? 'ok' : 'warn',
|
|
44
|
+
sv === SCHEMA_VERSION ? `=${sv}` : `库内为 ${sv ?? '(空)'},代码期望 ${SCHEMA_VERSION}`,
|
|
45
|
+
{ schemaVersion: sv },
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// ③ 模型可用性
|
|
49
|
+
let health = { ready: false, error: 'not probed' };
|
|
50
|
+
try {
|
|
51
|
+
health = await embedder.health();
|
|
52
|
+
} catch (e) {
|
|
53
|
+
health = { ready: false, error: String(e && e.message ? e.message : e) };
|
|
54
|
+
}
|
|
55
|
+
add(
|
|
56
|
+
'模型可用性',
|
|
57
|
+
health.ready ? 'ok' : 'fail',
|
|
58
|
+
health.ready
|
|
59
|
+
? `${health.model ?? embedder.modelId} @ ${health.modelDir ?? health.bridge ?? embedder.kind}`
|
|
60
|
+
: `不可用:${health.error || 'unknown'}`,
|
|
61
|
+
{ health },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// ④ 维度一致(编码探测 + 与库内 meta / 配置三方比对)
|
|
65
|
+
let probeDim = null;
|
|
66
|
+
let probeError = null;
|
|
67
|
+
try {
|
|
68
|
+
const v = await embedder.encode('维度自检');
|
|
69
|
+
probeDim = v.length;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
probeError = String(e && e.message ? e.message : e);
|
|
72
|
+
}
|
|
73
|
+
const metaDim = getMetaFor(db, META_KEYS.dim, sourceName);
|
|
74
|
+
if (probeError) {
|
|
75
|
+
add('维度一致', 'fail', `编码探测失败:${probeError}`);
|
|
76
|
+
} else {
|
|
77
|
+
const configOk = Number(probeDim) === Number(config.dim);
|
|
78
|
+
const metaOk = metaDim == null || Number(metaDim) === Number(probeDim);
|
|
79
|
+
const status = configOk && metaOk ? 'ok' : 'fail';
|
|
80
|
+
add(
|
|
81
|
+
'维度一致',
|
|
82
|
+
status,
|
|
83
|
+
`实测 ${probeDim} 维 / 配置 ${config.dim} / 库内 ${metaDim ?? '(空)'}` +
|
|
84
|
+
(status === 'ok' ? '' : ' ⇒ 需全量重建(--full)'),
|
|
85
|
+
{ probeDim, configDim: config.dim, metaDim },
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ⑤ hostId 校验(向量库明确不同步;hostId 不符说明库来自别的机器)
|
|
90
|
+
const metaHost = getMeta(db, META_KEYS.hostId);
|
|
91
|
+
if (!metaHost) {
|
|
92
|
+
setMeta(db, META_KEYS.hostId, String(config.hostId));
|
|
93
|
+
add('hostId 校验', 'ok', `首次初始化,已写入 ${config.hostId}`);
|
|
94
|
+
} else if (metaHost === String(config.hostId)) {
|
|
95
|
+
add('hostId 校验', 'ok', `=${metaHost}`);
|
|
96
|
+
} else {
|
|
97
|
+
add(
|
|
98
|
+
'hostId 校验',
|
|
99
|
+
allowHostMismatch ? 'warn' : 'fail',
|
|
100
|
+
`库内 hostId=${metaHost},当前=${config.hostId} ⇒ 该库非本机产物,建议全量重建(--full)`,
|
|
101
|
+
{ metaHost, current: config.hostId },
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ⑥ 覆盖度(信息项;非判据)——【2026-10-10 · 补坑库联动A】改成**逐 source** 报告
|
|
106
|
+
// ⚠️ 判据不是"向量数 vs 记忆数"这一种:坑源的对照物是**坑库条数**(`butler-pitfalls.json` 的条目数)。
|
|
107
|
+
const srcList = sources && sources.length
|
|
108
|
+
? sources.map((s) => ({ name: String(s.name), source: s.source }))
|
|
109
|
+
: source
|
|
110
|
+
? [{ name: String(sourceName), source }]
|
|
111
|
+
: [];
|
|
112
|
+
for (const s of srcList) {
|
|
113
|
+
const label = srcList.length > 1 ? `向量覆盖度(${s.name})` : '向量覆盖度';
|
|
114
|
+
try {
|
|
115
|
+
const memCount = s.source.count();
|
|
116
|
+
const vecCount = countVectors(db, s.name);
|
|
117
|
+
const ratio = memCount ? (vecCount / memCount) * 100 : 0;
|
|
118
|
+
add(label, 'info', `${s.name} 源:向量 ${vecCount} 条 / 源条数 ${memCount} 条(${ratio.toFixed(1)}%)`, {
|
|
119
|
+
sourceName: s.name,
|
|
120
|
+
vecCount,
|
|
121
|
+
memCount,
|
|
122
|
+
});
|
|
123
|
+
} catch (e) {
|
|
124
|
+
add(label, 'warn', `统计失败:${String(e && e.message ? e.message : e)}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const failed = checks.filter((c) => c.status === 'fail');
|
|
129
|
+
const warned = checks.filter((c) => c.status === 'warn');
|
|
130
|
+
return { ok: failed.length === 0, checks, failed: failed.length, warned: warned.length };
|
|
131
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 兜底:全表 text_hash 段轮转校验(方案 §3.1 兜底 / §6.2 ②)
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须有(A7 实测):41 处外部 `UPDATE memories SET text=?` 中,
|
|
4
|
+
// apply-pinned-refine.mjs:110 改 text **不动 updated_at** ⇒ 这类勘误永远进不了增量窗口。
|
|
5
|
+
// 兜底外置、不与增量主路径共用候选集;按 id 段轮转,累计若干轮覆盖全表。
|
|
6
|
+
import { textHash } from './hashes.mjs';
|
|
7
|
+
import { META_KEYS, getMetaFor, setMetaFor, getHashes, upsertVectors } from './vectors-store.mjs';
|
|
8
|
+
// 【S3c 挂钩(2026-09-21)】轮转发现的"正文变了"差异**必须落 `memories_dirty` 台账**:
|
|
9
|
+
// 本函数发现差异后会**立刻重编码并更新 `vectors.text_hash`** ⇒ 差异信号当场消失。
|
|
10
|
+
// 若此刻只用于重编码而不留痕,被外部改过 text 的条(A7 的 41 处写口)就再也没人知道要提炼它们。
|
|
11
|
+
// 台账是**只加不改**的旁路;`recordDirty` 幂等(同一条重复扫到只 hits+1,不复活已消费项)。
|
|
12
|
+
import { recordDirty } from './dirty-ledger.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} p
|
|
16
|
+
* @param {object} p.source
|
|
17
|
+
* @param {object} p.store
|
|
18
|
+
* @param {object} p.embedder
|
|
19
|
+
* @param {number} p.segments 全表分几段(默认 20)
|
|
20
|
+
* @param {number} p.rounds 本次跑几段(默认 1;--all 传 segments = 一轮完整覆盖)
|
|
21
|
+
* @param {string} [p.sourceName='memory'] 向量来源名
|
|
22
|
+
* ⚠️ 轮转语义按来源不同:memory = **id 段**;pitfall = `segment()` 内部按 `lastSeen` 排序后切片
|
|
23
|
+
* (`idRange()` 给的是排序下标区间 ⇒ 同一段轮转代码对两个来源都成立,不必分支)。
|
|
24
|
+
*/
|
|
25
|
+
export async function runHashSweep({
|
|
26
|
+
source,
|
|
27
|
+
store,
|
|
28
|
+
embedder,
|
|
29
|
+
modelId,
|
|
30
|
+
dim,
|
|
31
|
+
sourceName = 'memory',
|
|
32
|
+
segments = 20,
|
|
33
|
+
rounds = 1,
|
|
34
|
+
dryRun = false,
|
|
35
|
+
log = () => {},
|
|
36
|
+
now = () => Date.now(),
|
|
37
|
+
}) {
|
|
38
|
+
const db = store.db;
|
|
39
|
+
const src = String(sourceName || 'memory');
|
|
40
|
+
// ⚠️ `memories_dirty` 台账的语义是「哪条**记忆**的正文被外部改过、待提炼」——
|
|
41
|
+
// 坑源跑兜底时**不得**把 16 位 hex 的坑 id 记进去(会污染提炼侧输入)。故只对 memory 源落台账。
|
|
42
|
+
const useDirtyLedger = src === 'memory';
|
|
43
|
+
const startedAt = now();
|
|
44
|
+
const range = source.idRange();
|
|
45
|
+
const stats = {
|
|
46
|
+
source: src,
|
|
47
|
+
mode: 'hash-sweep',
|
|
48
|
+
segments,
|
|
49
|
+
rounds,
|
|
50
|
+
range,
|
|
51
|
+
cursorStart: Number(getMetaFor(db, META_KEYS.hashSweepCursor, src) || 0) % Math.max(1, segments),
|
|
52
|
+
cursorEnd: 0,
|
|
53
|
+
scanned: 0,
|
|
54
|
+
mismatched: 0,
|
|
55
|
+
missing: 0,
|
|
56
|
+
fixed: 0,
|
|
57
|
+
dirtyRecorded: 0,
|
|
58
|
+
segmentReports: [],
|
|
59
|
+
completedFullSweep: false,
|
|
60
|
+
elapsedMs: 0,
|
|
61
|
+
failures: [],
|
|
62
|
+
};
|
|
63
|
+
if (range.lo == null) {
|
|
64
|
+
stats.cursorEnd = stats.cursorStart;
|
|
65
|
+
stats.finishedAt = now();
|
|
66
|
+
stats.elapsedMs = stats.finishedAt - startedAt;
|
|
67
|
+
log(`[sweep] ${src} 源为空,跳过`);
|
|
68
|
+
return stats;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const width = Math.max(1, Math.ceil((range.hi - range.lo + 1) / Math.max(1, segments)));
|
|
72
|
+
let cursor = stats.cursorStart;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
for (let i = 0; i < rounds; i += 1) {
|
|
76
|
+
const lo = range.lo + cursor * width;
|
|
77
|
+
const hi = Math.min(range.hi, lo + width - 1);
|
|
78
|
+
const rows = source.segment(lo, hi);
|
|
79
|
+
const hashes = getHashes(db, src, rows.map((r) => r.id));
|
|
80
|
+
const need = [];
|
|
81
|
+
// 【S3c】差异明细(只收 "mismatch":正文哈希与向量库不符 = 被外部改过 text)
|
|
82
|
+
const dirtyItems = [];
|
|
83
|
+
let missing = 0;
|
|
84
|
+
let mismatched = 0;
|
|
85
|
+
for (const r of rows) {
|
|
86
|
+
const h = textHash(r.text);
|
|
87
|
+
const prev = hashes.get(String(r.id));
|
|
88
|
+
if (prev === undefined) {
|
|
89
|
+
missing += 1;
|
|
90
|
+
need.push({ row: r, hash: h });
|
|
91
|
+
} else if (prev !== h) {
|
|
92
|
+
mismatched += 1;
|
|
93
|
+
need.push({ row: r, hash: h });
|
|
94
|
+
dirtyItems.push({ refId: r.id, kind: 'mismatch', oldHash: prev, newHash: h });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 先落台账、再重编码(顺序不能反:重编码会把 old_hash 覆盖掉,差异就再也说不清了)
|
|
98
|
+
if (!dryRun && useDirtyLedger && dirtyItems.length) {
|
|
99
|
+
try {
|
|
100
|
+
recordDirty(db, dirtyItems, { now: now() });
|
|
101
|
+
stats.dirtyRecorded = (stats.dirtyRecorded ?? 0) + dirtyItems.length;
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// 台账写失败**不得**影响主路径(它只是提炼侧的补充输入源)
|
|
104
|
+
stats.failures.push({ stage: 'dirty-ledger', message: String(e && e.message ? e.message : e) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (need.length) {
|
|
108
|
+
const vecs = await embedder.encodeBatch(need.map((n) => n.row.text));
|
|
109
|
+
if (!dryRun) {
|
|
110
|
+
upsertVectors(
|
|
111
|
+
db,
|
|
112
|
+
need.map((n, k) => ({
|
|
113
|
+
source: src,
|
|
114
|
+
refId: n.row.id,
|
|
115
|
+
model: modelId,
|
|
116
|
+
dim: vecs[k].length,
|
|
117
|
+
vec: vecs[k],
|
|
118
|
+
textHash: n.hash,
|
|
119
|
+
updatedAt: n.row.updated_at,
|
|
120
|
+
})),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
stats.scanned += rows.length;
|
|
125
|
+
stats.missing += missing;
|
|
126
|
+
stats.mismatched += mismatched;
|
|
127
|
+
stats.fixed += need.length;
|
|
128
|
+
stats.segmentReports.push({
|
|
129
|
+
segment: cursor,
|
|
130
|
+
lo,
|
|
131
|
+
hi,
|
|
132
|
+
rows: rows.length,
|
|
133
|
+
missing,
|
|
134
|
+
mismatched,
|
|
135
|
+
fixed: need.length,
|
|
136
|
+
});
|
|
137
|
+
log(
|
|
138
|
+
`[sweep] 段 ${cursor}(id ${lo}-${hi}):${rows.length} 条,缺失 ${missing},哈希不符 ${mismatched} ⇒ 重编码 ${need.length}`,
|
|
139
|
+
);
|
|
140
|
+
cursor = (cursor + 1) % Math.max(1, segments);
|
|
141
|
+
if (cursor === 0) stats.completedFullSweep = true;
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
stats.failures.push({ stage: 'sweep', message: String(e && e.message ? e.message : e) });
|
|
145
|
+
stats.error = stats.failures[0].message;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
stats.cursorEnd = cursor;
|
|
149
|
+
if (!dryRun) {
|
|
150
|
+
setMetaFor(db, META_KEYS.hashSweepCursor, src, String(cursor));
|
|
151
|
+
if (stats.completedFullSweep) {
|
|
152
|
+
stats.lastFullHashSweepAt = now();
|
|
153
|
+
setMetaFor(db, META_KEYS.lastFullHashSweepAt, src, String(stats.lastFullHashSweepAt));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
stats.finishedAt = now();
|
|
157
|
+
stats.elapsedMs = stats.finishedAt - startedAt;
|
|
158
|
+
return stats;
|
|
159
|
+
}
|