dsh-layered-memory 0.6.1 → 0.7.1
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/README.en.md +106 -81
- package/README.md +93 -80
- package/assets/img/Hero.png +0 -0
- package/assets/img/Layers.png +0 -0
- package/assets/img/Modes.png +0 -0
- package/assets/img/ui-dark.jpg +0 -0
- package/assets/img/ui-light.jpg +0 -0
- package/assets/readme/flow.svg +189 -0
- package/assets/readme/storage.svg +115 -0
- package/dist/client.js +1100 -402
- package/dist/config.d.ts +12 -0
- package/dist/config.js +20 -17
- package/dist/hooks/capture.d.ts +17 -1
- package/dist/hooks/capture.js +45 -11
- package/dist/hooks/recall.d.ts +7 -0
- package/dist/hooks/recall.js +20 -4
- package/dist/index.d.ts +8 -0
- package/dist/index.js +107 -44
- package/dist/pipeline/rebuild.d.ts +2 -0
- package/dist/pipeline/rebuild.js +7 -0
- package/dist/pipeline/runner.d.ts +7 -1
- package/dist/pipeline/runner.js +22 -7
- package/dist/runtime-package-lock.json +982 -0
- package/dist/settings.js +79 -20
- package/dist/stats.d.ts +7 -1
- package/dist/stats.js +131 -14
- package/dist/store/download-queue.d.ts +71 -0
- package/dist/store/download-queue.js +313 -0
- package/dist/store/embedding-source.d.ts +160 -0
- package/dist/store/embedding-source.js +421 -0
- package/dist/store/embedding.d.ts +3 -1
- package/dist/store/embedding.js +5 -0
- package/dist/store/l0.d.ts +13 -5
- package/dist/store/l0.js +40 -10
- package/dist/store/l1.d.ts +15 -5
- package/dist/store/l1.js +47 -19
- package/dist/store/local-embedding.d.ts +64 -0
- package/dist/store/local-embedding.js +120 -0
- package/dist/store/model-catalog.d.ts +45 -0
- package/dist/store/model-catalog.js +78 -0
- package/dist/store/runtime-installer.d.ts +65 -0
- package/dist/store/runtime-installer.js +220 -0
- package/dist/store/sqlite.d.ts +68 -7
- package/dist/store/sqlite.js +422 -73
- package/dist/tools/index.js +6 -4
- package/dist/util/filelog.d.ts +2 -0
- package/dist/util/filelog.js +20 -3
- package/package.json +1 -1
- package/assets/readme/hero.svg +0 -58
package/dist/store/sqlite.js
CHANGED
|
@@ -23,11 +23,24 @@ const require = createRequire(import.meta.url);
|
|
|
23
23
|
const TAG = '[memory][sqlite]';
|
|
24
24
|
/** vec0 KNN 对遗留零向量的补偿缓冲(官方同款)。 */
|
|
25
25
|
const ZERO_VEC_BUFFER = 10;
|
|
26
|
+
/** IN 查询/删除的分块大小(保守避开 SQLite 变量数上限:现代构建 32766,老版 999)。 */
|
|
27
|
+
const IN_CHUNK = 900;
|
|
28
|
+
/** 零向量 skip 集上限(≤ IN_CHUNK:notInClause 不分块,占位符数即集合大小,须避开老构建 999 上限)。 */
|
|
29
|
+
const VEC_SKIP_CAP = 900;
|
|
30
|
+
/** 把 id 列表切成 ≤IN_CHUNK 的块(分块执行后合并语义等价于单次 IN)。 */
|
|
31
|
+
function chunkIds(ids) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (let i = 0; i < ids.length; i += IN_CHUNK)
|
|
34
|
+
out.push(ids.slice(i, i + IN_CHUNK));
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
26
37
|
export class MemoryDb {
|
|
27
38
|
db;
|
|
28
39
|
degraded = false;
|
|
29
40
|
ftsAvailable = false;
|
|
30
41
|
vecLoaded = false;
|
|
42
|
+
vecLoadWarned = false;
|
|
43
|
+
/** 向量维度:活切换嵌入源(D5)时会变——vec0 表随维度重建。 */
|
|
31
44
|
dimensions;
|
|
32
45
|
logger;
|
|
33
46
|
stmtUpsertL1;
|
|
@@ -41,6 +54,7 @@ export class MemoryDb {
|
|
|
41
54
|
stmtL1FtsSearch;
|
|
42
55
|
stmtL1FtsSearchFamily;
|
|
43
56
|
stmtUpsertL0;
|
|
57
|
+
stmtGetL0;
|
|
44
58
|
stmtDeleteL0Vec;
|
|
45
59
|
stmtInsertL0Vec;
|
|
46
60
|
stmtSearchL0Vec;
|
|
@@ -58,9 +72,12 @@ export class MemoryDb {
|
|
|
58
72
|
mkdirSync(dbDir, { recursive: true });
|
|
59
73
|
const { DatabaseSync: DbSync } = require('node:sqlite');
|
|
60
74
|
this.db = new DbSync(dbPath, { allowExtension: true });
|
|
61
|
-
// 并发读优化 + 有界内存(照搬官方 PRAGMA
|
|
75
|
+
// 并发读优化 + 有界内存(照搬官方 PRAGMA 组合,synchronous 为本仓新增:
|
|
76
|
+
// WAL 下官方推荐 NORMAL——批量写从"每事务一次 fsync"降为"每 checkpoint 一次",
|
|
77
|
+
// 重嵌入/导入提速明显;代价仅是断电时丢最后若干已提交事务(只丢不损,无损坏风险))
|
|
62
78
|
this.db.exec('PRAGMA busy_timeout = 5000');
|
|
63
79
|
this.db.exec('PRAGMA journal_mode = WAL');
|
|
80
|
+
this.db.exec('PRAGMA synchronous = NORMAL');
|
|
64
81
|
this.db.exec('PRAGMA cache_size = -65536');
|
|
65
82
|
this.db.exec('PRAGMA mmap_size = 134217728');
|
|
66
83
|
this.db.exec('PRAGMA wal_autocheckpoint = 1000');
|
|
@@ -87,28 +104,93 @@ export class MemoryDb {
|
|
|
87
104
|
// 构造期已降级(开库失败)→ 直接短路
|
|
88
105
|
if (this.degraded)
|
|
89
106
|
return { needsReindex: false, reason: 'database open failed' };
|
|
90
|
-
// dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec
|
|
91
|
-
|
|
107
|
+
// dimensions=0 是合法的"纯 FTS 模式",不能因 sqlite-vec 缺失而降级(官方语义);
|
|
108
|
+
// 后续活切换本地嵌入(维度 > 0)时由 swapProvider 补加载
|
|
109
|
+
this.ensureVecLoaded();
|
|
110
|
+
try {
|
|
111
|
+
return this.initSchema(providerInfo);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
115
|
+
this.logger?.error(`${TAG} schema 初始化失败,存储进入降级模式: ${message}`);
|
|
116
|
+
this.degraded = true;
|
|
117
|
+
return { needsReindex: false, reason: `schema init failed: ${message}` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** 惰性加载 sqlite-vec(纯 FTS 起步后切本地嵌入时补加载);失败只停用向量能力并告警一次。 */
|
|
121
|
+
ensureVecLoaded() {
|
|
122
|
+
if (this.vecLoaded || this.dimensions <= 0)
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
const sqliteVec = require('sqlite-vec');
|
|
126
|
+
this.db.enableLoadExtension(true);
|
|
92
127
|
try {
|
|
93
|
-
const sqliteVec = require('sqlite-vec');
|
|
94
|
-
this.db.enableLoadExtension(true);
|
|
95
128
|
sqliteVec.load(this.db);
|
|
96
|
-
this.db.enableLoadExtension(false);
|
|
97
129
|
this.vecLoaded = true;
|
|
98
130
|
}
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
finally {
|
|
132
|
+
// 加载失败也必须复位扩展开关,不留常开的扩展加载面
|
|
133
|
+
this.db.enableLoadExtension(false);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
138
|
+
if (!this.vecLoadWarned) {
|
|
139
|
+
this.vecLoadWarned = true;
|
|
101
140
|
this.logger?.warn(`${TAG} sqlite-vec 加载失败,向量检索停用(降级为纯 FTS): ${message}`);
|
|
102
141
|
}
|
|
103
142
|
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* 活切换嵌入源(D5):provider/model/维度任一变化 → drop 向量表按新维度重建,
|
|
146
|
+
* 返回 needsReindex=true(调用方后台重嵌,全部成功后 markEmbeddingSynced);
|
|
147
|
+
* 配置未变化 → false(切回同一模型不重嵌)。
|
|
148
|
+
* 新维度 > 0 但 sqlite-vec 不可用 → ok=false(调用方向用户说明,维持 FTS)。
|
|
149
|
+
*/
|
|
150
|
+
swapProvider(info) {
|
|
151
|
+
if (this.degraded)
|
|
152
|
+
return { ok: false, needsReindex: false, error: '数据库降级模式' };
|
|
153
|
+
if (info.dimensions <= 0) {
|
|
154
|
+
// 关闭档不走此路径(只换服务实例);防御性兜底:不动表,无需重嵌
|
|
155
|
+
this.dimensions = 0;
|
|
156
|
+
return { ok: true, needsReindex: false };
|
|
157
|
+
}
|
|
158
|
+
// 先置新维度再懒加载(ensureVecLoaded 以 dimensions>0 为门)——0 维起步的库
|
|
159
|
+
// 在首次活切本地/远程嵌入时此处补加载 sqlite-vec
|
|
160
|
+
this.dimensions = info.dimensions;
|
|
161
|
+
this.ensureVecLoaded();
|
|
162
|
+
if (!this.vecLoaded) {
|
|
163
|
+
return { ok: false, needsReindex: false, error: 'sqlite-vec 扩展不可用,无法启用向量检索' };
|
|
164
|
+
}
|
|
165
|
+
// unchanged 判据不能只信 embedding_meta:取消/崩溃路径可能留下"meta=旧 provider、
|
|
166
|
+
// 物理表=新维度"的错位(切换已 drop 并按新维度重建,但重嵌取消/失败没写 meta)。
|
|
167
|
+
// 只比对 meta 会在切回旧源时跳过 drop → 旧维度服务往新维度表写向量 → vec0 抛错
|
|
168
|
+
// → upsert 整体回滚 → 记录从检索库静默消失。必须同时校验物理表的真实维度。
|
|
169
|
+
const saved = this.readEmbeddingMeta();
|
|
170
|
+
const physical = this.physicalVecDims();
|
|
171
|
+
const unchanged = saved &&
|
|
172
|
+
saved.provider === info.provider &&
|
|
173
|
+
saved.model === info.model &&
|
|
174
|
+
saved.dimensions === info.dimensions &&
|
|
175
|
+
physical === info.dimensions;
|
|
176
|
+
if (unchanged)
|
|
177
|
+
return { ok: true, needsReindex: false };
|
|
178
|
+
this.dropVectorTables();
|
|
179
|
+
return { ok: true, needsReindex: true };
|
|
180
|
+
}
|
|
181
|
+
/** l1_vec 物理表的向量维度(建表 DDL 里的 float[N]);无表返回 null。 */
|
|
182
|
+
physicalVecDims() {
|
|
104
183
|
try {
|
|
105
|
-
|
|
184
|
+
const row = this.db
|
|
185
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'l1_vec'")
|
|
186
|
+
.get();
|
|
187
|
+
if (!row?.sql)
|
|
188
|
+
return null;
|
|
189
|
+
const m = /float\[(\d+)\]/.exec(row.sql);
|
|
190
|
+
return m ? Number(m[1]) : null;
|
|
106
191
|
}
|
|
107
|
-
catch
|
|
108
|
-
|
|
109
|
-
this.logger?.error(`${TAG} schema 初始化失败,存储进入降级模式: ${message}`);
|
|
110
|
-
this.degraded = true;
|
|
111
|
-
return { needsReindex: false, reason: `schema init failed: ${message}` };
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
112
194
|
}
|
|
113
195
|
}
|
|
114
196
|
initSchema(providerInfo) {
|
|
@@ -231,6 +313,7 @@ export class MemoryDb {
|
|
|
231
313
|
recorded_at=excluded.recorded_at,
|
|
232
314
|
timestamp=excluded.timestamp
|
|
233
315
|
`);
|
|
316
|
+
this.stmtGetL0 = this.db.prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?');
|
|
234
317
|
this.prepareL0VecStatements();
|
|
235
318
|
// ── FTS5 全文索引(建表失败仅停用 FTS,不降级整个库) ──
|
|
236
319
|
try {
|
|
@@ -374,6 +457,11 @@ export class MemoryDb {
|
|
|
374
457
|
dropVectorTables() {
|
|
375
458
|
this.db.exec('DROP TABLE IF EXISTS l1_vec');
|
|
376
459
|
this.db.exec('DROP TABLE IF EXISTS l0_vec');
|
|
460
|
+
// 表已重建:作废按块缓存的 IN 语句(旧语句指向已删表,不能依赖引擎自动重编译兜底)
|
|
461
|
+
this.inStmts.clear();
|
|
462
|
+
// provider/model/维度已变:旧的"不可嵌入"判定作废(新 provider 可能嵌入得了),skip 集清空
|
|
463
|
+
this.clearVecSkipIds('l1');
|
|
464
|
+
this.clearVecSkipIds('l0');
|
|
377
465
|
this.prepareL1VecStatements();
|
|
378
466
|
this.prepareL0VecStatements();
|
|
379
467
|
}
|
|
@@ -430,9 +518,10 @@ export class MemoryDb {
|
|
|
430
518
|
.run('embedding_provider_info', JSON.stringify(info));
|
|
431
519
|
}
|
|
432
520
|
/**
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
521
|
+
* 持久化 embedding meta(语义:物理向量表当前对应的 provider/维度)。
|
|
522
|
+
* 活切换在 swapProvider 成功后即写(表已是新维度);启动/补齐链在
|
|
523
|
+
* 缺失向量补齐收敛(missing=0)后写——缺失行补齐判据是行数差,
|
|
524
|
+
* 不依赖 meta(review P7 语义在 backfill 计数判据下仍然收敛)。
|
|
436
525
|
*/
|
|
437
526
|
markEmbeddingSynced(info) {
|
|
438
527
|
if (this.degraded)
|
|
@@ -452,26 +541,9 @@ export class MemoryDb {
|
|
|
452
541
|
if (this.degraded)
|
|
453
542
|
return false;
|
|
454
543
|
try {
|
|
455
|
-
const ts = timestampsToDb(record.timestamps);
|
|
456
544
|
this.db.exec('BEGIN');
|
|
457
545
|
try {
|
|
458
|
-
this.
|
|
459
|
-
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
|
|
460
|
-
if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
|
|
461
|
-
this.stmtDeleteL1Vec.run(record.id);
|
|
462
|
-
if (embedding && !isZeroVector(embedding)) {
|
|
463
|
-
this.stmtInsertL1Vec.run(record.id, vecToBuffer(embedding), toIso(record.updatedAt));
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
if (this.ftsAvailable) {
|
|
467
|
-
try {
|
|
468
|
-
this.stmtL1FtsDelete.run(record.id);
|
|
469
|
-
this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
|
|
470
|
-
}
|
|
471
|
-
catch (ftsErr) {
|
|
472
|
-
this.logger?.warn(`${TAG} L1 FTS 写入失败(非致命)id=${record.id}: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
546
|
+
this.upsertL1InTx(record, embedding);
|
|
475
547
|
this.db.exec('COMMIT');
|
|
476
548
|
}
|
|
477
549
|
catch (err) {
|
|
@@ -490,34 +562,115 @@ export class MemoryDb {
|
|
|
490
562
|
return false;
|
|
491
563
|
}
|
|
492
564
|
}
|
|
493
|
-
/**
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
565
|
+
/**
|
|
566
|
+
* 批量 upsert L1(单事务;与单条同语义:FTS 失败整批回滚)。
|
|
567
|
+
* 追加/导入热路径用它——逐条开事务在 WAL FULL 下每条一次 fsync。
|
|
568
|
+
* 整批失败时回退逐条写入:好记录照常入库、坏记录只丢自身——否则
|
|
569
|
+
* JSONL 事实源已先行追加,检索库却整批缺失且无自动重导路径(批次空洞)。
|
|
570
|
+
*/
|
|
571
|
+
upsertL1Batch(records, embeddings) {
|
|
572
|
+
if (this.degraded || records.length === 0)
|
|
573
|
+
return false;
|
|
497
574
|
try {
|
|
498
|
-
const placeholders = ids.map(() => '?').join(',');
|
|
499
575
|
this.db.exec('BEGIN');
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
576
|
+
try {
|
|
577
|
+
for (let i = 0; i < records.length; i++) {
|
|
578
|
+
this.upsertL1InTx(records[i], embeddings?.[i]);
|
|
579
|
+
}
|
|
580
|
+
this.db.exec('COMMIT');
|
|
503
581
|
}
|
|
504
|
-
|
|
505
|
-
|
|
582
|
+
catch (err) {
|
|
583
|
+
try {
|
|
584
|
+
this.db.exec('ROLLBACK');
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
/* ignore */
|
|
588
|
+
}
|
|
589
|
+
throw err;
|
|
506
590
|
}
|
|
507
|
-
|
|
508
|
-
return ids.length;
|
|
591
|
+
return true;
|
|
509
592
|
}
|
|
510
593
|
catch (err) {
|
|
594
|
+
this.logger?.warn(`${TAG} L1 批量写入失败,回退逐条写入: ${err instanceof Error ? err.message : String(err)}`);
|
|
595
|
+
const failed = [];
|
|
596
|
+
for (let i = 0; i < records.length; i++) {
|
|
597
|
+
if (!this.upsertL1(records[i], embeddings?.[i]))
|
|
598
|
+
failed.push(records[i]?.id ?? `#${i}`);
|
|
599
|
+
}
|
|
600
|
+
if (failed.length > 0) {
|
|
601
|
+
this.logger?.warn(`${TAG} 逐条回退后仍失败 ${failed.length}/${records.length} 条: ${failed.slice(0, 5).join(', ')}${failed.length > 5 ? '…' : ''}`);
|
|
602
|
+
}
|
|
603
|
+
return failed.length === 0;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/** 事务内的单条写入体(upsertL1 / upsertL1Batch 共用;调用方负责 BEGIN/COMMIT)。 */
|
|
607
|
+
upsertL1InTx(record, embedding) {
|
|
608
|
+
const ts = timestampsToDb(record.timestamps);
|
|
609
|
+
this.stmtUpsertL1.run(record.id, record.content, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, toIso(record.createdAt), toIso(record.updatedAt), JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
|
|
610
|
+
// vec0 不支持 ON CONFLICT → 先删后插;零向量跳过(cosine 未定义)
|
|
611
|
+
if (this.stmtDeleteL1Vec && this.stmtInsertL1Vec) {
|
|
612
|
+
this.stmtDeleteL1Vec.run(record.id);
|
|
613
|
+
if (embedding && !isZeroVector(embedding)) {
|
|
614
|
+
this.stmtInsertL1Vec.run(record.id, vecToBuffer(embedding), toIso(record.updatedAt));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// FTS 删除/插入与元数据同事务:失败必须整体回滚——若只吞 FTS 错误照常 COMMIT,
|
|
618
|
+
// 已执行的 DELETE 会让该 id 的索引行被删未补,记录从此全文检索不可见(静默丢数据)。
|
|
619
|
+
if (this.ftsAvailable) {
|
|
620
|
+
this.stmtL1FtsDelete.run(record.id);
|
|
621
|
+
this.stmtL1FtsInsert.run(tokenizeForFts(record.content), record.content, record.id, record.type, record.priority, record.scene_name, record.sessionId ?? 'default', record.version ?? 0, ts.str, ts.start, ts.end, JSON.stringify(record.metadata ?? {}), record.family ?? familyForType(record.type));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/** 批量删除 L1(元数据 + 向量 + FTS),返回删除条数。IN 按 ≤900 分块(避变量数上限)。 */
|
|
625
|
+
deleteL1Batch(ids) {
|
|
626
|
+
if (this.degraded || ids.length === 0)
|
|
627
|
+
return 0;
|
|
628
|
+
try {
|
|
629
|
+
this.db.exec('BEGIN');
|
|
511
630
|
try {
|
|
512
|
-
|
|
631
|
+
for (const chunk of chunkIds(ids))
|
|
632
|
+
this.inStatement('l1_records', 'delete', chunk.length).run(...chunk);
|
|
633
|
+
if (this.stmtDeleteL1Vec) {
|
|
634
|
+
for (const chunk of chunkIds(ids))
|
|
635
|
+
this.inStatement('l1_vec', 'delete', chunk.length).run(...chunk);
|
|
636
|
+
}
|
|
637
|
+
if (this.ftsAvailable) {
|
|
638
|
+
for (const chunk of chunkIds(ids))
|
|
639
|
+
this.inStatement('l1_fts', 'delete', chunk.length).run(...chunk);
|
|
640
|
+
}
|
|
641
|
+
this.db.exec('COMMIT');
|
|
642
|
+
return ids.length;
|
|
513
643
|
}
|
|
514
|
-
catch {
|
|
515
|
-
|
|
644
|
+
catch (err) {
|
|
645
|
+
try {
|
|
646
|
+
this.db.exec('ROLLBACK');
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
/* ignore */
|
|
650
|
+
}
|
|
651
|
+
throw err;
|
|
516
652
|
}
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
517
655
|
this.logger?.warn(`${TAG} L1 批量删除失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
518
656
|
return 0;
|
|
519
657
|
}
|
|
520
658
|
}
|
|
659
|
+
/** 按块缓存的 IN 语句(表名/动作/尺寸 → 预编译语句):热路径不再每次动态 prepare。 */
|
|
660
|
+
inStmts = new Map();
|
|
661
|
+
inStatement(table, action, size) {
|
|
662
|
+
const key = `${table}:${action}:${size}`;
|
|
663
|
+
let stmt = this.inStmts.get(key);
|
|
664
|
+
if (!stmt) {
|
|
665
|
+
const ph = Array.from({ length: size }, () => '?').join(',');
|
|
666
|
+
stmt =
|
|
667
|
+
action === 'delete'
|
|
668
|
+
? this.db.prepare(`DELETE FROM ${table} WHERE record_id IN (${ph})`)
|
|
669
|
+
: this.db.prepare(`SELECT record_id, content, type, priority, scene_name, version, timestamp_str, created_time, updated_time, metadata_json, family FROM ${table} WHERE record_id IN (${ph})`);
|
|
670
|
+
this.inStmts.set(key, stmt);
|
|
671
|
+
}
|
|
672
|
+
return stmt;
|
|
673
|
+
}
|
|
521
674
|
/**
|
|
522
675
|
* 清空 L1 检索库全部数据(重建用)。records/FTS 直接 DELETE;
|
|
523
676
|
* 向量表走 DROP + 重建(vec0 的全表 DELETE 语义不可靠,dropVectorTables
|
|
@@ -549,7 +702,10 @@ export class MemoryDb {
|
|
|
549
702
|
if (this.stmtDeleteL1Vec) {
|
|
550
703
|
this.db.exec('DROP TABLE IF EXISTS l1_vec');
|
|
551
704
|
this.prepareL1VecStatements();
|
|
705
|
+
this.inStmts.clear();
|
|
552
706
|
}
|
|
707
|
+
// 重建后 L1 id 全新,旧 skip 集是死数据,清空让新记录获得一次嵌入机会
|
|
708
|
+
this.clearVecSkipIds('l1');
|
|
553
709
|
this.logger?.info(`${TAG} L1 检索库已清空(重建)`);
|
|
554
710
|
return true;
|
|
555
711
|
}
|
|
@@ -582,10 +738,10 @@ export class MemoryDb {
|
|
|
582
738
|
getL1ByIds(ids) {
|
|
583
739
|
if (this.degraded || ids.length === 0)
|
|
584
740
|
return [];
|
|
585
|
-
const
|
|
586
|
-
const
|
|
587
|
-
.
|
|
588
|
-
|
|
741
|
+
const rows = [];
|
|
742
|
+
for (const chunk of chunkIds(ids)) {
|
|
743
|
+
rows.push(...this.inStatement('l1_records', 'select', chunk.length).all(...chunk));
|
|
744
|
+
}
|
|
589
745
|
return rows.map(rowToRecord);
|
|
590
746
|
}
|
|
591
747
|
/** 浏览列表(UI 用):按更新时间倒序,支持类型/场景/族过滤与分页。失败返回空。 */
|
|
@@ -716,13 +872,9 @@ export class MemoryDb {
|
|
|
716
872
|
}
|
|
717
873
|
}
|
|
718
874
|
if (this.ftsAvailable) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
723
|
-
catch (ftsErr) {
|
|
724
|
-
this.logger?.warn(`${TAG} L0 FTS 写入失败(非致命)id=${r.id}: ${ftsErr instanceof Error ? ftsErr.message : String(ftsErr)}`);
|
|
725
|
-
}
|
|
875
|
+
// 同 upsertL1:FTS 失败冒泡触发整批回滚,禁止"删了没补"的索引空洞。
|
|
876
|
+
this.stmtL0FtsDelete.run(r.id);
|
|
877
|
+
this.stmtL0FtsInsert.run(tokenizeForFts(r.content), r.content, r.id, r.sessionId, r.role, r.recordedAt, r.timestamp);
|
|
726
878
|
}
|
|
727
879
|
}
|
|
728
880
|
this.db.exec('COMMIT');
|
|
@@ -859,9 +1011,7 @@ export class MemoryDb {
|
|
|
859
1011
|
for (const { record_id, distance } of rows) {
|
|
860
1012
|
if (distance == null || Number.isNaN(distance))
|
|
861
1013
|
continue;
|
|
862
|
-
const row = this.
|
|
863
|
-
.prepare('SELECT session_id, role, message_text, recorded_at, timestamp FROM l0_conversations WHERE record_id = ?')
|
|
864
|
-
.get(record_id);
|
|
1014
|
+
const row = this.stmtGetL0.get(record_id);
|
|
865
1015
|
if (!row)
|
|
866
1016
|
continue;
|
|
867
1017
|
hits.push({
|
|
@@ -884,19 +1034,124 @@ export class MemoryDb {
|
|
|
884
1034
|
// ============================
|
|
885
1035
|
// 重嵌入(reindexAll 用)
|
|
886
1036
|
// ============================
|
|
887
|
-
|
|
1037
|
+
/** L1 缺失向量的记录数(排除 skip 集后的补齐判据;向量能力不可用返回 -1)。 */
|
|
1038
|
+
countL1VecMissing(exclude) {
|
|
1039
|
+
if (this.degraded || !this.stmtSearchL1Vec)
|
|
1040
|
+
return -1;
|
|
1041
|
+
try {
|
|
1042
|
+
const row = this.db
|
|
1043
|
+
.prepare(`SELECT COUNT(*) AS n FROM l1_records r
|
|
1044
|
+
LEFT JOIN l1_vec v ON v.record_id = r.record_id
|
|
1045
|
+
WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
|
|
1046
|
+
.all(...notInParams(exclude))[0];
|
|
1047
|
+
return row?.n ?? 0;
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
// -1 会让补齐判据(> 0)按"无缺失"处理——必须留痕,不能静默停摆
|
|
1051
|
+
this.logger?.warn(`${TAG} L1 缺失向量计数失败(补齐判据按无缺失处理): ${err instanceof Error ? err.message : String(err)}`);
|
|
1052
|
+
return -1;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/** L0 缺失向量的记录数(同上)。 */
|
|
1056
|
+
countL0VecMissing(exclude) {
|
|
1057
|
+
if (this.degraded || !this.stmtSearchL0Vec)
|
|
1058
|
+
return -1;
|
|
1059
|
+
try {
|
|
1060
|
+
const row = this.db
|
|
1061
|
+
.prepare(`SELECT COUNT(*) AS n FROM l0_conversations r
|
|
1062
|
+
LEFT JOIN l0_vec v ON v.record_id = r.record_id
|
|
1063
|
+
WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
|
|
1064
|
+
.all(...notInParams(exclude))[0];
|
|
1065
|
+
return row?.n ?? 0;
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
this.logger?.warn(`${TAG} L0 缺失向量计数失败(补齐判据按无缺失处理): ${err instanceof Error ? err.message : String(err)}`);
|
|
1069
|
+
return -1;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
/**
|
|
1073
|
+
* 待重嵌入的 L1:只取缺失向量的记录(增量),排除 skip 集里已判定
|
|
1074
|
+
* "当前 provider 下不可嵌入(零向量)"的 id——缺 1 条不再全量重嵌,
|
|
1075
|
+
* 零向量记录也不再反复喂给 embeddings API(H1 死循环双根因)。
|
|
1076
|
+
*/
|
|
1077
|
+
getL1ForReindex(exclude) {
|
|
888
1078
|
if (this.degraded)
|
|
889
1079
|
return [];
|
|
890
|
-
|
|
891
|
-
.
|
|
892
|
-
|
|
1080
|
+
try {
|
|
1081
|
+
return this.db
|
|
1082
|
+
.prepare(`SELECT r.record_id AS id, r.content FROM l1_records r
|
|
1083
|
+
LEFT JOIN l1_vec v ON v.record_id = r.record_id
|
|
1084
|
+
WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
|
|
1085
|
+
.all(...notInParams(exclude));
|
|
1086
|
+
}
|
|
1087
|
+
catch (err) {
|
|
1088
|
+
this.logger?.warn(`${TAG} L1 重嵌入取数失败(返回空,本轮跳过): ${err instanceof Error ? err.message : String(err)}`);
|
|
1089
|
+
return [];
|
|
1090
|
+
}
|
|
893
1091
|
}
|
|
894
|
-
|
|
1092
|
+
/** 待重嵌入的 L0(增量 + 排除 skip 集,同 getL1ForReindex)。 */
|
|
1093
|
+
getL0ForReindex(exclude) {
|
|
895
1094
|
if (this.degraded)
|
|
896
1095
|
return [];
|
|
897
|
-
|
|
898
|
-
.
|
|
899
|
-
|
|
1096
|
+
try {
|
|
1097
|
+
return this.db
|
|
1098
|
+
.prepare(`SELECT r.record_id AS id, r.message_text AS text FROM l0_conversations r
|
|
1099
|
+
LEFT JOIN l0_vec v ON v.record_id = r.record_id
|
|
1100
|
+
WHERE v.record_id IS NULL${notInClause('r.record_id', exclude)}`)
|
|
1101
|
+
.all(...notInParams(exclude));
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
this.logger?.warn(`${TAG} L0 重嵌入取数失败(返回空,本轮跳过): ${err instanceof Error ? err.message : String(err)}`);
|
|
1105
|
+
return [];
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
// ── 零向量 skip 集(embedding_meta 持久化;provider 变化时随向量表一起清空) ──
|
|
1109
|
+
getVecSkipSet(kind) {
|
|
1110
|
+
if (this.degraded)
|
|
1111
|
+
return new Set();
|
|
1112
|
+
try {
|
|
1113
|
+
const row = this.db
|
|
1114
|
+
.prepare('SELECT value FROM embedding_meta WHERE key = ?')
|
|
1115
|
+
.get(vecSkipKey(kind));
|
|
1116
|
+
if (!row)
|
|
1117
|
+
return new Set();
|
|
1118
|
+
const parsed = JSON.parse(row.value);
|
|
1119
|
+
if (!Array.isArray(parsed))
|
|
1120
|
+
return new Set();
|
|
1121
|
+
return new Set(parsed.filter((x) => typeof x === 'string'));
|
|
1122
|
+
}
|
|
1123
|
+
catch {
|
|
1124
|
+
return new Set();
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
addVecSkippedIds(kind, ids) {
|
|
1128
|
+
if (this.degraded || ids.length === 0)
|
|
1129
|
+
return;
|
|
1130
|
+
try {
|
|
1131
|
+
let merged = [...new Set([...this.getVecSkipSet(kind), ...ids])];
|
|
1132
|
+
// 上限防 NOT IN 占位符无界膨胀(老构建变量上限 999);达到上限本身
|
|
1133
|
+
// 说明 embedding 服务大面积返回零向量,被挤出的旧 id 只是多一次重试
|
|
1134
|
+
if (merged.length > VEC_SKIP_CAP) {
|
|
1135
|
+
merged = merged.slice(-VEC_SKIP_CAP);
|
|
1136
|
+
this.logger?.warn(`${TAG} skip 集达上限 ${VEC_SKIP_CAP}(零向量记录过多,embedding 服务疑似异常)`);
|
|
1137
|
+
}
|
|
1138
|
+
this.db
|
|
1139
|
+
.prepare('INSERT INTO embedding_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
|
|
1140
|
+
.run(vecSkipKey(kind), JSON.stringify(merged));
|
|
1141
|
+
}
|
|
1142
|
+
catch (err) {
|
|
1143
|
+
this.logger?.warn(`${TAG} skip 集写入失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
clearVecSkipIds(kind) {
|
|
1147
|
+
if (this.degraded)
|
|
1148
|
+
return;
|
|
1149
|
+
try {
|
|
1150
|
+
this.db.prepare('DELETE FROM embedding_meta WHERE key = ?').run(vecSkipKey(kind));
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
/* 空集语义,失败无影响 */
|
|
1154
|
+
}
|
|
900
1155
|
}
|
|
901
1156
|
/** 只更新向量行(重嵌入用)。 */
|
|
902
1157
|
updateL1Vec(id, embedding) {
|
|
@@ -927,6 +1182,85 @@ export class MemoryDb {
|
|
|
927
1182
|
return false;
|
|
928
1183
|
}
|
|
929
1184
|
}
|
|
1185
|
+
/**
|
|
1186
|
+
* 批量更新 L1 向量行(重嵌入热路径):单事务写入整批,替代逐条裸写——
|
|
1187
|
+
* 逐条每行一次隐式事务,批量场景(万级记录重嵌)开销集中在 fsync 上。
|
|
1188
|
+
* 整批失败回退逐条:好行照常入库,坏行只丢自身(向量行 id 寻址,无顺序依赖)。
|
|
1189
|
+
* 返回成功写入的行数(零向量行防御性跳过、不计入)。
|
|
1190
|
+
*/
|
|
1191
|
+
updateL1VecBatch(items) {
|
|
1192
|
+
if (this.degraded || !this.stmtDeleteL1Vec || !this.stmtInsertL1Vec || items.length === 0)
|
|
1193
|
+
return 0;
|
|
1194
|
+
try {
|
|
1195
|
+
this.db.exec('BEGIN');
|
|
1196
|
+
try {
|
|
1197
|
+
let written = 0;
|
|
1198
|
+
for (const it of items) {
|
|
1199
|
+
if (isZeroVector(it.embedding))
|
|
1200
|
+
continue;
|
|
1201
|
+
this.stmtDeleteL1Vec.run(it.id);
|
|
1202
|
+
this.stmtInsertL1Vec.run(it.id, vecToBuffer(it.embedding), new Date().toISOString());
|
|
1203
|
+
written++;
|
|
1204
|
+
}
|
|
1205
|
+
this.db.exec('COMMIT');
|
|
1206
|
+
return written;
|
|
1207
|
+
}
|
|
1208
|
+
catch (err) {
|
|
1209
|
+
try {
|
|
1210
|
+
this.db.exec('ROLLBACK');
|
|
1211
|
+
}
|
|
1212
|
+
catch {
|
|
1213
|
+
/* ignore */
|
|
1214
|
+
}
|
|
1215
|
+
throw err;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
catch (err) {
|
|
1219
|
+
this.logger?.warn(`${TAG} L1 向量批量写入失败,回退逐条: ${err instanceof Error ? err.message : String(err)}`);
|
|
1220
|
+
let ok = 0;
|
|
1221
|
+
for (const it of items)
|
|
1222
|
+
if (this.updateL1Vec(it.id, it.embedding))
|
|
1223
|
+
ok++;
|
|
1224
|
+
return ok;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/** L0 版 updateL1VecBatch(语义同:单事务 + 失败回退逐条)。recordedAt 整批统一。 */
|
|
1228
|
+
updateL0VecBatch(items, recordedAt) {
|
|
1229
|
+
if (this.degraded || !this.stmtDeleteL0Vec || !this.stmtInsertL0Vec || items.length === 0)
|
|
1230
|
+
return 0;
|
|
1231
|
+
try {
|
|
1232
|
+
this.db.exec('BEGIN');
|
|
1233
|
+
try {
|
|
1234
|
+
let written = 0;
|
|
1235
|
+
for (const it of items) {
|
|
1236
|
+
if (isZeroVector(it.embedding))
|
|
1237
|
+
continue;
|
|
1238
|
+
this.stmtDeleteL0Vec.run(it.id);
|
|
1239
|
+
this.stmtInsertL0Vec.run(it.id, vecToBuffer(it.embedding), recordedAt);
|
|
1240
|
+
written++;
|
|
1241
|
+
}
|
|
1242
|
+
this.db.exec('COMMIT');
|
|
1243
|
+
return written;
|
|
1244
|
+
}
|
|
1245
|
+
catch (err) {
|
|
1246
|
+
try {
|
|
1247
|
+
this.db.exec('ROLLBACK');
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
/* ignore */
|
|
1251
|
+
}
|
|
1252
|
+
throw err;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
catch (err) {
|
|
1256
|
+
this.logger?.warn(`${TAG} L0 向量批量写入失败,回退逐条: ${err instanceof Error ? err.message : String(err)}`);
|
|
1257
|
+
let ok = 0;
|
|
1258
|
+
for (const it of items)
|
|
1259
|
+
if (this.updateL0Vec(it.id, it.embedding, recordedAt))
|
|
1260
|
+
ok++;
|
|
1261
|
+
return ok;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
930
1264
|
close() {
|
|
931
1265
|
try {
|
|
932
1266
|
this.db.close();
|
|
@@ -980,13 +1314,28 @@ function toIso(epochMs) {
|
|
|
980
1314
|
return '';
|
|
981
1315
|
return new Date(epochMs).toISOString();
|
|
982
1316
|
}
|
|
983
|
-
|
|
1317
|
+
/** 全零向量(cosine 未定义,不可入向量表)。reindex 侧用它区分"不可嵌入"与"写入失败"。 */
|
|
1318
|
+
export function isZeroVector(vec) {
|
|
984
1319
|
for (const v of vec) {
|
|
985
1320
|
if (v !== 0)
|
|
986
1321
|
return false;
|
|
987
1322
|
}
|
|
988
1323
|
return true;
|
|
989
1324
|
}
|
|
1325
|
+
/** NOT IN 片段(空集 → 空串;配合 notInParams 使用)。 */
|
|
1326
|
+
function notInClause(column, exclude) {
|
|
1327
|
+
if (!exclude || exclude.size === 0)
|
|
1328
|
+
return '';
|
|
1329
|
+
return ` AND ${column} NOT IN (${[...exclude].map(() => '?').join(',')})`;
|
|
1330
|
+
}
|
|
1331
|
+
function notInParams(exclude) {
|
|
1332
|
+
if (!exclude || exclude.size === 0)
|
|
1333
|
+
return [];
|
|
1334
|
+
return [...exclude];
|
|
1335
|
+
}
|
|
1336
|
+
function vecSkipKey(kind) {
|
|
1337
|
+
return kind === 'l1' ? 'embedding_zero_vec_l1' : 'embedding_zero_vec_l0';
|
|
1338
|
+
}
|
|
990
1339
|
/** DB 字符串 → 族(异常值按 type 前缀兜底归一)。 */
|
|
991
1340
|
function normFamily(raw, type) {
|
|
992
1341
|
if (raw === 'work' || raw === 'chat')
|