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 CHANGED
@@ -154,6 +154,48 @@ storeDocument(id, content, metadata)
154
154
 
155
155
  ## Changelog
156
156
 
157
+ ### v6.4.4 (2026-11-07)
158
+
159
+ - **Fixed — the similarity numbers are real cosine similarities.** The vec0 tables use the default L2 metric and the
160
+ embeddings are unit-normalized, so cosine similarity = 1 − d²/2. The engine used 1 − d/2 (L2 read as a cosine
161
+ distance): `searchNodes` `similarity` and the default `hybridSearch` `vector_similarity` were too low above cos 0.5
162
+ (cos 0.8 showed ≈0.684) and too high below it (orthogonal vectors showed ≈0.293 instead of 0). Ranking of the default
163
+ search is unchanged (rank fusion uses ranks only).
164
+ - **Unchanged on purpose — the legacy paths keep the 6.3.2 score.** `hybridSearch` with `useGraph: true` or with
165
+ summaries on, and the graph-seed threshold (`> 0.4`, about cosine 0.28), stay byte-identical to 6.3.2 for evaluation
166
+ comparability; that score is named `legacyL2AsCosineScore` and is not a cosine.
167
+ - **Fixed — chunk vectors without metadata are cleaned up.** Reconciliation now deletes searchable chunk vectors that
168
+ have no `chunk_metadata` row (same rule and batching as entity orphans; off mode only classifies it as deferred).
169
+ `importGraph` no longer creates them: a full replace (`merge: false`) and a same-id document replace delete that
170
+ document's chunk vectors first. Rowids past 2^53 are kept exact.
171
+ - **Added — `getKnowledgeGraphStats().server.coverage.{chunk,entity}.orphan_vectors`**: searchable vectors with no
172
+ metadata row (`null` when the slot map cannot be resolved). `missing` keeps its meaning.
173
+ - Not measured: Windows, Drive-synced DBs under two machines.
174
+
175
+ ### v6.4.3 (2026-11-02)
176
+
177
+ - **Fixed — re-embedding no longer leaks vector slots.** sqlite-vec puts a new row only into a free slot of the latest
178
+ 1,024-slot chunk, so the old DELETE + INSERT of the same rowid left a hole whenever the row sat in an older chunk (one
179
+ project DB reached 84 chunks for 5,578 vectors, 93.5% empty, 337 of 439 MB). Same-rowid re-embeds (document chunks,
180
+ KG chunks, bulk `embedChunks`, entities) now rewrite the vector in place; entity re-embeds keep their rowid.
181
+ - **Added — idle verified rebuild of the vector tables.** When the engine is idle (no tool call, backfill or
182
+ reconciliation; 60 s quiet; checked at most every 10 min) and a table has at least 8 reclaimable chunks that outweigh
183
+ its live vectors, or holds damaged slots, it is rebuilt in one transaction keeping every searchable rowid and its
184
+ exact vector bytes (verified before commit; rolled back otherwise). Skipped at once if another connection holds the
185
+ write lock. Automatic only up to 20,000 live vectors per table (measured on one Mac: 5,600 = 0.44 s, 20,000 =
186
+ 1.24 s and +267 MB transient). No VACUUM: the file stops growing; it shrinks only through a VACUUM.
187
+ - **Fixed — coverage counts only searchable vectors.** A row whose slot validity bit is 0 (invisible to search) was
188
+ counted as embedded; it is now missing and is repaired: writes and deletes that meet such a slot, or a slot shared by
189
+ two rowids, rebuild the table first in the same transaction, then the backfill re-embeds at the original rowid.
190
+ An unresolvable slot position is reported as an error (`missing: null`), never as a count.
191
+ - **Added — `getKnowledgeGraphStats().server.vector_slots`**: per table slots, valid, validity-0, shared, empty,
192
+ reclaimable chunks, whether a rebuild is due and why, and this process's last attempt.
193
+ - **Added — write-seq signal** (`[rag-memory] write-seq N` on stderr when the engine's own connection changed rows),
194
+ used by the framework launcher to record a DB handoff only after the engine wrote.
195
+ - **Changed — an entity metadata row whose vector row is gone is kept unstamped (missing) instead of deleted**, so the
196
+ backfill re-inserts at the same rowid; new entity rowids are allocated past both tables.
197
+ - Not measured: Windows, Drive-synced DBs under two machines, tables above 20,000 vectors.
198
+
157
199
  ### v6.4.2 (2026-10-31)
158
200
 
159
201
  - **Fixed — the engine no longer keeps the peak memory of its largest embedding.** The ONNX session is opened with
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { EVIDENCE_FORMAT, type EvidenceItemInput, type EvidenceStatus, type Evid
4
4
  import { EmbeddingGate } from './src/embeddingGate.js';
5
5
  import type { EmbedPriority } from './src/embeddingGate.js';
6
6
  import { BackfillCoordinator } from './src/backfillCoordinator.js';
7
+ import type { RebuildResult, VecTable } from './src/vecSlots.js';
7
8
  export declare const LEGACY_QUERY_INSTRUCTION = "Represent this sentence for searching relevant passages: ";
8
9
  export declare function embeddingInput(text: string, isQuery: boolean, defaultModel?: boolean): string;
9
10
  export declare function makeEmbedFn(model: (input: string, opts: any) => Promise<{
@@ -132,6 +133,20 @@ export declare class RAGKnowledgeGraphManager {
132
133
  __testForceFkOff?: boolean;
133
134
  }): Promise<void>;
134
135
  private openConnection;
136
+ writeSeq: number;
137
+ private tcBase;
138
+ private lastActivityAt;
139
+ private lastWriteAt;
140
+ private lastSlotCheckAt;
141
+ readonly vectorSlotLast: Partial<Record<VecTable, RebuildResult & {
142
+ at: string;
143
+ }>>;
144
+ private static slotCheckMs;
145
+ private static slotQuietMs;
146
+ maybeRebuildVectors(now?: number): RebuildResult[] | null;
147
+ vectorSlotStats(): Record<string, unknown>;
148
+ private totalChanges;
149
+ noteWrites(): void;
135
150
  private ensureCurrentProfile;
136
151
  private buildRealLoader;
137
152
  startReconciliation(): Promise<void>;
@@ -474,7 +489,7 @@ export declare class RAGKnowledgeGraphManager {
474
489
  }>;
475
490
  /**
476
491
  * Diagnostic seam for the graph re-ranker (evaluation change graph-role-evaluation, R2).
477
- * Returns the seed entities (query-vector matched, similarity > 0.4, top-10 per variant) and the
492
+ * Returns the seed entities (query-vector matched, legacy score > 0.4 = cosine ≈0.28, top-10 per variant) and the
478
493
  * 1-hop connected entities exactly as hybridSearch(useGraph:true) computes them, plus edge detail
479
494
  * that hybridSearch itself does not use (edge id, type, direction, confidence). It never generates
480
495
  * candidates and never changes ranking; hybridSearch consumes only the name sets.
package/dist/index.js CHANGED
@@ -34,6 +34,8 @@ import { migrations } from './src/migrations/migrations.js';
34
34
  import { EmbeddingGate, GateNotReadyError, GateDisabledError, TerminalConfigError } from './src/embeddingGate.js';
35
35
  import { resolveModelCacheDir, preflightCacheDir, artifactKey, ModelDownloadLock, handleLoaderFailure } from './src/modelCache.js';
36
36
  import { BackfillCoordinator } from './src/backfillCoordinator.js';
37
+ import { l2ToCosineSimilarity, legacyL2AsCosineScore } from './src/vecSimilarity.js';
38
+ import { writeVector, deleteVector, registerVecSlotFunctions, InvalidSlotError, slotSummary, rebuildVecTable, rebuildReason, DEFAULT_REBUILD_POLICY } from './src/vecSlots.js';
37
39
  import { calendarDate, resolveCalendarTimeZone, stampDatePrefix, stripDatePrefix } from './src/observations/date-prefix.js';
38
40
  import os from 'node:os';
39
41
  import { createHash } from 'crypto';
@@ -326,6 +328,7 @@ export class RAGKnowledgeGraphManager {
326
328
  openConnection(forceFkOff = false) {
327
329
  this.db = new Database(DB_FILE_PATH);
328
330
  sqliteVec.load(this.db);
331
+ registerVecSlotFunctions(this.db); // vec_slot_valid() for the D1c searchability predicate
329
332
  this.db.pragma('journal_mode = WAL');
330
333
  this.db.pragma('synchronous = NORMAL');
331
334
  this.db.pragma('busy_timeout = 5000');
@@ -356,6 +359,119 @@ export class RAGKnowledgeGraphManager {
356
359
  `for history integrity; refusing to run migrations without it.`);
357
360
  }
358
361
  }
362
+ // Write-sequence baseline for THIS connection (see noteWrites). A fresh connection starts
363
+ // its own total_changes() count, so the baseline is reset on every open; the announced
364
+ // sequence itself never goes back.
365
+ this.tcBase = this.totalChanges();
366
+ }
367
+ // --- Write-sequence signal (test/write-seq.test.mjs) ------------------------------------
368
+ // One stderr line per observation that THIS connection changed rows:
369
+ // [rag-memory] write-seq <N> (N = 1, 2, 3, … for the life of the process)
370
+ // The framework launcher (mcp-proxy.cjs) records "the DB state this machine left" only
371
+ // after such a line. Before this signal it recorded whenever the file changed and went
372
+ // quiet — which also fired when a cloud sync client swapped the other machine's file in
373
+ // under an idle engine, and that record then refused a correct DB (measured 2026-11-02).
374
+ // total_changes() counts rows changed by this connection (vec0 shadow tables included);
375
+ // reads and other connections do not move it. It is read outside a transaction only, so a
376
+ // statement still inside one is announced after it ends. It also counts rows of a
377
+ // transaction that was rolled back: a line means "this connection's change count went up",
378
+ // not "a commit happened". The launcher consumes a pending sequence once the file it last
379
+ // recorded is still there, the WAL is empty and the file is stable; until then a sequence that
380
+ // came from a rollback can still publish a file another writer or a sync client changed, and
381
+ // that period has no fixed upper bound (hub session 251 review r1 M1 · r3. r2: a WAL-size
382
+ // test was tried and removed — a header-only WAL looks non-empty and another connection's
383
+ // checkpoint hides a real commit).
384
+ // Written with fs.writeSync: the last call runs inside process 'exit', where an async
385
+ // stderr pipe write (macOS) can be lost.
386
+ writeSeq = 0;
387
+ tcBase = 0;
388
+ // ---- vec-slot-reuse D1b: idle verified rebuild of the vec0 tables -------------------------------
389
+ // Initial operating values (2_ s253 r2 Q3: starting values, not optima): look at most every 10 min,
390
+ // only after 60 s without a tool call or an own-connection write, never while a tool call, a backfill
391
+ // scan or a reconciliation is running. A repair request (validity-0 rows seen) skips the 10-min wait.
392
+ // The saving threshold and the live-vector cap live in DEFAULT_REBUILD_POLICY (src/vecSlots.ts).
393
+ lastActivityAt = Date.now();
394
+ lastWriteAt = 0;
395
+ lastSlotCheckAt = 0;
396
+ // Last attempt per table. Process memory only: unknown after a restart (stats says so).
397
+ vectorSlotLast = {};
398
+ static slotCheckMs = Number(process.env.RAG_MEMORY_SLOT_CHECK_MS ?? 600_000);
399
+ static slotQuietMs = Number(process.env.RAG_MEMORY_SLOT_QUIET_MS ?? 60_000);
400
+ maybeRebuildVectors(now = Date.now()) {
401
+ if (!this.db || this.shuttingDown || this.inFlight > 0 || this.db.inTransaction)
402
+ return null;
403
+ if (this.coordinator?.busy)
404
+ return null;
405
+ if (now - Math.max(this.lastActivityAt, this.lastWriteAt) < RAGKnowledgeGraphManager.slotQuietMs)
406
+ return null;
407
+ // A repair request skips the 10-min wait once: only if it arrived after the last check (r3 M5 — otherwise
408
+ // a busy / failed / over-cap attempt would be retried on every 1 s tick).
409
+ const repair = this.coordinator?.repairRequested ?? false;
410
+ const freshRepair = repair && (this.coordinator?.repairRequestedAt ?? 0) > this.lastSlotCheckAt;
411
+ if (!freshRepair && now - this.lastSlotCheckAt < RAGKnowledgeGraphManager.slotCheckMs)
412
+ return null;
413
+ this.lastSlotCheckAt = now;
414
+ const results = [];
415
+ for (const table of ['chunks', 'entity_embeddings']) {
416
+ const r = rebuildVecTable(this.db, table);
417
+ this.vectorSlotLast[table] = { ...r, at: new Date(now).toISOString() };
418
+ results.push(r);
419
+ if (r.status === 'rebuilt') {
420
+ console.error(`🧹 vec0 ${table} rebuilt (${r.reason}): chunks ${r.before?.chunks} -> ${r.after?.chunks}, ` +
421
+ `valid ${r.after?.valid}, dropped validity-0 ${r.before?.invalid}, shared-slot ${r.before?.aliased}, ${r.ms} ms`);
422
+ }
423
+ else if (r.status !== 'not-needed') {
424
+ console.error(`⚠️ vec0 ${table} rebuild ${r.status}${r.error ? `: ${r.error}` : ''}`);
425
+ }
426
+ }
427
+ if (repair && results.every(r => r.status === 'rebuilt' || r.status === 'not-needed'))
428
+ this.coordinator?.takeRepairRequest();
429
+ if (results.some(r => r.status === 'rebuilt')) {
430
+ this.coordinator?.invalidateCoverage();
431
+ this.noteWrites();
432
+ this.coordinator?.kick(); // rows dropped as validity-0 are now missing -> re-embedded at their rowid
433
+ }
434
+ return results;
435
+ }
436
+ vectorSlotStats() {
437
+ const out = {};
438
+ for (const table of ['chunks', 'entity_embeddings']) {
439
+ let summary;
440
+ try {
441
+ summary = slotSummary(this.db, table);
442
+ }
443
+ catch (e) {
444
+ summary = { error: e.message };
445
+ }
446
+ const pending = 'error' in summary ? 'position-error'
447
+ : (summary.valid > DEFAULT_REBUILD_POLICY.maxLive ? 'over-cap' : (rebuildReason(summary) ?? null));
448
+ const last = this.vectorSlotLast[table];
449
+ out[table] = { ...summary, due: pending,
450
+ last_attempt: last ? { at: last.at, status: last.status, reason: last.reason ?? null, ms: last.ms, error: last.error ?? null }
451
+ : 'unknown (process memory; none since start)' };
452
+ }
453
+ out.policy = { ...DEFAULT_REBUILD_POLICY, check_ms: RAGKnowledgeGraphManager.slotCheckMs, quiet_ms: RAGKnowledgeGraphManager.slotQuietMs,
454
+ repair_requested: this.coordinator?.repairRequested ?? false, vacuum: 'never (maintenance script only)' };
455
+ return out;
456
+ }
457
+ totalChanges() {
458
+ if (!this.db)
459
+ return 0;
460
+ return Number(this.db.prepare('select total_changes()').pluck().get());
461
+ }
462
+ noteWrites() {
463
+ try {
464
+ if (!this.db || this.db.inTransaction)
465
+ return;
466
+ const tc = this.totalChanges();
467
+ if (tc <= this.tcBase)
468
+ return;
469
+ this.tcBase = tc;
470
+ this.lastWriteAt = Date.now();
471
+ this.writeSeq += 1;
472
+ fsSync.writeSync(2, `[rag-memory] write-seq ${this.writeSeq}\n`);
473
+ }
474
+ catch { /* a dead handle is the reopen path's business; the next tick tries again */ }
359
475
  }
360
476
  // Upsert the stored-vector compatibility profile (spec §6c layer 2) and record
361
477
  // retrieval config in server_meta (layer 3 — never a backfill trigger).
@@ -552,7 +668,7 @@ export class RAGKnowledgeGraphManager {
552
668
  const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
553
669
  .get(entityId);
554
670
  if (meta) {
555
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
671
+ deleteVector(this.db, 'entity_embeddings', Number(meta.rowid));
556
672
  this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
557
673
  }
558
674
  deleteStaleKgChunks(this.db, entityId);
@@ -567,7 +683,7 @@ export class RAGKnowledgeGraphManager {
567
683
  if (!meta)
568
684
  return;
569
685
  const tx = this.db.transaction(() => {
570
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
686
+ deleteVector(this.db, 'entity_embeddings', Number(meta.rowid));
571
687
  this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
572
688
  });
573
689
  tx();
@@ -597,8 +713,7 @@ export class RAGKnowledgeGraphManager {
597
713
  return false; // inputs changed
598
714
  if (this.currentProfileId !== captured.profile_id)
599
715
  return false; // profile changed during inference
600
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${rowid}`);
601
- this.db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(Buffer.from(embedding.buffer));
716
+ writeVector(this.db, 'chunks', rowid, Buffer.from(embedding.buffer)); // same slot when valid (vec-slot-reuse)
602
717
  this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
603
718
  .run(captured.text_sha256, captured.profile_id, rowid);
604
719
  const meta = parseEvidenceMetadata(cur.metadata);
@@ -610,7 +725,17 @@ export class RAGKnowledgeGraphManager {
610
725
  this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id = ?`).run(String(rowid));
611
726
  return true;
612
727
  });
613
- const written = tx();
728
+ let written;
729
+ try {
730
+ written = tx();
731
+ }
732
+ catch (e) {
733
+ if (e instanceof InvalidSlotError) {
734
+ this.coordinator?.requestRepair();
735
+ return false;
736
+ }
737
+ throw e;
738
+ }
614
739
  this.coordinator?.invalidateCoverage();
615
740
  return written;
616
741
  }
@@ -648,14 +773,23 @@ export class RAGKnowledgeGraphManager {
648
773
  const cur = this.db.prepare(`SELECT text FROM chunk_metadata WHERE rowid = ?`).get(rowid);
649
774
  if (!cur || cur.text !== row.text)
650
775
  return false; // superseded — discard
651
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safe}`);
652
- this.db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${safe}, ?)`).run(Buffer.from(embedding.buffer));
776
+ writeVector(this.db, 'chunks', safe, Buffer.from(embedding.buffer));
653
777
  this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
654
778
  .run(hash, this.currentProfileId, rowid);
655
779
  this.db.prepare(`DELETE FROM embedding_backfill_failures WHERE kind = 'chunk' AND target_id = ?`).run(String(rowid));
656
780
  return true;
657
781
  });
658
- const written = tx();
782
+ let written;
783
+ try {
784
+ written = tx();
785
+ }
786
+ catch (e) {
787
+ if (e instanceof InvalidSlotError) {
788
+ this.coordinator?.requestRepair();
789
+ return false;
790
+ }
791
+ throw e;
792
+ }
659
793
  this.coordinator?.invalidateCoverage();
660
794
  return written;
661
795
  }
@@ -872,6 +1006,7 @@ export class RAGKnowledgeGraphManager {
872
1006
  console.error(`⚠️ reopen storm (${REOPEN_STORM_MAX} in ${REOPEN_STORM_WINDOW_MS / 1000}s) — watch paused, reason=${reason}`);
873
1007
  return false;
874
1008
  }
1009
+ this.noteWrites(); // rows changed on the old connection are announced before its count is gone
875
1010
  const old = this.db;
876
1011
  // Salvage: frames committed to the old WAL after the last checkpoint (≤1 s) go to the main
877
1012
  // file if the old descriptor is still readable. An I/O error here just means they are gone.
@@ -977,14 +1112,22 @@ export class RAGKnowledgeGraphManager {
977
1112
  catch (e) {
978
1113
  console.error(`⚠️ wal tick: ${e.message}`);
979
1114
  }
1115
+ try {
1116
+ this.maybeRebuildVectors();
1117
+ }
1118
+ catch (e) {
1119
+ console.error(`⚠️ vec0 rebuild check: ${e.message}`);
1120
+ }
980
1121
  this.checkpointWal();
1122
+ this.noteWrites();
981
1123
  }, tickMs);
982
1124
  this.walTimer.unref();
983
1125
  }
984
1126
  noteActivity(delayMs = 200) {
1127
+ this.lastActivityAt = Date.now();
985
1128
  if (this.walSoon)
986
1129
  return;
987
- this.walSoon = setTimeout(() => { this.walSoon = null; this.checkpointWal(); }, delayMs);
1130
+ this.walSoon = setTimeout(() => { this.walSoon = null; this.checkpointWal(); this.noteWrites(); }, delayMs);
988
1131
  this.walSoon.unref();
989
1132
  }
990
1133
  cleanup() {
@@ -1006,6 +1149,7 @@ export class RAGKnowledgeGraphManager {
1006
1149
  // close() folds the WAL only for the last connection; with other engines attached it
1007
1150
  // would leave every frame behind.
1008
1151
  this.checkpointWal();
1152
+ this.noteWrites();
1009
1153
  this.db.close();
1010
1154
  this.db = null;
1011
1155
  }
@@ -1319,13 +1463,11 @@ export class RAGKnowledgeGraphManager {
1319
1463
  SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?
1320
1464
  `).get(eid);
1321
1465
  if (embeddingMetadata) {
1322
- const embeddings = this.db.prepare(`
1323
- DELETE FROM entity_embeddings WHERE rowid = ?
1324
- `).run(embeddingMetadata.rowid);
1466
+ const vecState = deleteVector(this.db, 'entity_embeddings', Number(embeddingMetadata.rowid));
1325
1467
  const metadata = this.db.prepare(`
1326
1468
  DELETE FROM entity_embedding_metadata WHERE entity_id = ?
1327
1469
  `).run(eid);
1328
- if (embeddings.changes > 0 || metadata.changes > 0) {
1470
+ if (vecState === 'valid' || metadata.changes > 0) {
1329
1471
  console.error(` ├─ Removed entity embeddings for '${name}'`);
1330
1472
  }
1331
1473
  }
@@ -1446,7 +1588,7 @@ export class RAGKnowledgeGraphManager {
1446
1588
  const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
1447
1589
  .get(p.entityId);
1448
1590
  if (meta) {
1449
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
1591
+ deleteVector(this.db, 'entity_embeddings', Number(meta.rowid));
1450
1592
  this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`)
1451
1593
  .run(p.entityId);
1452
1594
  }
@@ -1753,7 +1895,7 @@ export class RAGKnowledgeGraphManager {
1753
1895
  name: result.name,
1754
1896
  entityType: result.entityType,
1755
1897
  observations: JSON.parse(result.observations),
1756
- similarity: Math.max(0, 1 - result.distance / 2) // Convert cosine distance (0-2) to similarity (1-0)
1898
+ similarity: l2ToCosineSimilarity(result.distance) // vec0 L2 distance of unit vectors -> cosine similarity
1757
1899
  }));
1758
1900
  // hybrid-partial (spec §4): entities without vectors must not vanish from
1759
1901
  // search while backfill catches up — merge lexical FTS hits for the gap.
@@ -2059,19 +2201,40 @@ export class RAGKnowledgeGraphManager {
2059
2201
  SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?
2060
2202
  `).get(entityId);
2061
2203
  if (existingMetadata) {
2062
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(existingMetadata.rowid)}`);
2063
- this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
2204
+ // vec-slot-reuse: keep the rowid and rewrite the vector in place (DELETE + INSERT with a new
2205
+ // rowid leaked one vec0 slot per re-embed). The metadata row is updated, not replaced.
2206
+ writeVector(this.db, 'entity_embeddings', Number(existingMetadata.rowid), Buffer.from(embedding.buffer));
2207
+ this.db.prepare(`
2208
+ UPDATE entity_embedding_metadata
2209
+ SET embedding_text = ?, input_hash = ?, profile_id = ?, provenance_state = 'verified'
2210
+ WHERE rowid = ?
2211
+ `).run(embeddingText, inputHash, this.currentProfileId, existingMetadata.rowid);
2212
+ return true;
2064
2213
  }
2065
- const result = this.db.prepare(`
2066
- INSERT INTO entity_embeddings (embedding) VALUES (?)
2067
- `).run(Buffer.from(embedding.buffer));
2214
+ // r4 M2: metadata rows can now outlive their vector (kept unstamped to keep the rowid), so the vec0
2215
+ // auto rowid could collide with one. Allocate past both tables, in this transaction.
2216
+ const nextRowid = BigInt(this.db.prepare(`SELECT MAX(r) m FROM (
2217
+ SELECT MAX(rowid) r FROM entity_embedding_metadata UNION ALL SELECT MAX(rowid) r FROM entity_embeddings_rowids)`)
2218
+ .safeIntegers(true).get().m ?? 0n) + 1n;
2219
+ writeVector(this.db, 'entity_embeddings', nextRowid, Buffer.from(embedding.buffer));
2220
+ const result = { lastInsertRowid: nextRowid };
2068
2221
  this.db.prepare(`
2069
2222
  INSERT INTO entity_embedding_metadata (rowid, entity_id, embedding_text, input_hash, profile_id, provenance_state)
2070
2223
  VALUES (?, ?, ?, ?, ?, 'verified')
2071
2224
  `).run(result.lastInsertRowid, entityId, embeddingText, inputHash, this.currentProfileId);
2072
2225
  return true;
2073
2226
  });
2074
- const written = writeTx();
2227
+ let written;
2228
+ try {
2229
+ written = writeTx();
2230
+ }
2231
+ catch (e) {
2232
+ if (e instanceof InvalidSlotError) {
2233
+ this.coordinator?.requestRepair();
2234
+ return false;
2235
+ }
2236
+ throw e;
2237
+ }
2075
2238
  if (!written) {
2076
2239
  console.error(`⏭️ discarded superseded embedding for ${entityId} (entity changed during inference)`);
2077
2240
  }
@@ -2293,7 +2456,7 @@ export class RAGKnowledgeGraphManager {
2293
2456
  let deletedAssociations = 0;
2294
2457
  for (const chunk of existingChunks) {
2295
2458
  // Delete vector embeddings (vec0 needs literal integer, not parameterized)
2296
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
2459
+ deleteVector(this.db, 'chunks', safeRowid(chunk.rowid));
2297
2460
  deletedVectors++;
2298
2461
  const associations = this.db.prepare(`
2299
2462
  DELETE FROM chunk_entities WHERE chunk_rowid = ?
@@ -2638,7 +2801,7 @@ export class RAGKnowledgeGraphManager {
2638
2801
  const existing = db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).all(documentId);
2639
2802
  for (const ch of existing) {
2640
2803
  db.prepare(`DELETE FROM chunk_entities WHERE chunk_rowid = ?`).run(ch.rowid);
2641
- db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(ch.rowid)}`);
2804
+ deleteVector(db, 'chunks', safeRowid(ch.rowid));
2642
2805
  }
2643
2806
  db.prepare(`DELETE FROM chunk_metadata WHERE document_id = ?`).run(documentId);
2644
2807
  deleteKgChunkRows(db, citedEvidence);
@@ -2653,9 +2816,23 @@ export class RAGKnowledgeGraphManager {
2653
2816
  `).run(chunkId, documentId, seg.chunk_index, seg.text, seg.start_pos, seg.end_pos, seg.start_token, seg.end_token);
2654
2817
  const rowid = Number(info.lastInsertRowid);
2655
2818
  if (vec) {
2656
- db.prepare(`INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)`).run(vec);
2657
- db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
2658
- .run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
2819
+ // A validity-0 row left at a reused rowid cannot be written over (vec-slot-reuse): the chunk then stays
2820
+ // vectorless (missing) until the idle rebuild drops that row and the backfill embeds it.
2821
+ let wrote = true;
2822
+ try {
2823
+ writeVector(db, 'chunks', rowid, vec);
2824
+ }
2825
+ catch (e) {
2826
+ if (e instanceof InvalidSlotError) {
2827
+ wrote = false;
2828
+ this.coordinator?.requestRepair();
2829
+ }
2830
+ else
2831
+ throw e;
2832
+ }
2833
+ if (wrote)
2834
+ db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = ? WHERE rowid = ?`)
2835
+ .run(shaHex(seg.text), this.currentProfileId, provenance, rowid);
2659
2836
  }
2660
2837
  }
2661
2838
  if (oldRowids.length > 0) {
@@ -2802,10 +2979,7 @@ export class RAGKnowledgeGraphManager {
2802
2979
  // Store in vector table (+ verified provenance, §6a-2 atomic)
2803
2980
  try {
2804
2981
  const tx = this.db.transaction(() => {
2805
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(rowid)}`);
2806
- this.db.prepare(`
2807
- INSERT INTO chunks (rowid, embedding) VALUES (${rowid}, ?)
2808
- `).run(Buffer.from(embedding.buffer));
2982
+ writeVector(this.db, 'chunks', safeRowid(rowid), Buffer.from(embedding.buffer));
2809
2983
  this.db.prepare(`UPDATE chunk_metadata SET input_hash = ?, profile_id = ?, provenance_state = 'verified' WHERE rowid = ?`)
2810
2984
  .run(createHash('sha256').update(chunk.text).digest('hex'), this.currentProfileId, rowid);
2811
2985
  });
@@ -2813,6 +2987,8 @@ export class RAGKnowledgeGraphManager {
2813
2987
  embeddedCount++;
2814
2988
  }
2815
2989
  catch (error) {
2990
+ if (error instanceof InvalidSlotError)
2991
+ this.coordinator?.requestRepair();
2816
2992
  const errMsg = `chunk ${chunk.chunk_id} (rowid=${rowid}, type=${typeof chunk.rowid}): ${error instanceof Error ? error.message : String(error)}`;
2817
2993
  console.error(`Failed to embed ${errMsg}`);
2818
2994
  errors.push(errMsg);
@@ -3251,7 +3427,7 @@ export class RAGKnowledgeGraphManager {
3251
3427
  `).run(chunk.rowid);
3252
3428
  deletedAssociations += associations.changes;
3253
3429
  // Delete vector embeddings (vec0 needs literal integer, not parameterized)
3254
- db.exec(`DELETE FROM chunks WHERE rowid = ${safeRowid(chunk.rowid)}`);
3430
+ deleteVector(db, 'chunks', safeRowid(chunk.rowid));
3255
3431
  deletedVectors++;
3256
3432
  }
3257
3433
  const metadata = db.prepare(`
@@ -3490,19 +3666,27 @@ export class RAGKnowledgeGraphManager {
3490
3666
  this.db.exec(`DELETE FROM entity_observations`);
3491
3667
  this.db.exec(`DELETE FROM observation_roots`);
3492
3668
  this.db.exec(`DELETE FROM entities`);
3669
+ // documents → chunk_metadata is a CASCADE, but vec0 has no FK: delete the document chunk vectors first,
3670
+ // or they stay searchable-less orphans (IN-42 · same order as storeDocument's replace path).
3671
+ // rowids stay BigInt end to end (2_ r1 R1: a Number cast past 2^53 deletes the wrong vector)
3672
+ for (const c of this.db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id IS NOT NULL`).safeIntegers(true).all()) {
3673
+ deleteVector(this.db, 'chunks', c.rowid);
3674
+ }
3493
3675
  this.db.exec(`DELETE FROM documents`);
3494
3676
  // entities 를 지워도 파생 데이터는 따라오지 않는다: chunk_metadata 에는
3495
3677
  // entities 로 가는 FK 가 없고 entity_embedding_metadata.entity_id 는 UNIQUE 일
3496
3678
  // 뿐이다. 그래서 replace-import 뒤에 **사라진 entity 의 벡터와 KG chunk 가
3497
- // 검색에 남았다**(advisor beta 발견 2). document chunk 는 documents 의
3498
- // CASCADE 로 이미 정리되므로 여기서는 entity·relationship chunk 만 지운다.
3679
+ // 검색에 남았다**(advisor beta 발견 2). document chunk 메타는 documents 의
3680
+ // CASCADE 로 정리되고 그 벡터는 위에서 먼저 지웠다(IN-42) — 여기서는 entity·relationship chunk 만 지운다.
3499
3681
  const orphanChunks = this.db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type IN ('entity','relationship')`)
3500
3682
  .all();
3501
3683
  for (const c of orphanChunks) {
3502
- this.db.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
3684
+ deleteVector(this.db, 'chunks', Number(c.rowid));
3503
3685
  this.db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
3504
3686
  }
3505
- this.db.exec(`DELETE FROM entity_embeddings WHERE rowid IN (SELECT rowid FROM entity_embedding_metadata)`);
3687
+ for (const m of this.db.prepare(`SELECT rowid FROM entity_embedding_metadata`).all()) {
3688
+ deleteVector(this.db, 'entity_embeddings', Number(m.rowid)); // validity-0 rows are left to the rebuild
3689
+ }
3506
3690
  this.db.exec(`DELETE FROM entity_embedding_metadata`);
3507
3691
  console.error('🗑️ Cleared existing data for full import');
3508
3692
  }
@@ -3555,6 +3739,10 @@ export class RAGKnowledgeGraphManager {
3555
3739
  // doc.id is a string here (checked above, before any write).
3556
3740
  for (const r of collectEvidenceRowidsCitingDocument(this.db, doc.id))
3557
3741
  citedEvidence.add(r);
3742
+ // REPLACE deletes an existing row → its chunk_metadata goes by CASCADE, the vec0 vectors would not (IN-42).
3743
+ for (const c of this.db.prepare(`SELECT rowid FROM chunk_metadata WHERE document_id = ?`).safeIntegers(true).all(doc.id)) {
3744
+ deleteVector(this.db, 'chunks', c.rowid);
3745
+ }
3558
3746
  const result = stmt.run(doc.id, doc.content, JSON.stringify(doc.metadata || {}), doc.created_at || new Date().toISOString());
3559
3747
  if (result.changes > 0) {
3560
3748
  imported.documents++;
@@ -3724,7 +3912,7 @@ export class RAGKnowledgeGraphManager {
3724
3912
  }
3725
3913
  /**
3726
3914
  * Diagnostic seam for the graph re-ranker (evaluation change graph-role-evaluation, R2).
3727
- * Returns the seed entities (query-vector matched, similarity > 0.4, top-10 per variant) and the
3915
+ * Returns the seed entities (query-vector matched, legacy score > 0.4 = cosine ≈0.28, top-10 per variant) and the
3728
3916
  * 1-hop connected entities exactly as hybridSearch(useGraph:true) computes them, plus edge detail
3729
3917
  * that hybridSearch itself does not use (edge id, type, direction, confidence). It never generates
3730
3918
  * candidates and never changes ranking; hybridSearch consumes only the name sets.
@@ -3776,7 +3964,7 @@ export class RAGKnowledgeGraphManager {
3776
3964
  WHERE r.source_entity = ? OR r.target_entity = ?
3777
3965
  ORDER BY r.id`);
3778
3966
  for (const entity of similar) {
3779
- const similarity = Math.max(0, 1 - entity.distance / 2);
3967
+ const similarity = legacyL2AsCosineScore(entity.distance); // legacy seed score, pinned (4ac3155) — not a cosine
3780
3968
  if (similarity > 0.4) {
3781
3969
  seeds.push({ entity_id: entity.entity_id, name: entity.name, similarity });
3782
3970
  for (const row of edgeStmt.all(entity.entity_id, entity.entity_id, entity.entity_id, entity.entity_id, entity.entity_id)) {
@@ -4123,7 +4311,8 @@ export class RAGKnowledgeGraphManager {
4123
4311
  ({ summary, keyHighlight, relevanceScore } = await this.generateContentSummary(result.text, primaryQueryEmbedding, chunkEntities, result.chunk_type === 'relationship' ? 1 : 2 // Shorter summary for relationships
4124
4312
  ));
4125
4313
  }
4126
- const vectorSimilarity = Math.max(0, 1 - result.distance / 2);
4314
+ // default (rank fusion) = real cosine · legacy paths (useGraph:true or summaries on) keep the pinned 6.3.2 score
4315
+ const vectorSimilarity = rankFusion ? l2ToCosineSimilarity(result.distance) : legacyL2AsCosineScore(result.distance);
4127
4316
  const ftsBoost = ftsBoostMap.get(result.chunk_id) || 0; // = 1/(60 + FTS rank), 0 if not an FTS hit
4128
4317
  const vectorRank = vectorRankOf.get(result.chunk_id);
4129
4318
  const finalScore = rankFusion
@@ -4387,9 +4576,17 @@ export class RAGKnowledgeGraphManager {
4387
4576
  reconciliation_state: this.coordinator?.reconState ?? 'n/a',
4388
4577
  reconciliation_last_error: this.coordinator?.reconLastError ?? null,
4389
4578
  coverage: cov ? {
4390
- chunk: { total: cov.chunk.total, embedded: cov.chunk.embedded, verified: cov.chunk.verified, legacy_assumed: cov.chunk.legacy_assumed, missing: cov.chunk.total - cov.chunk.embedded },
4391
- entity: { total: cov.entity.total, embedded: cov.entity.embedded, verified: cov.entity.verified, legacy_assumed: cov.entity.legacy_assumed, missing: cov.entity.total - cov.entity.embedded },
4579
+ // vec-slot-reuse D1c: an unresolvable slot map is reported as error with missing = null, not as a count.
4580
+ chunk: cov.chunk.error
4581
+ ? { total: cov.chunk.total, embedded: null, verified: null, legacy_assumed: null, missing: null, orphan_vectors: null, error: cov.chunk.error }
4582
+ : { total: cov.chunk.total, embedded: cov.chunk.embedded, verified: cov.chunk.verified, legacy_assumed: cov.chunk.legacy_assumed, missing: cov.chunk.total - cov.chunk.embedded, orphan_vectors: cov.chunk.orphan_vectors ?? null },
4583
+ entity: cov.entity.error
4584
+ ? { total: cov.entity.total, embedded: null, verified: null, legacy_assumed: null, missing: null, orphan_vectors: null, error: cov.entity.error }
4585
+ : { total: cov.entity.total, embedded: cov.entity.embedded, verified: cov.entity.verified, legacy_assumed: cov.entity.legacy_assumed, missing: cov.entity.total - cov.entity.embedded, orphan_vectors: cov.entity.orphan_vectors ?? null },
4392
4586
  } : null,
4587
+ // vec-slot-reuse: per vec0 table = slots, searchable, validity-0 rows, reclaimable chunks, why a rebuild
4588
+ // is (not) due, and the last attempt of this process. Read-only: computing it writes nothing.
4589
+ vector_slots: this.db ? this.vectorSlotStats() : null,
4393
4590
  }
4394
4591
  };
4395
4592
  }
@@ -4901,6 +5098,7 @@ async function main() {
4901
5098
  // Synchronously, before anything is awaited: a host may SIGKILL before the settle
4902
5099
  // sequence below completes (codex: ~185 ms after SIGTERM).
4903
5100
  ragKgManager.checkpointWal();
5101
+ ragKgManager.noteWrites();
4904
5102
  void (async () => {
4905
5103
  try {
4906
5104
  await server.close();
@@ -6,12 +6,16 @@ export interface CoverageSnapshot {
6
6
  embedded: number;
7
7
  verified: number;
8
8
  legacy_assumed: number;
9
+ orphan_vectors?: number;
10
+ error?: string;
9
11
  };
10
12
  entity: {
11
13
  total: number;
12
14
  embedded: number;
13
15
  verified: number;
14
16
  legacy_assumed: number;
17
+ orphan_vectors?: number;
18
+ error?: string;
15
19
  };
16
20
  }
17
21
  export interface CoordinatorDeps {
@@ -37,17 +41,25 @@ export declare class BackfillCoordinator {
37
41
  private shuttingDown;
38
42
  private connLossReruns;
39
43
  private scanning;
44
+ private repairWanted;
45
+ readonly slotErrors: Map<string, string>;
46
+ repairRequestedAt: number;
47
+ requestRepair(): void;
48
+ takeRepairRequest(): boolean;
49
+ get repairRequested(): boolean;
40
50
  private scanPromise;
41
51
  private rerunRequested;
42
52
  private snapshot;
43
53
  constructor(deps: CoordinatorDeps);
44
54
  get reconState(): ReconState;
55
+ get busy(): boolean;
45
56
  get reconLastError(): string | undefined;
46
57
  get eligible(): boolean;
47
58
  private countNullWithVector;
48
59
  private countRepairables;
49
60
  private sanitize;
50
61
  runReconciliation(): Promise<void>;
62
+ private reconActive;
51
63
  private reconcileOnce;
52
64
  private reconcileEntities;
53
65
  private reconcileChunks;