1use 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#[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 #[command(subcommand)]
40 pub command: VecSubcommand,
41}
42
43#[derive(clap::Subcommand)]
45pub enum VecSubcommand {
46 OrphanList(VecOrphanListArgs),
48 PurgeOrphan(VecPurgeOrphanArgs),
50 Stats(VecStatsArgs),
52}
53
54#[derive(clap::Args)]
56pub struct VecOrphanListArgs {
57 #[arg(long, hide = true)]
59 pub json: bool,
60 #[arg(long)]
62 pub db: Option<String>,
63}
64
65#[derive(clap::Args)]
67pub struct VecOrphanListInner {
68 pub json: bool,
70 pub db: Option<String>,
72}
73
74#[derive(clap::Args)]
76pub struct VecPurgeOrphanArgs {
77 #[arg(long, hide = true)]
79 pub json: bool,
80 #[arg(long)]
82 pub db: Option<String>,
83 #[arg(long, default_value_t = false)]
85 pub yes: bool,
86 #[arg(long, default_value_t = false)]
88 pub dry_run: bool,
89}
90
91#[derive(clap::Args)]
93pub struct VecStatsArgs {
94 #[arg(long, hide = true)]
96 pub json: bool,
97 #[arg(long)]
99 pub db: Option<String>,
100}
101
102#[derive(Serialize)]
103struct VecOrphanListItem {
104 memory_id: i64,
106 vector_hash: String,
108 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 deleted_entities: i64,
126 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 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
155fn 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
182pub 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 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 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 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 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}