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(
361            crate::i18n::validation::refuse_delete_vec_orphans_without_yes(
362                orphan_count,
363                orphan_entities_count,
364                orphan_chunks_count,
365            ),
366        ));
367    }
368
369    let deleted: i64 = conn.execute(
370        &format!(
371            "DELETE FROM {memory_table}
372             WHERE NOT EXISTS (
373                 SELECT 1 FROM memories m
374                 WHERE m.id = {memory_table}.memory_id
375                   AND m.deleted_at IS NULL
376             )"
377        ),
378        [],
379    )? as i64;
380
381    let deleted_entities: i64 = if vec_table_exists(&conn, "vec_entities") {
382        conn.execute(
383            "DELETE FROM vec_entities
384             WHERE NOT EXISTS (
385                 SELECT 1 FROM memories m
386                 WHERE m.id = vec_entities.memory_id
387                   AND m.deleted_at IS NULL
388             )",
389            [],
390        )
391        .unwrap_or(0) as i64
392    } else {
393        0
394    };
395    let deleted_chunks: i64 = if vec_table_exists(&conn, "vec_chunks") {
396        conn.execute(
397            "DELETE FROM vec_chunks
398             WHERE NOT EXISTS (
399                 SELECT 1 FROM memories m
400                 WHERE m.id = vec_chunks.memory_id
401                   AND m.deleted_at IS NULL
402             )",
403            [],
404        )
405        .unwrap_or(0) as i64
406    } else {
407        0
408    };
409
410    tracing::info!(target: "vec", deleted, deleted_entities, deleted_chunks, "purged orphan vec rows");
411
412    output::emit_json(&VecPurgeOrphanResponse {
413        action: "purged_orphan".to_string(),
414        deleted,
415        deleted_entities,
416        deleted_chunks,
417        dry_run: false,
418        elapsed_ms: start.elapsed().as_millis() as u64,
419    })?;
420    Ok(())
421}
422
423fn run_stats(args: VecStatsArgs) -> Result<(), AppError> {
424    let start = std::time::Instant::now();
425    let paths = AppPaths::resolve(args.db.as_deref())?;
426    crate::storage::connection::ensure_db_ready(&paths)?;
427    let conn = open_ro(&paths.db)?;
428
429    let (total_rows, orphaned) = live_memory_embedding_stats(&conn);
430    let coverage_percent = if total_rows > 0 {
431        ((total_rows - orphaned) as f64 / total_rows as f64) * 100.0
432    } else {
433        100.0
434    };
435
436    let vec_entities_rows =
437        count_rows_first_existing(&conn, &["entity_embeddings", "vec_entities"]);
438    let vec_chunks_rows = count_rows_first_existing(&conn, &["chunk_embeddings", "vec_chunks"]);
439    let fts_memories_rows = conn
440        .query_row("SELECT COUNT(*) FROM fts_memories", [], |r| r.get(0))
441        .unwrap_or(0);
442
443    output::emit_json(&VecStatsResponse {
444        total_rows,
445        orphaned,
446        coverage_percent,
447        vec_entities_rows,
448        vec_chunks_rows,
449        fts_memories_rows,
450        dims: dim_breakdown(&conn),
451        elapsed_ms: start.elapsed().as_millis() as u64,
452    })?;
453    Ok(())
454}
455
456fn vec_table_exists(conn: &rusqlite::Connection, name: &str) -> bool {
457    conn.query_row(
458        "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
459        rusqlite::params![name],
460        |r| r.get::<_, i64>(0).map(|v| v > 0),
461    )
462    .unwrap_or(false)
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use rusqlite::Connection;
469
470    fn open_vec_test_db() -> Connection {
471        let conn = Connection::open_in_memory().unwrap();
472        // GAP-SG-119: use DEFAULT_EMBEDDING_DIM (not legacy 384) for new fixtures.
473        let dim = crate::constants::DEFAULT_EMBEDDING_DIM;
474        conn.execute_batch(&format!(
475            "CREATE TABLE memories (
476                id INTEGER PRIMARY KEY,
477                deleted_at INTEGER
478            );
479            CREATE TABLE memory_embeddings (
480                memory_id INTEGER PRIMARY KEY,
481                namespace TEXT NOT NULL,
482                embedding BLOB NOT NULL,
483                source TEXT NOT NULL,
484                model TEXT NOT NULL,
485                dim INTEGER NOT NULL DEFAULT {dim}
486            );
487            CREATE TABLE vec_memories (
488                memory_id INTEGER PRIMARY KEY,
489                embedding BLOB NOT NULL,
490                created_at INTEGER NOT NULL DEFAULT 0
491            );
492            CREATE TABLE entity_embeddings (
493                entity_id INTEGER PRIMARY KEY,
494                namespace TEXT NOT NULL,
495                embedding BLOB NOT NULL,
496                source TEXT NOT NULL,
497                model TEXT NOT NULL,
498                dim INTEGER NOT NULL DEFAULT {dim}
499            );
500            CREATE TABLE vec_entities (
501                memory_id INTEGER PRIMARY KEY
502            );
503            CREATE TABLE chunk_embeddings (
504                chunk_id INTEGER PRIMARY KEY,
505                memory_id INTEGER NOT NULL,
506                embedding BLOB NOT NULL,
507                source TEXT NOT NULL,
508                model TEXT NOT NULL,
509                dim INTEGER NOT NULL DEFAULT {dim}
510            );
511            CREATE TABLE vec_chunks (
512                memory_id INTEGER PRIMARY KEY
513            );",
514        ))
515        .unwrap();
516        conn
517    }
518
519    #[test]
520    fn vec_orphan_list_response_serializes_all_fields() {
521        let resp = VecOrphanListResponse {
522            action: "orphan_list".into(),
523            count: 0,
524            items: Vec::new(),
525            elapsed_ms: 5,
526        };
527        let v = serde_json::to_value(&resp).unwrap();
528        assert_eq!(v["action"], "orphan_list");
529        assert_eq!(v["count"], 0i64);
530        assert_eq!(v["elapsed_ms"], 5u64);
531        assert!(v["items"].is_array());
532    }
533
534    #[test]
535    fn vec_purge_orphan_response_serializes_dry_run_flag() {
536        let resp = VecPurgeOrphanResponse {
537            action: "purge_orphan_dry_run".into(),
538            deleted: 0,
539            deleted_entities: 0,
540            deleted_chunks: 0,
541            dry_run: true,
542            elapsed_ms: 1,
543        };
544        let v = serde_json::to_value(&resp).unwrap();
545        assert_eq!(v["dry_run"], true);
546        assert_eq!(v["deleted"], 0i64);
547    }
548
549    #[test]
550    fn vec_stats_response_computes_coverage() {
551        let resp = VecStatsResponse {
552            total_rows: 100,
553            orphaned: 25,
554            coverage_percent: 75.0,
555            vec_entities_rows: Some(50),
556            vec_chunks_rows: None,
557            fts_memories_rows: 100,
558            dims: vec![],
559            elapsed_ms: 10,
560        };
561        let v = serde_json::to_value(&resp).unwrap();
562        assert_eq!(v["coverage_percent"], 75.0);
563        assert_eq!(v["vec_entities_rows"], 50i64);
564        assert!(v.get("vec_chunks_rows").is_none());
565        assert!(v["dims"].as_array().unwrap().is_empty());
566    }
567
568    #[test]
569    fn dim_breakdown_groups_rows_per_dim_and_table() {
570        // G52: mixed dims in the same table must surface as separate rows.
571        let conn = open_vec_test_db();
572        conn.execute_batch(
573            "INSERT INTO memories (id, deleted_at) VALUES (1, NULL), (2, NULL), (3, NULL);
574             INSERT INTO memory_embeddings (memory_id, namespace, embedding, source, model, dim)
575             VALUES (1, 'g', x'00', 'test', 'test', 64),
576                    (2, 'g', x'00', 'test', 'test', 64),
577                    (3, 'g', x'00', 'test', 'test', 384);",
578        )
579        .unwrap();
580        let dims = dim_breakdown(&conn);
581        let mem: Vec<_> = dims
582            .iter()
583            .filter(|d| d.table == "memory_embeddings")
584            .collect();
585        assert_eq!(mem.len(), 2, "expected one row per distinct dim");
586        assert_eq!((mem[0].dim, mem[0].rows), (64, 2));
587        assert_eq!((mem[1].dim, mem[1].rows), (384, 1));
588    }
589
590    #[test]
591    fn live_memory_embedding_stats_prefers_memory_embeddings() {
592        let conn = open_vec_test_db();
593        conn.execute("INSERT INTO memories (id, deleted_at) VALUES (1, NULL)", [])
594            .unwrap();
595        conn.execute("INSERT INTO memories (id, deleted_at) VALUES (2, 123)", [])
596            .unwrap();
597        conn.execute(
598            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
599             VALUES (1, 'global', X'00', 'llm', 'm', 384)",
600            [],
601        )
602        .unwrap();
603        conn.execute(
604            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
605             VALUES (2, 'global', X'00', 'llm', 'm', 384)",
606            [],
607        )
608        .unwrap();
609        conn.execute(
610            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
611             VALUES (3, 'global', X'00', 'llm', 'm', 384)",
612            [],
613        )
614        .unwrap();
615        conn.execute(
616            "INSERT INTO vec_memories(memory_id, embedding, created_at) VALUES (99, X'00', 0)",
617            [],
618        )
619        .unwrap();
620
621        let (total, orphaned) = live_memory_embedding_stats(&conn);
622        assert_eq!(total, 3);
623        assert_eq!(orphaned, 2);
624    }
625
626    #[test]
627    fn count_rows_first_existing_prefers_new_embedding_tables() {
628        let conn = open_vec_test_db();
629        conn.execute(
630            "INSERT INTO entity_embeddings(entity_id, namespace, embedding, source, model, dim)
631             VALUES (1, 'global', X'00', 'llm', 'm', 384)",
632            [],
633        )
634        .unwrap();
635        conn.execute("INSERT INTO vec_entities(memory_id) VALUES (1)", [])
636            .unwrap();
637        conn.execute(
638            "INSERT INTO chunk_embeddings(chunk_id, memory_id, embedding, source, model, dim)
639             VALUES (1, 1, X'00', 'llm', 'm', 384)",
640            [],
641        )
642        .unwrap();
643        conn.execute("INSERT INTO vec_chunks(memory_id) VALUES (1)", [])
644            .unwrap();
645
646        assert_eq!(
647            count_rows_first_existing(&conn, &["entity_embeddings", "vec_entities"]),
648            Some(1)
649        );
650        assert_eq!(
651            count_rows_first_existing(&conn, &["chunk_embeddings", "vec_chunks"]),
652            Some(1)
653        );
654    }
655}