Skip to main content

sqlite_graphrag/commands/
vec.rs

1//! Handler for the `vec` CLI subcommand family.
2//!
3//! Provides maintenance operations for the memory embedding store,
4//! preferring `memory_embeddings` and falling back to legacy `vec_memories`:
5//!
6//! - `orphan-list`: lists embedding rows whose `memory_id` no longer
7//!   references a live (non-soft-deleted) memory.
8//! - `purge-orphan`: deletes those orphan rows in a single transaction.
9//! - `stats`: surfaces total rows, orphan count, and coverage percentage.
10//!
11//! G39 (v1.0.69): before v1.0.69, the only way to detect a vec-orphan was
12//! `health --json` which reported `vec_memories_orphaned > 0` with no
13//! remediation path. This module closes the loop.
14
15use crate::errors::AppError;
16use crate::output;
17use crate::paths::AppPaths;
18use crate::storage::connection::{open_ro, open_rw};
19use serde::Serialize;
20
21const MEMORY_VEC_TABLES: &[&str] = &["memory_embeddings", "vec_memories"];
22
23/// Arguments for the `vec` subcommand family.
24#[derive(clap::Args)]
25#[command(
26    about = "Vector index maintenance (orphan detection, purge, stats)",
27    after_long_help = "EXAMPLES:\n  \
28        # List orphan memory embedding rows whose memory_id is gone\n  \
29        sqlite-graphrag vec orphan-list\n\n  \
30        # Dry-run the purge (does not delete)\n  \
31        sqlite-graphrag vec purge-orphan --dry-run\n\n  \
32        # Actually purge orphans\n  \
33        sqlite-graphrag vec purge-orphan --yes\n\n  \
34        # Show stats for all vec0 tables\n  \
35        sqlite-graphrag vec stats --json"
36)]
37pub struct VecArgs {
38    /// Subcommand to execute.
39    #[command(subcommand)]
40    pub command: VecSubcommand,
41}
42
43/// Subcommands nested under `vec`.
44#[derive(clap::Subcommand)]
45pub enum VecSubcommand {
46    /// List orphan memory embedding rows.
47    OrphanList(VecOrphanListArgs),
48    /// Delete orphan memory embedding rows. Requires `--yes` to confirm.
49    PurgeOrphan(VecPurgeOrphanArgs),
50    /// Show statistics for vec_memories, vec_entities, vec_chunks.
51    Stats(VecStatsArgs),
52}
53
54/// Arguments for `vec orphan-list`.
55#[derive(clap::Args)]
56pub struct VecOrphanListArgs {
57    /// No-op; JSON is always emitted on stdout.
58    #[arg(long, hide = true)]
59    pub json: bool,
60    /// Path to the SQLite database file.
61    #[arg(long)]
62    pub db: Option<String>,
63}
64
65/// Arguments for `vec purge-orphan`.
66#[derive(clap::Args)]
67pub struct VecOrphanListInner {
68    /// Emit machine-readable JSON on stdout.
69    pub json: bool,
70    /// Path to the SQLite database file.
71    pub db: Option<String>,
72}
73
74/// Arguments for `vec purge-orphan`.
75#[derive(clap::Args)]
76pub struct VecPurgeOrphanArgs {
77    /// No-op; JSON is always emitted on stdout.
78    #[arg(long, hide = true)]
79    pub json: bool,
80    /// Path to the SQLite database file.
81    #[arg(long)]
82    pub db: Option<String>,
83    /// Skip the interactive confirmation; required for automation.
84    #[arg(long, default_value_t = false)]
85    pub yes: bool,
86    /// Report what would be purged without writing.
87    #[arg(long, default_value_t = false)]
88    pub dry_run: bool,
89}
90
91/// Arguments for `vec stats`.
92#[derive(clap::Args)]
93pub struct VecStatsArgs {
94    /// No-op; JSON is always emitted on stdout.
95    #[arg(long, hide = true)]
96    pub json: bool,
97    /// Path to the SQLite database file.
98    #[arg(long)]
99    pub db: Option<String>,
100}
101
102#[derive(Serialize)]
103struct VecOrphanListItem {
104    /// The orphan `memory_id` value stored in the active memory embedding table.
105    memory_id: i64,
106    /// Hash of the float vector blob, for fingerprinting.
107    vector_hash: String,
108    /// When the orphan row was originally inserted.
109    created_at: i64,
110}
111
112#[derive(Serialize)]
113struct VecOrphanListResponse {
114    action: String,
115    count: i64,
116    items: Vec<VecOrphanListItem>,
117    elapsed_ms: u64,
118}
119
120#[derive(Serialize)]
121struct VecPurgeOrphanResponse {
122    action: String,
123    deleted: i64,
124    /// Number of orphan rows in `vec_entities` that were also removed (G39).
125    deleted_entities: i64,
126    /// Number of orphan rows in `vec_chunks` that were also removed (G39).
127    deleted_chunks: i64,
128    dry_run: bool,
129    elapsed_ms: u64,
130}
131
132#[derive(Serialize)]
133struct VecStatsResponse {
134    total_rows: i64,
135    orphaned: i64,
136    coverage_percent: f64,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    vec_entities_rows: Option<i64>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    vec_chunks_rows: Option<i64>,
141    fts_memories_rows: i64,
142    /// G52: per-dimensionality row counts across the three embedding tables.
143    /// Surfaces mixed-dim contamination (G43) without manual SQL.
144    dims: Vec<DimBreakdown>,
145    elapsed_ms: u64,
146}
147
148#[derive(Serialize)]
149struct DimBreakdown {
150    table: String,
151    dim: i64,
152    rows: i64,
153}
154
155/// G52: aggregates `SELECT dim, COUNT(*) ... GROUP BY dim` over each
156/// embedding table that exists. Mixed dimensionalities in the same table
157/// indicate G43-style contamination that blinds cosine similarity.
158fn dim_breakdown(conn: &rusqlite::Connection) -> Vec<DimBreakdown> {
159    let mut out = Vec::new();
160    for table in ["memory_embeddings", "entity_embeddings", "chunk_embeddings"] {
161        if !vec_table_exists(conn, table) {
162            continue;
163        }
164        let sql = format!("SELECT dim, COUNT(*) FROM {table} GROUP BY dim ORDER BY dim");
165        let Ok(mut stmt) = conn.prepare(&sql) else {
166            continue;
167        };
168        let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)));
169        if let Ok(rows) = rows {
170            for (dim, count) in rows.flatten() {
171                out.push(DimBreakdown {
172                    table: table.to_string(),
173                    dim,
174                    rows: count,
175                });
176            }
177        }
178    }
179    out
180}
181
182/// Dispatch entry point called from `main`.
183///
184/// # Errors
185/// Propagates any [`AppError`] raised by the underlying subcommand.
186pub fn run(args: VecArgs) -> Result<(), AppError> {
187    match args.command {
188        VecSubcommand::OrphanList(a) => run_orphan_list(a),
189        VecSubcommand::PurgeOrphan(a) => run_purge_orphan(a),
190        VecSubcommand::Stats(a) => run_stats(a),
191    }
192}
193
194fn live_memory_embedding_stats(conn: &rusqlite::Connection) -> (i64, i64) {
195    if let Some(table_name) = first_existing_vec_table(conn, MEMORY_VEC_TABLES) {
196        let total = conn
197            .query_row(&format!("SELECT COUNT(*) FROM {table_name}"), [], |r| {
198                r.get(0)
199            })
200            .unwrap_or(0);
201        let orphaned = conn
202            .query_row(
203                &format!(
204                    "SELECT COUNT(*)
205                     FROM {table_name} v
206                     LEFT JOIN memories m ON m.id = v.memory_id
207                     WHERE m.id IS NULL OR m.deleted_at IS NOT NULL"
208                ),
209                [],
210                |r| r.get(0),
211            )
212            .unwrap_or(0);
213        return (total, orphaned);
214    }
215
216    (0, 0)
217}
218
219fn first_existing_vec_table<'a>(
220    conn: &rusqlite::Connection,
221    candidates: &'a [&'a str],
222) -> Option<&'a str> {
223    candidates
224        .iter()
225        .copied()
226        .find(|table_name| vec_table_exists(conn, table_name))
227}
228
229fn count_rows_first_existing(conn: &rusqlite::Connection, candidates: &[&str]) -> Option<i64> {
230    for table in candidates {
231        if vec_table_exists(conn, table) {
232            return conn
233                .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
234                .ok();
235        }
236    }
237    None
238}
239
240fn run_orphan_list(args: VecOrphanListArgs) -> Result<(), AppError> {
241    let start = std::time::Instant::now();
242    let paths = AppPaths::resolve(args.db.as_deref())?;
243    crate::storage::connection::ensure_db_ready(&paths)?;
244    let conn = open_ro(&paths.db)?;
245
246    let Some(memory_table) = first_existing_vec_table(&conn, MEMORY_VEC_TABLES) else {
247        return output::emit_json(&VecOrphanListResponse {
248            action: "orphan_list".to_string(),
249            count: 0,
250            items: Vec::new(),
251            elapsed_ms: start.elapsed().as_millis() as u64,
252        });
253    };
254
255    // List embedding rows that have no corresponding live memory row.
256    // We use a hash of the float[] blob (BLAKE3) as a fingerprint so the
257    // operator can detect duplicate embeddings even after the parent
258    // memory has been re-embedded with new content.
259    let mut stmt = conn.prepare(&format!(
260        "SELECT v.memory_id, v.embedding, CAST(v.created_at AS INTEGER)
261         FROM {memory_table} v
262         LEFT JOIN memories m ON m.id = v.memory_id
263         WHERE m.id IS NULL OR m.deleted_at IS NOT NULL
264         ORDER BY v.memory_id"
265    ))?;
266    let rows: Vec<VecOrphanListItem> = stmt
267        .query_map([], |r| {
268            let memory_id: i64 = r.get(0)?;
269            let blob: Vec<u8> = r.get(1)?;
270            let created_at: i64 = r.get(2)?;
271            let vector_hash = blake3::hash(&blob).to_hex().to_string();
272            Ok(VecOrphanListItem {
273                memory_id,
274                vector_hash,
275                created_at,
276            })
277        })?
278        .collect::<Result<Vec<_>, _>>()?;
279    let count = rows.len() as i64;
280
281    output::emit_json(&VecOrphanListResponse {
282        action: "orphan_list".to_string(),
283        count,
284        items: rows,
285        elapsed_ms: start.elapsed().as_millis() as u64,
286    })?;
287    Ok(())
288}
289
290fn run_purge_orphan(args: VecPurgeOrphanArgs) -> Result<(), AppError> {
291    let start = std::time::Instant::now();
292    let paths = AppPaths::resolve(args.db.as_deref())?;
293    crate::storage::connection::ensure_db_ready(&paths)?;
294    let conn = open_rw(&paths.db)?;
295
296    let Some(memory_table) = first_existing_vec_table(&conn, MEMORY_VEC_TABLES) else {
297        return output::emit_json(&VecPurgeOrphanResponse {
298            action: "purge_orphan".to_string(),
299            deleted: 0,
300            deleted_entities: 0,
301            deleted_chunks: 0,
302            dry_run: args.dry_run,
303            elapsed_ms: start.elapsed().as_millis() as u64,
304        });
305    };
306
307    let orphan_count: i64 = conn
308        .query_row(
309            &format!(
310                "SELECT COUNT(*) FROM {memory_table} v
311                 LEFT JOIN memories m ON m.id = v.memory_id
312                 WHERE m.id IS NULL OR m.deleted_at IS NOT NULL"
313            ),
314            [],
315            |r| r.get(0),
316        )
317        .unwrap_or(0);
318
319    // G39: also count orphans in vec_entities and vec_chunks. These
320    // tables follow the same `memory_id` foreign key convention and
321    // accumulate orphans on the same paths as vec_memories.
322    let orphan_entities_count: i64 = if vec_table_exists(&conn, "vec_entities") {
323        conn.query_row(
324            "SELECT COUNT(*) FROM vec_entities v
325             LEFT JOIN memories m ON m.id = v.memory_id
326             WHERE m.id IS NULL OR m.deleted_at IS NOT NULL",
327            [],
328            |r| r.get(0),
329        )
330        .unwrap_or(0)
331    } else {
332        0
333    };
334    let orphan_chunks_count: i64 = if vec_table_exists(&conn, "vec_chunks") {
335        conn.query_row(
336            "SELECT COUNT(*) FROM vec_chunks v
337             LEFT JOIN memories m ON m.id = v.memory_id
338             WHERE m.id IS NULL OR m.deleted_at IS NOT NULL",
339            [],
340            |r| r.get(0),
341        )
342        .unwrap_or(0)
343    } else {
344        0
345    };
346
347    if args.dry_run {
348        tracing::info!(target: "vec", orphan_count, orphan_entities_count, orphan_chunks_count, "dry-run: would delete orphans");
349        return output::emit_json(&VecPurgeOrphanResponse {
350            action: "purge_orphan_dry_run".to_string(),
351            deleted: 0,
352            deleted_entities: 0,
353            deleted_chunks: 0,
354            dry_run: true,
355            elapsed_ms: start.elapsed().as_millis() as u64,
356        });
357    }
358
359    if !args.yes {
360        return Err(AppError::Validation(crate::i18n::validation::refuse_delete_vec_orphans_without_yes(
361                orphan_count,
362                orphan_entities_count,
363                orphan_chunks_count,
364            )));
365    }
366
367    let deleted: i64 = conn.execute(
368        &format!(
369            "DELETE FROM {memory_table}
370             WHERE NOT EXISTS (
371                 SELECT 1 FROM memories m
372                 WHERE m.id = {memory_table}.memory_id
373                   AND m.deleted_at IS NULL
374             )"
375        ),
376        [],
377    )? as i64;
378
379    let deleted_entities: i64 = if vec_table_exists(&conn, "vec_entities") {
380        conn.execute(
381            "DELETE FROM vec_entities
382             WHERE NOT EXISTS (
383                 SELECT 1 FROM memories m
384                 WHERE m.id = vec_entities.memory_id
385                   AND m.deleted_at IS NULL
386             )",
387            [],
388        )
389        .unwrap_or(0) as i64
390    } else {
391        0
392    };
393    let deleted_chunks: i64 = if vec_table_exists(&conn, "vec_chunks") {
394        conn.execute(
395            "DELETE FROM vec_chunks
396             WHERE NOT EXISTS (
397                 SELECT 1 FROM memories m
398                 WHERE m.id = vec_chunks.memory_id
399                   AND m.deleted_at IS NULL
400             )",
401            [],
402        )
403        .unwrap_or(0) as i64
404    } else {
405        0
406    };
407
408    tracing::info!(target: "vec", deleted, deleted_entities, deleted_chunks, "purged orphan vec rows");
409
410    output::emit_json(&VecPurgeOrphanResponse {
411        action: "purged_orphan".to_string(),
412        deleted,
413        deleted_entities,
414        deleted_chunks,
415        dry_run: false,
416        elapsed_ms: start.elapsed().as_millis() as u64,
417    })?;
418    Ok(())
419}
420
421fn run_stats(args: VecStatsArgs) -> Result<(), AppError> {
422    let start = std::time::Instant::now();
423    let paths = AppPaths::resolve(args.db.as_deref())?;
424    crate::storage::connection::ensure_db_ready(&paths)?;
425    let conn = open_ro(&paths.db)?;
426
427    let (total_rows, orphaned) = live_memory_embedding_stats(&conn);
428    let coverage_percent = if total_rows > 0 {
429        ((total_rows - orphaned) as f64 / total_rows as f64) * 100.0
430    } else {
431        100.0
432    };
433
434    let vec_entities_rows =
435        count_rows_first_existing(&conn, &["entity_embeddings", "vec_entities"]);
436    let vec_chunks_rows = count_rows_first_existing(&conn, &["chunk_embeddings", "vec_chunks"]);
437    let fts_memories_rows = conn
438        .query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))
439        .unwrap_or(0);
440
441    output::emit_json(&VecStatsResponse {
442        total_rows,
443        orphaned,
444        coverage_percent,
445        vec_entities_rows,
446        vec_chunks_rows,
447        fts_memories_rows,
448        dims: dim_breakdown(&conn),
449        elapsed_ms: start.elapsed().as_millis() as u64,
450    })?;
451    Ok(())
452}
453
454fn vec_table_exists(conn: &rusqlite::Connection, name: &str) -> bool {
455    conn.query_row(
456        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
457        rusqlite::params![name],
458        |r| r.get::<_, i64>(0).map(|v| v > 0),
459    )
460    .unwrap_or(false)
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use rusqlite::Connection;
467
468    fn open_vec_test_db() -> Connection {
469        let conn = Connection::open_in_memory().unwrap();
470        // GAP-SG-119: use DEFAULT_EMBEDDING_DIM (not legacy 384) for new fixtures.
471        let dim = crate::constants::DEFAULT_EMBEDDING_DIM;
472        conn.execute_batch(&format!(
473            "CREATE TABLE memories (
474                id INTEGER PRIMARY KEY,
475                deleted_at INTEGER
476            );
477            CREATE TABLE memory_embeddings (
478                memory_id INTEGER PRIMARY KEY,
479                namespace TEXT NOT NULL,
480                embedding BLOB NOT NULL,
481                source TEXT NOT NULL,
482                model TEXT NOT NULL,
483                dim INTEGER NOT NULL DEFAULT {dim}
484            );
485            CREATE TABLE vec_memories (
486                memory_id INTEGER PRIMARY KEY,
487                embedding BLOB NOT NULL,
488                created_at INTEGER NOT NULL DEFAULT 0
489            );
490            CREATE TABLE entity_embeddings (
491                entity_id INTEGER PRIMARY KEY,
492                namespace TEXT NOT NULL,
493                embedding BLOB NOT NULL,
494                source TEXT NOT NULL,
495                model TEXT NOT NULL,
496                dim INTEGER NOT NULL DEFAULT {dim}
497            );
498            CREATE TABLE vec_entities (
499                memory_id INTEGER PRIMARY KEY
500            );
501            CREATE TABLE chunk_embeddings (
502                chunk_id INTEGER PRIMARY KEY,
503                memory_id INTEGER NOT NULL,
504                embedding BLOB NOT NULL,
505                source TEXT NOT NULL,
506                model TEXT NOT NULL,
507                dim INTEGER NOT NULL DEFAULT {dim}
508            );
509            CREATE TABLE vec_chunks (
510                memory_id INTEGER PRIMARY KEY
511            );",
512        ))
513        .unwrap();
514        conn
515    }
516
517    #[test]
518    fn vec_orphan_list_response_serializes_all_fields() {
519        let resp = VecOrphanListResponse {
520            action: "orphan_list".into(),
521            count: 0,
522            items: Vec::new(),
523            elapsed_ms: 5,
524        };
525        let v = serde_json::to_value(&resp).unwrap();
526        assert_eq!(v["action"], "orphan_list");
527        assert_eq!(v["count"], 0i64);
528        assert_eq!(v["elapsed_ms"], 5u64);
529        assert!(v["items"].is_array());
530    }
531
532    #[test]
533    fn vec_purge_orphan_response_serializes_dry_run_flag() {
534        let resp = VecPurgeOrphanResponse {
535            action: "purge_orphan_dry_run".into(),
536            deleted: 0,
537            deleted_entities: 0,
538            deleted_chunks: 0,
539            dry_run: true,
540            elapsed_ms: 1,
541        };
542        let v = serde_json::to_value(&resp).unwrap();
543        assert_eq!(v["dry_run"], true);
544        assert_eq!(v["deleted"], 0i64);
545    }
546
547    #[test]
548    fn vec_stats_response_computes_coverage() {
549        let resp = VecStatsResponse {
550            total_rows: 100,
551            orphaned: 25,
552            coverage_percent: 75.0,
553            vec_entities_rows: Some(50),
554            vec_chunks_rows: None,
555            fts_memories_rows: 100,
556            dims: vec![],
557            elapsed_ms: 10,
558        };
559        let v = serde_json::to_value(&resp).unwrap();
560        assert_eq!(v["coverage_percent"], 75.0);
561        assert_eq!(v["vec_entities_rows"], 50i64);
562        assert!(v.get("vec_chunks_rows").is_none());
563        assert!(v["dims"].as_array().unwrap().is_empty());
564    }
565
566    #[test]
567    fn dim_breakdown_groups_rows_per_dim_and_table() {
568        // G52: mixed dims in the same table must surface as separate rows.
569        let conn = open_vec_test_db();
570        conn.execute_batch(
571            "INSERT INTO memories (id, deleted_at) VALUES (1, NULL), (2, NULL), (3, NULL);
572             INSERT INTO memory_embeddings (memory_id, namespace, embedding, source, model, dim)
573             VALUES (1, 'g', x'00', 'test', 'test', 64),
574                    (2, 'g', x'00', 'test', 'test', 64),
575                    (3, 'g', x'00', 'test', 'test', 384);",
576        )
577        .unwrap();
578        let dims = dim_breakdown(&conn);
579        let mem: Vec<_> = dims
580            .iter()
581            .filter(|d| d.table == "memory_embeddings")
582            .collect();
583        assert_eq!(mem.len(), 2, "expected one row per distinct dim");
584        assert_eq!((mem[0].dim, mem[0].rows), (64, 2));
585        assert_eq!((mem[1].dim, mem[1].rows), (384, 1));
586    }
587
588    #[test]
589    fn live_memory_embedding_stats_prefers_memory_embeddings() {
590        let conn = open_vec_test_db();
591        conn.execute("INSERT INTO memories (id, deleted_at) VALUES (1, NULL)", [])
592            .unwrap();
593        conn.execute("INSERT INTO memories (id, deleted_at) VALUES (2, 123)", [])
594            .unwrap();
595        conn.execute(
596            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
597             VALUES (1, 'global', X'00', 'llm', 'm', 384)",
598            [],
599        )
600        .unwrap();
601        conn.execute(
602            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
603             VALUES (2, 'global', X'00', 'llm', 'm', 384)",
604            [],
605        )
606        .unwrap();
607        conn.execute(
608            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
609             VALUES (3, 'global', X'00', 'llm', 'm', 384)",
610            [],
611        )
612        .unwrap();
613        conn.execute(
614            "INSERT INTO vec_memories(memory_id, embedding, created_at) VALUES (99, X'00', 0)",
615            [],
616        )
617        .unwrap();
618
619        let (total, orphaned) = live_memory_embedding_stats(&conn);
620        assert_eq!(total, 3);
621        assert_eq!(orphaned, 2);
622    }
623
624    #[test]
625    fn count_rows_first_existing_prefers_new_embedding_tables() {
626        let conn = open_vec_test_db();
627        conn.execute(
628            "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
629             VALUES (1, 'global', X'00', 'llm', 'm', 384)",
630            [],
631        )
632        .unwrap();
633        conn.execute("INSERT INTO vec_entities(memory_id) VALUES (1)", [])
634            .unwrap();
635        conn.execute(
636            "INSERT INTO chunk_embeddings(chunk_id, memory_id, embedding, source, model, dim)
637             VALUES (1, 1, X'00', 'llm', 'm', 384)",
638            [],
639        )
640        .unwrap();
641        conn.execute("INSERT INTO vec_chunks(memory_id) VALUES (1)", [])
642            .unwrap();
643
644        assert_eq!(
645            count_rows_first_existing(&conn, &["entity_embeddings", "vec_entities"]),
646            Some(1)
647        );
648        assert_eq!(
649            count_rows_first_existing(&conn, &["chunk_embeddings", "vec_chunks"]),
650            Some(1)
651        );
652    }
653}