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.
@@ -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 JOIN entity_embeddings v ON v.rowid = m.rowid
58
- WHERE m.provenance_state IS NULL`).get();
59
- const chk = db.prepare(`SELECT COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
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 without vector (split state) -> delete metadata (row becomes a backfill target)
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
- + q(`SELECT COUNT(*) c FROM entity_embeddings WHERE rowid NOT IN (SELECT rowid FROM entity_embedding_metadata)`)
79
- + q(`SELECT COUNT(*) c FROM entity_embedding_metadata WHERE rowid NOT IN (SELECT rowid FROM entity_embeddings)`)
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 JOIN chunks v ON v.rowid = m.rowid WHERE m.provenance_state IS NOT NULL AND m.profile_id IS NOT ?`, profileId)
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 chunks)`);
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
- await batchedRun(`DELETE FROM entity_embeddings WHERE rowid IN (
107
- SELECT rowid FROM entity_embeddings WHERE rowid NOT IN (SELECT rowid FROM entity_embedding_metadata) LIMIT ${RECON_BATCH})`);
108
- await batchedRun(`DELETE FROM entity_embedding_metadata WHERE rowid IN (
109
- SELECT rowid FROM entity_embedding_metadata WHERE rowid NOT IN (SELECT rowid FROM entity_embeddings) LIMIT ${RECON_BATCH})`);
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 chunks) LIMIT ${RECON_BATCH})`);
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.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(r.rowid)}`);
124
- db.prepare(`DELETE FROM entity_embedding_metadata WHERE rowid = ?`).run(r.rowid);
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 JOIN chunks v ON v.rowid = m.rowid
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.exec(`DELETE FROM chunks WHERE rowid = ${Number(r.rowid)}`);
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
- console.error(`🧾 provenance sanitation: ${touched} split-state/old-profile rows invalidated`);
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
- this.reconPromise = this.reconcileOnce();
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
- JOIN entity_embeddings v ON v.rowid = m.rowid
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.exec(`DELETE FROM entity_embeddings WHERE rowid = ${row.rowid}`);
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 JOIN chunks v ON v.rowid = m.rowid
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.exec(`DELETE FROM chunks WHERE rowid = ${row.rowid}`);
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
- const chunkRows = db.prepare(`SELECT m.rowid, m.text, m.input_hash, m.profile_id, m.provenance_state,
456
- EXISTS(SELECT 1 FROM chunks v WHERE v.rowid = m.rowid) AS has_vec
457
- FROM chunk_metadata m WHERE m.text IS NOT NULL`).all();
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
- // Phase 2: entities.
479
- const entityRows = db.prepare(`SELECT e.id, m.input_hash, m.profile_id, m.provenance_state, m.rowid AS meta_rowid,
480
- CASE WHEN m.rowid IS NOT NULL AND EXISTS(SELECT 1 FROM entity_embeddings v WHERE v.rowid = m.rowid) THEN 1 ELSE 0 END AS has_vec
481
- FROM entities e LEFT JOIN entity_embedding_metadata m ON m.entity_id = e.id`).all();
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
- const chunkBy = db.prepare(`SELECT m.provenance_state s, COUNT(*) c FROM chunk_metadata m JOIN chunks v ON v.rowid = m.rowid
523
- GROUP BY m.provenance_state`).all();
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 entBy = db.prepare(`SELECT m.provenance_state s, COUNT(*) c FROM entity_embedding_metadata m
527
- JOIN entity_embeddings v ON v.rowid = m.rowid GROUP BY m.provenance_state`).all();
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
- entity: { total: entTotal, embedded: entVerified + entLegacy, verified: entVerified, legacy_assumed: entLegacy },
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.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
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.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
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.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
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;