rag-memory-epf-mcp 6.4.2 → 6.4.4
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.md +42 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.js +238 -40
- package/dist/src/backfillCoordinator.d.ts +12 -0
- package/dist/src/backfillCoordinator.js +163 -42
- package/dist/src/observations/projection.js +4 -3
- package/dist/src/vecSimilarity.d.ts +17 -0
- package/dist/src/vecSimilarity.js +23 -0
- package/dist/src/vecSlots.d.ts +51 -0
- package/dist/src/vecSlots.js +323 -0
- package/docs/UPDATING.md +18 -0
- package/package.json +2 -2
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// kick() merely triggers a debounced scan. All reconciliation writes are
|
|
15
15
|
// per-row transactions with a CAS re-check so a foreground embed that lands
|
|
16
16
|
// first is never downgraded (6R note 3).
|
|
17
|
+
import { searchableVectorSql, VecSlotError, deleteVector } from './vecSlots.js';
|
|
17
18
|
// A handle closed by the engine's reopen (wal-sidecar-guard), or an I/O error from a sidecar the
|
|
18
19
|
// sync client swapped out: the run was cut, not broken.
|
|
19
20
|
const CONN_LOSS_RERUN_MAX = 3;
|
|
@@ -36,6 +37,15 @@ export class BackfillCoordinator {
|
|
|
36
37
|
shuttingDown = false;
|
|
37
38
|
connLossReruns = 0;
|
|
38
39
|
scanning = false;
|
|
40
|
+
// vec-slot-reuse: rows whose vec0 slot has validity 0 cannot be rewritten in place (sqlite-vec refuses
|
|
41
|
+
// to DELETE many of them); the backfill skips them and asks for the verified rebuild instead.
|
|
42
|
+
repairWanted = false;
|
|
43
|
+
slotErrors = new Map(); // table -> unresolvable slot map message
|
|
44
|
+
repairRequestedAt = 0;
|
|
45
|
+
requestRepair() { if (!this.repairWanted)
|
|
46
|
+
this.repairRequestedAt = Date.now(); this.repairWanted = true; }
|
|
47
|
+
takeRepairRequest() { const w = this.repairWanted; this.repairWanted = false; return w; }
|
|
48
|
+
get repairRequested() { return this.repairWanted; }
|
|
39
49
|
scanPromise = null;
|
|
40
50
|
rerunRequested = false;
|
|
41
51
|
snapshot = null;
|
|
@@ -43,6 +53,8 @@ export class BackfillCoordinator {
|
|
|
43
53
|
this.deps = deps;
|
|
44
54
|
}
|
|
45
55
|
get reconState() { return this.recon; }
|
|
56
|
+
// vec-slot-reuse D1b: the idle vector rebuild must not overlap a backfill scan or a reconciliation pass.
|
|
57
|
+
get busy() { return this.scanning || !!this.scanPromise || this.reconActive || this.recon === 'running'; }
|
|
46
58
|
get reconLastError() { return this.reconError; }
|
|
47
59
|
// Shared eligibility barrier (spec §3): vector search AND automatic backfill.
|
|
48
60
|
// 'n/a' (fresh DB — nothing to reconcile) counts as satisfied (6R note 1).
|
|
@@ -54,17 +66,19 @@ export class BackfillCoordinator {
|
|
|
54
66
|
// non-atomic writes can leave metadata-without-vector and vector-without-
|
|
55
67
|
// metadata split states, and neither must be miscounted as reconciled work.
|
|
56
68
|
countNullWithVector(db) {
|
|
57
|
-
const ent = db.prepare(`SELECT COUNT(*) c FROM entity_embedding_metadata m
|
|
58
|
-
WHERE m.provenance_state IS NULL`).get();
|
|
59
|
-
const chk = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata m
|
|
60
|
-
WHERE m.provenance_state IS NULL`).get();
|
|
69
|
+
const ent = db.prepare(`SELECT COUNT(*) c FROM entity_embedding_metadata m
|
|
70
|
+
WHERE m.provenance_state IS NULL AND ${searchableVectorSql('entity_embeddings', 'm.rowid')}`).get();
|
|
71
|
+
const chk = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata m
|
|
72
|
+
WHERE m.provenance_state IS NULL AND ${searchableVectorSql('chunks', 'm.rowid')}`).get();
|
|
61
73
|
return ent.c + chk.c;
|
|
62
74
|
}
|
|
63
75
|
// Sanitation (beta B3·B4) — runs on EVERY reconciliation entry, even when no
|
|
64
76
|
// NULL rows exist, so profile changes and legacy split states are repaired
|
|
65
77
|
// before vector eligibility can open:
|
|
66
78
|
// 1. entity vectors without metadata (orphans) -> delete vector
|
|
67
|
-
// 2. entity metadata
|
|
79
|
+
// 2. stamped entity metadata whose vector row is gone -> normalized to missing, NOT deleted: the row keeps
|
|
80
|
+
// its rowid and becomes a backfill target (vec-slot-reuse r3 M2; validity-0 vector rows are repaired
|
|
81
|
+
// inline by deleteVector/writeVector or by the idle rebuild, never DELETEd here)
|
|
68
82
|
// 3. provenance-stamped rows whose compatibility profile != current
|
|
69
83
|
// (old engine/model profile) -> delete-to-missing
|
|
70
84
|
// 4. chunk metadata stamped but vectorless -> normalize to missing (NULL provenance)
|
|
@@ -75,12 +89,16 @@ export class BackfillCoordinator {
|
|
|
75
89
|
const profileId = this.deps.currentProfileId();
|
|
76
90
|
const q = (sql, ...args) => db.prepare(sql).get(...args).c;
|
|
77
91
|
return this.countNullWithVector(db)
|
|
78
|
-
|
|
79
|
-
|
|
92
|
+
// vec-slot-reuse r3 M2: searchable orphans only (validity-0 rows belong to the rebuild); vectorless
|
|
93
|
+
// metadata counts only while still stamped (it is normalized to missing, not deleted, to keep its rowid).
|
|
94
|
+
+ q(`SELECT COUNT(*) c FROM entity_embeddings_rowids m WHERE m.rowid NOT IN (SELECT rowid FROM entity_embedding_metadata) AND ${searchableVectorSql('entity_embeddings', 'm.rowid')}`)
|
|
95
|
+
// IN-42: searchable chunk vectors without chunk_metadata (same rule as entity orphans)
|
|
96
|
+
+ q(`SELECT COUNT(*) c FROM chunks_rowids m WHERE m.rowid NOT IN (SELECT rowid FROM chunk_metadata) AND ${searchableVectorSql('chunks', 'm.rowid')}`)
|
|
97
|
+
+ q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM entity_embeddings_rowids)`)
|
|
80
98
|
+ q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE provenance_state IS NOT NULL AND profile_id IS NOT ?`, profileId)
|
|
81
|
-
+ q(`SELECT COUNT(*) c FROM chunk_metadata m
|
|
99
|
+
+ q(`SELECT COUNT(*) c FROM chunk_metadata m WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? AND m.rowid IN (SELECT rowid FROM chunks_rowids)`, profileId)
|
|
82
100
|
// stamped chunk metadata whose vector is gone (beta 3R M3)
|
|
83
|
-
+ q(`SELECT COUNT(*) c FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM
|
|
101
|
+
+ q(`SELECT COUNT(*) c FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM chunks_rowids)`);
|
|
84
102
|
}
|
|
85
103
|
// Batched (beta 2R residual): each batch commits its own transaction and
|
|
86
104
|
// yields the event loop — a large DB or a profile switch must not stall the
|
|
@@ -103,13 +121,48 @@ export class BackfillCoordinator {
|
|
|
103
121
|
await new Promise(r => setImmediate(r));
|
|
104
122
|
}
|
|
105
123
|
};
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
// 1. searchable entity vectors without metadata -> delete (validity-0 orphans are left to the rebuild:
|
|
125
|
+
// vec0 refuses to DELETE most of them, vec-slot-reuse r3 M2)
|
|
126
|
+
for (;;) {
|
|
127
|
+
if (this.shuttingDown)
|
|
128
|
+
throw new Error('shutdown during sanitation');
|
|
129
|
+
const orphans = db.prepare(`SELECT m.rowid FROM entity_embeddings_rowids m
|
|
130
|
+
WHERE m.rowid NOT IN (SELECT rowid FROM entity_embedding_metadata) AND ${searchableVectorSql('entity_embeddings', 'm.rowid')} LIMIT ${RECON_BATCH}`).all();
|
|
131
|
+
if (orphans.length === 0)
|
|
132
|
+
break;
|
|
133
|
+
batchTx(() => { for (const r of orphans) {
|
|
134
|
+
deleteVector(db, 'entity_embeddings', r.rowid);
|
|
135
|
+
touched++;
|
|
136
|
+
} });
|
|
137
|
+
if (orphans.length < RECON_BATCH)
|
|
138
|
+
break;
|
|
139
|
+
await new Promise(r => setImmediate(r));
|
|
140
|
+
}
|
|
141
|
+
// 1b. searchable chunk vectors without metadata -> delete (IN-42 · same batching and validity rule as 1)
|
|
142
|
+
for (;;) {
|
|
143
|
+
if (this.shuttingDown)
|
|
144
|
+
throw new Error('shutdown during sanitation');
|
|
145
|
+
const orphans = db.prepare(`SELECT m.rowid FROM chunks_rowids m
|
|
146
|
+
WHERE m.rowid NOT IN (SELECT rowid FROM chunk_metadata) AND ${searchableVectorSql('chunks', 'm.rowid')} LIMIT ${RECON_BATCH}`).safeIntegers(true).all();
|
|
147
|
+
if (orphans.length === 0)
|
|
148
|
+
break;
|
|
149
|
+
batchTx(() => { for (const r of orphans) {
|
|
150
|
+
deleteVector(db, 'chunks', r.rowid);
|
|
151
|
+
touched++;
|
|
152
|
+
} });
|
|
153
|
+
if (orphans.length < RECON_BATCH)
|
|
154
|
+
break;
|
|
155
|
+
await new Promise(r => setImmediate(r));
|
|
156
|
+
}
|
|
157
|
+
// 2. stamped entity metadata whose vector row is gone -> normalize to missing (was: delete). Keeping the
|
|
158
|
+
// row keeps its rowid, so the backfill re-inserts at the same rowid (r3 M2 case C: restart after repair).
|
|
159
|
+
await batchedRun(`UPDATE entity_embedding_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL
|
|
160
|
+
WHERE rowid IN (
|
|
161
|
+
SELECT rowid FROM entity_embedding_metadata
|
|
162
|
+
WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM entity_embeddings_rowids) LIMIT ${RECON_BATCH})`);
|
|
110
163
|
await batchedRun(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL
|
|
111
164
|
WHERE rowid IN (
|
|
112
|
-
SELECT rowid FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM
|
|
165
|
+
SELECT rowid FROM chunk_metadata WHERE provenance_state IS NOT NULL AND rowid NOT IN (SELECT rowid FROM chunks_rowids) LIMIT ${RECON_BATCH})`);
|
|
113
166
|
// Old-profile rows: row deletes in bounded batches with yields.
|
|
114
167
|
for (;;) {
|
|
115
168
|
if (this.shuttingDown)
|
|
@@ -120,8 +173,8 @@ export class BackfillCoordinator {
|
|
|
120
173
|
break;
|
|
121
174
|
batchTx(() => {
|
|
122
175
|
for (const r of staleEnt) {
|
|
123
|
-
db
|
|
124
|
-
db.prepare(`
|
|
176
|
+
deleteVector(db, 'entity_embeddings', r.rowid); // validity-0: left to the rebuild
|
|
177
|
+
db.prepare(`UPDATE entity_embedding_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL WHERE rowid = ?`).run(r.rowid);
|
|
125
178
|
touched++;
|
|
126
179
|
}
|
|
127
180
|
});
|
|
@@ -130,31 +183,37 @@ export class BackfillCoordinator {
|
|
|
130
183
|
for (;;) {
|
|
131
184
|
if (this.shuttingDown)
|
|
132
185
|
throw new Error('shutdown during sanitation');
|
|
133
|
-
const staleChunk = db.prepare(`SELECT m.rowid FROM chunk_metadata m
|
|
134
|
-
WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? LIMIT ${RECON_BATCH}`).all(profileId);
|
|
186
|
+
const staleChunk = db.prepare(`SELECT m.rowid FROM chunk_metadata m
|
|
187
|
+
WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ? AND m.rowid IN (SELECT rowid FROM chunks_rowids) LIMIT ${RECON_BATCH}`).all(profileId);
|
|
135
188
|
if (staleChunk.length === 0)
|
|
136
189
|
break;
|
|
137
190
|
batchTx(() => {
|
|
138
191
|
for (const r of staleChunk) {
|
|
139
|
-
db
|
|
192
|
+
deleteVector(db, 'chunks', r.rowid); // validity-0: left to the rebuild
|
|
140
193
|
db.prepare(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL, provenance_state = NULL WHERE rowid = ?`).run(r.rowid);
|
|
141
194
|
touched++;
|
|
142
195
|
}
|
|
143
196
|
});
|
|
144
197
|
await new Promise(r => setImmediate(r));
|
|
145
198
|
}
|
|
146
|
-
if (touched > 0)
|
|
147
|
-
|
|
199
|
+
if (touched > 0) {
|
|
200
|
+
this.snapshot = null; // cached coverage (incl. orphan_vectors) is stale — also on the legacy=0 early return (2_ r1 R2)
|
|
201
|
+
console.error(`🧾 provenance sanitation: ${touched} split-state/old-profile/orphan rows invalidated`);
|
|
202
|
+
}
|
|
148
203
|
return touched;
|
|
149
204
|
}
|
|
150
205
|
// Single-flight, idempotent. off mode defers (spec §9); fresh DBs go straight
|
|
151
206
|
// to 'n/a'. Row-level errors are fail-closed (vector deleted -> missing);
|
|
152
207
|
// systemic errors transition to 'failed' and keep eligibility shut.
|
|
153
208
|
runReconciliation() {
|
|
154
|
-
if (!this.reconPromise)
|
|
155
|
-
|
|
209
|
+
if (!this.reconPromise) {
|
|
210
|
+
// r3 M4: busy for the whole pass (sanitize included), not only while recon === 'running'.
|
|
211
|
+
this.reconActive = true;
|
|
212
|
+
this.reconPromise = this.reconcileOnce().finally(() => { this.reconActive = false; });
|
|
213
|
+
}
|
|
156
214
|
return this.reconPromise;
|
|
157
215
|
}
|
|
216
|
+
reconActive = false;
|
|
158
217
|
async reconcileOnce() {
|
|
159
218
|
const db = this.deps.db();
|
|
160
219
|
if (!db) {
|
|
@@ -233,8 +292,7 @@ export class BackfillCoordinator {
|
|
|
233
292
|
// grandfather candidates — metadata-without-vector was already removed by
|
|
234
293
|
// sanitize().
|
|
235
294
|
const rows = db.prepare(`SELECT m.rowid, m.entity_id, m.embedding_text FROM entity_embedding_metadata m
|
|
236
|
-
|
|
237
|
-
WHERE m.provenance_state IS NULL LIMIT ${RECON_BATCH}`).all();
|
|
295
|
+
WHERE m.provenance_state IS NULL AND ${searchableVectorSql('entity_embeddings', 'm.rowid')} LIMIT ${RECON_BATCH}`).all();
|
|
238
296
|
if (rows.length === 0)
|
|
239
297
|
return;
|
|
240
298
|
for (const row of rows) {
|
|
@@ -244,7 +302,7 @@ export class BackfillCoordinator {
|
|
|
244
302
|
const cur = db.prepare(`SELECT provenance_state FROM entity_embedding_metadata WHERE rowid = ?`).get(row.rowid);
|
|
245
303
|
if (!cur || cur.provenance_state !== null)
|
|
246
304
|
return;
|
|
247
|
-
db
|
|
305
|
+
deleteVector(db, 'entity_embeddings', row.rowid);
|
|
248
306
|
db.prepare(`DELETE FROM entity_embedding_metadata WHERE rowid = ?`).run(row.rowid);
|
|
249
307
|
});
|
|
250
308
|
const stampTx = db.transaction((hash) => {
|
|
@@ -300,8 +358,8 @@ export class BackfillCoordinator {
|
|
|
300
358
|
for (;;) {
|
|
301
359
|
if (this.shuttingDown)
|
|
302
360
|
throw new Error('shutdown during reconciliation');
|
|
303
|
-
const rows = db.prepare(`SELECT m.rowid, m.text FROM chunk_metadata m
|
|
304
|
-
WHERE m.provenance_state IS NULL LIMIT ${RECON_BATCH}`).all();
|
|
361
|
+
const rows = db.prepare(`SELECT m.rowid, m.text FROM chunk_metadata m
|
|
362
|
+
WHERE m.provenance_state IS NULL AND ${searchableVectorSql('chunks', 'm.rowid')} LIMIT ${RECON_BATCH}`).all();
|
|
305
363
|
if (rows.length === 0)
|
|
306
364
|
return;
|
|
307
365
|
for (const row of rows) {
|
|
@@ -313,7 +371,7 @@ export class BackfillCoordinator {
|
|
|
313
371
|
// Vector removed; provenance stays NULL. The barrier only protects
|
|
314
372
|
// VECTOR-BEARING NULL rows — vectorless rows are always legitimate
|
|
315
373
|
// backfill targets (spec §6c "벡터 없음").
|
|
316
|
-
db
|
|
374
|
+
deleteVector(db, 'chunks', row.rowid);
|
|
317
375
|
db.prepare(`UPDATE chunk_metadata SET input_hash = NULL, profile_id = NULL WHERE rowid = ?`)
|
|
318
376
|
.run(row.rowid);
|
|
319
377
|
});
|
|
@@ -452,13 +510,32 @@ export class BackfillCoordinator {
|
|
|
452
510
|
return consecutive >= 3 ? 'abort' : 'fail';
|
|
453
511
|
};
|
|
454
512
|
// Phase 1: chunks.
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
513
|
+
let sawInvalid = false;
|
|
514
|
+
const errorsBefore = JSON.stringify([...this.slotErrors]);
|
|
515
|
+
const slotErrorsChanged = () => JSON.stringify([...this.slotErrors]) !== errorsBefore;
|
|
516
|
+
let chunkRows = [];
|
|
517
|
+
try {
|
|
518
|
+
chunkRows = db.prepare(`SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state,
|
|
519
|
+
${searchableVectorSql('chunks', 'm.rowid')} AS has_vec,
|
|
520
|
+
EXISTS(SELECT 1 FROM chunks_rowids r WHERE r.rowid = m.rowid) AS has_row
|
|
521
|
+
FROM chunk_metadata m WHERE m.text IS NOT NULL`).all();
|
|
522
|
+
this.slotErrors.delete('chunks');
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
if (!(e instanceof VecSlotError) && !String(e.message).includes('vec0 slot'))
|
|
526
|
+
throw e;
|
|
527
|
+
this.slotErrors.set('chunks', e.message);
|
|
528
|
+
console.error(`⚠️ backfill: chunk slot map unresolvable, chunk phase held: ${e.message}`);
|
|
529
|
+
}
|
|
458
530
|
let processed = 0;
|
|
459
531
|
for (const row of chunkRows) {
|
|
460
532
|
if (this.shuttingDown || !this.eligible)
|
|
461
533
|
return;
|
|
534
|
+
if (row.has_row && !row.has_vec) {
|
|
535
|
+
this.requestRepair();
|
|
536
|
+
sawInvalid = true;
|
|
537
|
+
continue;
|
|
538
|
+
} // validity 0 -> rebuild
|
|
462
539
|
const currentHash = this.deps.chunkInputHash(row.text);
|
|
463
540
|
const isTarget = !row.has_vec
|
|
464
541
|
|| (row.provenance_state !== null && (row.profile_id !== profileId || row.input_hash !== currentHash));
|
|
@@ -475,13 +552,28 @@ export class BackfillCoordinator {
|
|
|
475
552
|
if (++processed % 8 === 0)
|
|
476
553
|
await new Promise(r => setImmediate(r));
|
|
477
554
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
555
|
+
let entityRows = [];
|
|
556
|
+
try {
|
|
557
|
+
entityRows = db.prepare(`SELECT e.id, m.input_hash, m.profile_id, m.provenance_state, m.rowid AS meta_rowid,
|
|
558
|
+
CASE WHEN m.rowid IS NOT NULL AND ${searchableVectorSql('entity_embeddings', 'm.rowid')} THEN 1 ELSE 0 END AS has_vec,
|
|
559
|
+
CASE WHEN m.rowid IS NOT NULL AND EXISTS(SELECT 1 FROM entity_embeddings_rowids r WHERE r.rowid = m.rowid) THEN 1 ELSE 0 END AS has_row
|
|
560
|
+
FROM entities e LEFT JOIN entity_embedding_metadata m ON m.entity_id = e.id`).all();
|
|
561
|
+
this.slotErrors.delete('entity_embeddings');
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
if (!(e instanceof VecSlotError) && !String(e.message).includes('vec0 slot'))
|
|
565
|
+
throw e;
|
|
566
|
+
this.slotErrors.set('entity_embeddings', e.message);
|
|
567
|
+
console.error(`⚠️ backfill: entity slot map unresolvable, entity phase held: ${e.message}`);
|
|
568
|
+
}
|
|
482
569
|
for (const row of entityRows) {
|
|
483
570
|
if (this.shuttingDown || !this.eligible)
|
|
484
571
|
return;
|
|
572
|
+
if (row.has_row && !row.has_vec) {
|
|
573
|
+
this.requestRepair();
|
|
574
|
+
sawInvalid = true;
|
|
575
|
+
continue;
|
|
576
|
+
} // validity 0 -> rebuild
|
|
485
577
|
const currentHash = this.deps.buildEntityInputHash(row.id);
|
|
486
578
|
if (currentHash === null)
|
|
487
579
|
continue; // malformed entity: leave to explicit repair
|
|
@@ -501,6 +593,8 @@ export class BackfillCoordinator {
|
|
|
501
593
|
if (++processed % 8 === 0)
|
|
502
594
|
await new Promise(r => setImmediate(r));
|
|
503
595
|
}
|
|
596
|
+
if (sawInvalid || slotErrorsChanged())
|
|
597
|
+
this.snapshot = null; // r3 M3: coverage reflects what the scan saw
|
|
504
598
|
if (processed > 0) {
|
|
505
599
|
this.snapshot = null;
|
|
506
600
|
console.error(`✅ backfill pass complete (${processed} rows examined for re-embedding)`);
|
|
@@ -519,12 +613,37 @@ export class BackfillCoordinator {
|
|
|
519
613
|
if (this.snapshot)
|
|
520
614
|
return this.snapshot;
|
|
521
615
|
const chunkTotal = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata`).get().c;
|
|
522
|
-
|
|
523
|
-
|
|
616
|
+
// Searchable only (D1c). A slot map error is reported per table instead of a count.
|
|
617
|
+
const byState = (sql) => {
|
|
618
|
+
try {
|
|
619
|
+
return { rows: db.prepare(sql).all() };
|
|
620
|
+
}
|
|
621
|
+
catch (e) {
|
|
622
|
+
if (!(e instanceof VecSlotError) && !String(e.message).includes('vec0 slot'))
|
|
623
|
+
throw e;
|
|
624
|
+
return { rows: [], error: e.message };
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const chunkQ = byState(`SELECT m.provenance_state s, COUNT(*) c FROM chunk_metadata m
|
|
628
|
+
WHERE ${searchableVectorSql('chunks', 'm.rowid')} GROUP BY m.provenance_state`);
|
|
629
|
+
const chunkBy = chunkQ.rows;
|
|
524
630
|
const entTotal = db.prepare(`SELECT COUNT(*) c FROM entities`).get().c;
|
|
525
631
|
// Vector-join (beta B3): metadata without an actual vector is missing, not embedded.
|
|
526
|
-
const
|
|
527
|
-
|
|
632
|
+
const entQ = byState(`SELECT m.provenance_state s, COUNT(*) c FROM entity_embedding_metadata m
|
|
633
|
+
WHERE ${searchableVectorSql('entity_embeddings', 'm.rowid')} GROUP BY m.provenance_state`);
|
|
634
|
+
const entBy = entQ.rows;
|
|
635
|
+
const orphanQ = (t, meta) => {
|
|
636
|
+
try {
|
|
637
|
+
return db.prepare(`SELECT COUNT(*) c FROM ${t}_rowids m WHERE m.rowid NOT IN (SELECT rowid FROM ${meta}) AND ${searchableVectorSql(t, 'm.rowid')}`).get().c;
|
|
638
|
+
}
|
|
639
|
+
catch (e) {
|
|
640
|
+
if (!(e instanceof VecSlotError) && !String(e.message).includes('vec0 slot'))
|
|
641
|
+
throw e;
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
const chunkOrphans = orphanQ('chunks', 'chunk_metadata');
|
|
646
|
+
const entOrphans = orphanQ('entity_embeddings', 'entity_embedding_metadata');
|
|
528
647
|
const pick = (rows, s) => rows.find(r => r.s === s)?.c ?? 0;
|
|
529
648
|
// Searchable = verified + legacy_assumed. NULL rows (pre-reconciliation or
|
|
530
649
|
// deferred in off mode) are NOT counted as embedded (6R note 4).
|
|
@@ -533,8 +652,10 @@ export class BackfillCoordinator {
|
|
|
533
652
|
const entVerified = pick(entBy, 'verified');
|
|
534
653
|
const entLegacy = pick(entBy, 'legacy_assumed');
|
|
535
654
|
this.snapshot = {
|
|
536
|
-
chunk: { total: chunkTotal, embedded: chunkVerified + chunkLegacy, verified: chunkVerified, legacy_assumed: chunkLegacy
|
|
537
|
-
|
|
655
|
+
chunk: { total: chunkTotal, embedded: chunkVerified + chunkLegacy, verified: chunkVerified, legacy_assumed: chunkLegacy, orphan_vectors: chunkOrphans,
|
|
656
|
+
...(chunkQ.error ? { error: chunkQ.error } : {}) },
|
|
657
|
+
entity: { total: entTotal, embedded: entVerified + entLegacy, verified: entVerified, legacy_assumed: entLegacy, orphan_vectors: entOrphans,
|
|
658
|
+
...(entQ.error ? { error: entQ.error } : {}) },
|
|
538
659
|
};
|
|
539
660
|
return this.snapshot;
|
|
540
661
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deleteVector } from '../vecSlots.js';
|
|
1
2
|
// active 행을 projection_order 순으로 모아 entities.observations 를 재작성한다.
|
|
2
3
|
// 단계 1 에서는 이 배열이 여전히 FTS/벡터의 입력이다 — 그래서 projection 갱신만으로
|
|
3
4
|
// 기존 entity FTS 트리거와 벡터 무효화가 따라온다(spec §4.5).
|
|
@@ -23,7 +24,7 @@ export function deleteStaleKgChunks(db, entityId) {
|
|
|
23
24
|
const chunks = db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type = 'entity' AND entity_id = ?`).all(entityId);
|
|
24
25
|
let n = 0;
|
|
25
26
|
for (const c of chunks) {
|
|
26
|
-
db
|
|
27
|
+
deleteVector(db, 'chunks', Number(c.rowid));
|
|
27
28
|
db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
|
|
28
29
|
n++;
|
|
29
30
|
}
|
|
@@ -39,7 +40,7 @@ export function deleteKgRelationshipChunks(db, relationshipIds) {
|
|
|
39
40
|
for (const rid of relationshipIds) {
|
|
40
41
|
const chunks = db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type = 'relationship' AND relationship_id = ?`).all(rid);
|
|
41
42
|
for (const c of chunks) {
|
|
42
|
-
db
|
|
43
|
+
deleteVector(db, 'chunks', Number(c.rowid));
|
|
43
44
|
db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
|
|
44
45
|
n++;
|
|
45
46
|
}
|
|
@@ -60,7 +61,7 @@ export function deleteKgChunkRows(db, rowids) {
|
|
|
60
61
|
const row = db.prepare(`SELECT chunk_type FROM chunk_metadata WHERE rowid = ?`).get(rowid);
|
|
61
62
|
if (!row || (row.chunk_type !== 'entity' && row.chunk_type !== 'relationship'))
|
|
62
63
|
continue;
|
|
63
|
-
db
|
|
64
|
+
deleteVector(db, 'chunks', rowid);
|
|
64
65
|
db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(rowid);
|
|
65
66
|
n += db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(rowid).changes;
|
|
66
67
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vec0 tables (chunks · entity_embeddings) are created without `distance_metric`, so sqlite-vec returns the
|
|
3
|
+
* default L2 distance. Embeddings are unit-normalized (`normalize: true`), so for unit vectors
|
|
4
|
+
* ‖a − b‖² = 2 − 2·cos → cosine similarity = 1 − d²/2.
|
|
5
|
+
* The earlier conversion 1 − d/2 treated the L2 value as a cosine distance: right − old = (d − d²)/2, so it
|
|
6
|
+
* under-reported for d < 1 (cos > 0.5: cos 0.8 showed ≈0.684) and over-reported for d > 1 (orthogonal showed
|
|
7
|
+
* ≈0.293 instead of 0 · cos 0.3 showed ≈0.408 and passed the 0.4 graph-seed threshold). Clamped to [0, 1].
|
|
8
|
+
* Guarded by test/vec-similarity.test.mjs (also asserts the metric is still L2).
|
|
9
|
+
*/
|
|
10
|
+
export declare function l2ToCosineSimilarity(distance: number): number;
|
|
11
|
+
/**
|
|
12
|
+
* The 6.3.2 score, kept ONLY for the legacy paths that are pinned byte-for-byte (engine commit 4ac3155 · test
|
|
13
|
+
* graph-context-explain): hybridSearch with useGraph:true or summaries on, and the graph-seed `> 0.4` threshold of
|
|
14
|
+
* explainGraphContext. It is the historical 1 − d/2 conversion of an L2 distance and is NOT a cosine similarity
|
|
15
|
+
* (its 0.4 threshold equals a real cosine of about 0.28). Default search and searchNodes use l2ToCosineSimilarity.
|
|
16
|
+
*/
|
|
17
|
+
export declare function legacyL2AsCosineScore(distance: number): number;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vec0 tables (chunks · entity_embeddings) are created without `distance_metric`, so sqlite-vec returns the
|
|
3
|
+
* default L2 distance. Embeddings are unit-normalized (`normalize: true`), so for unit vectors
|
|
4
|
+
* ‖a − b‖² = 2 − 2·cos → cosine similarity = 1 − d²/2.
|
|
5
|
+
* The earlier conversion 1 − d/2 treated the L2 value as a cosine distance: right − old = (d − d²)/2, so it
|
|
6
|
+
* under-reported for d < 1 (cos > 0.5: cos 0.8 showed ≈0.684) and over-reported for d > 1 (orthogonal showed
|
|
7
|
+
* ≈0.293 instead of 0 · cos 0.3 showed ≈0.408 and passed the 0.4 graph-seed threshold). Clamped to [0, 1].
|
|
8
|
+
* Guarded by test/vec-similarity.test.mjs (also asserts the metric is still L2).
|
|
9
|
+
*/
|
|
10
|
+
export function l2ToCosineSimilarity(distance) {
|
|
11
|
+
if (!Number.isFinite(distance))
|
|
12
|
+
return 0;
|
|
13
|
+
return Math.min(1, Math.max(0, 1 - (distance * distance) / 2));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The 6.3.2 score, kept ONLY for the legacy paths that are pinned byte-for-byte (engine commit 4ac3155 · test
|
|
17
|
+
* graph-context-explain): hybridSearch with useGraph:true or summaries on, and the graph-seed `> 0.4` threshold of
|
|
18
|
+
* explainGraphContext. It is the historical 1 − d/2 conversion of an L2 distance and is NOT a cosine similarity
|
|
19
|
+
* (its 0.4 threshold equals a real cosine of about 0.28). Default search and searchNodes use l2ToCosineSimilarity.
|
|
20
|
+
*/
|
|
21
|
+
export function legacyL2AsCosineScore(distance) {
|
|
22
|
+
return Math.max(0, 1 - distance / 2);
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type VecTable = 'chunks' | 'entity_embeddings';
|
|
3
|
+
export type SlotState = 'valid' | 'invalid' | 'shared' | 'absent';
|
|
4
|
+
export declare class VecSlotError extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function bitIsSet(validity: unknown, offset: unknown, size: unknown): boolean;
|
|
8
|
+
export declare function slotState(db: Database.Database, table: VecTable, rowid: number | bigint): SlotState;
|
|
9
|
+
export declare class InvalidSlotError extends VecSlotError {
|
|
10
|
+
constructor(table: string, rowid: number | bigint);
|
|
11
|
+
}
|
|
12
|
+
export declare function writeVector(db: Database.Database, table: VecTable, rowid: number | bigint, embedding: Buffer): SlotState;
|
|
13
|
+
export declare function deleteVector(db: Database.Database, table: VecTable, rowid: number | bigint): SlotState;
|
|
14
|
+
export declare function registerVecSlotFunctions(db: Database.Database): void;
|
|
15
|
+
export declare function searchableVectorSql(table: VecTable, rowidExpr: string): string;
|
|
16
|
+
export interface SlotSummary {
|
|
17
|
+
table: VecTable;
|
|
18
|
+
chunks: number;
|
|
19
|
+
chunkSize: number;
|
|
20
|
+
slots: number;
|
|
21
|
+
valid: number;
|
|
22
|
+
invalid: number;
|
|
23
|
+
aliased: number;
|
|
24
|
+
empty: number;
|
|
25
|
+
reclaimable: number;
|
|
26
|
+
reclaimableBytes: number;
|
|
27
|
+
rewriteBytes: number;
|
|
28
|
+
}
|
|
29
|
+
export declare function slotSummary(db: Database.Database, table: VecTable, dims?: number): SlotSummary;
|
|
30
|
+
export interface RebuildPolicy {
|
|
31
|
+
minChunks: number;
|
|
32
|
+
maxLive: number;
|
|
33
|
+
}
|
|
34
|
+
export declare const DEFAULT_REBUILD_POLICY: RebuildPolicy;
|
|
35
|
+
export declare function rebuildReason(s: SlotSummary, policy?: RebuildPolicy): string | null;
|
|
36
|
+
export type RebuildFault = 'after-drop' | 'after-create' | 'after-partial' | 'corrupt-one';
|
|
37
|
+
export interface RebuildResult {
|
|
38
|
+
table: VecTable;
|
|
39
|
+
status: 'rebuilt' | 'not-needed' | 'busy' | 'position-error' | 'over-cap' | 'failed';
|
|
40
|
+
reason?: string;
|
|
41
|
+
before?: SlotSummary;
|
|
42
|
+
after?: SlotSummary;
|
|
43
|
+
ms: number;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function rebuildVecTable(db: Database.Database, table: VecTable, opts?: {
|
|
47
|
+
policy?: RebuildPolicy;
|
|
48
|
+
force?: boolean;
|
|
49
|
+
fault?: RebuildFault;
|
|
50
|
+
dims?: number;
|
|
51
|
+
}): RebuildResult;
|