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(
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 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 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}