Skip to main content

sqlite_graphrag/commands/
embedding.rs

1//! GAP-005 (v1.0.82): `embedding` subcommand — health and retry of the
2//! pending-embeddings queue that buffers memories whose embedding step failed.
3//!
4//! ## Subcommands
5//! - `embedding status` — counts by status
6//! - `embedding list [--status <STATUS>]` — list pending entries
7//! - `embedding retry <pending_id>` — re-run embedding for one entry
8//! - `embedding abandon <pending_id>` — mark as abandoned
9//!
10//! The pending_embeddings table captures every `embed_with_fallback` failure
11//! with `exit_code`, `stderr_tail`, and `backend_chain` for diagnostics. This
12//! subcommand makes that state observable and recoverable.
13
14use clap::{Args, Subcommand};
15use serde::Serialize;
16
17use crate::cli::LlmBackendChoice;
18use crate::errors::AppError;
19use crate::output::emit_json_compact;
20use crate::paths::AppPaths;
21use crate::storage::connection::open_rw;
22use crate::storage::pending_embeddings::{self, PendingEmbedding, PendingEmbeddingStatus};
23
24#[derive(Debug, Args)]
25#[command(after_long_help = "EXAMPLES:\n  \
26    # Show queue health and counts per status\n  \
27    sqlite-graphrag embedding status --json\n\n  \
28    # List all pending embeddings waiting for retry\n  \
29    sqlite-graphrag embedding list --status pending --json\n\n  \
30    # Mark pending_id 7 as abandoned (will not be retried automatically)\n  \
31    sqlite-graphrag embedding abandon 7 --yes\n\n  \
32    # Note: `embedding retry` requires re-running an LLM subprocess; for full\n  \
33    # retry of every pending entry use `enrich --operation re-embed --pending-only`")]
34/// Embedding args.
35pub struct EmbeddingArgs {
36    /// Cmd.
37    #[command(subcommand)]
38    pub cmd: EmbeddingCmd,
39}
40
41/// Embedding cmd.
42#[derive(Debug, Subcommand)]
43pub enum EmbeddingCmd {
44    /// Show queue health (counts by status).
45    Status(EmbeddingStatusArgs),
46    /// List pending embeddings filtered by status.
47    List(EmbeddingListArgs),
48    /// Mark one entry as abandoned.
49    Abandon(EmbeddingAbandonArgs),
50}
51
52/// Embedding status args.
53#[derive(Debug, Args)]
54pub struct EmbeddingStatusArgs {
55    /// Path to the SQLite database file.
56    #[arg(long)]
57    pub db: Option<String>,
58    /// JSON output (always on; accepted for CLI consistency).
59    #[arg(long, hide = true)]
60    pub json: bool,
61}
62
63/// Embedding list args.
64#[derive(Debug, Args)]
65pub struct EmbeddingListArgs {
66    /// Path to the SQLite database file.
67    #[arg(long)]
68    pub db: Option<String>,
69    /// Filter by status: pending | in_progress | done | abandoned. Default: pending.
70    #[arg(long, value_enum, default_value_t = EmbeddingStatusFilter::Pending)]
71    pub status: EmbeddingStatusFilter,
72    /// Maximum number of entries to return. Default: 100.
73    #[arg(long, default_value_t = 100)]
74    pub limit: usize,
75    /// JSON output (always on; accepted for CLI consistency).
76    #[arg(long, hide = true)]
77    pub json: bool,
78}
79
80/// Embedding status filter.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
82#[value(rename_all = "snake_case")]
83pub enum EmbeddingStatusFilter {
84    /// Pending variant.
85    Pending,
86    /// In progress variant.
87    InProgress,
88    /// Done variant.
89    Done,
90    /// Abandoned variant.
91    Abandoned,
92}
93
94impl From<EmbeddingStatusFilter> for PendingEmbeddingStatus {
95    fn from(value: EmbeddingStatusFilter) -> Self {
96        match value {
97            EmbeddingStatusFilter::Pending => Self::Pending,
98            EmbeddingStatusFilter::InProgress => Self::InProgress,
99            EmbeddingStatusFilter::Done => Self::Done,
100            EmbeddingStatusFilter::Abandoned => Self::Abandoned,
101        }
102    }
103}
104
105/// Embedding abandon args.
106#[derive(Debug, Args)]
107pub struct EmbeddingAbandonArgs {
108    /// Path to the SQLite database file.
109    #[arg(long)]
110    pub db: Option<String>,
111    /// Pending id to abandon.
112    pub pending_id: i64,
113    /// Skip the interactive confirmation prompt.
114    #[arg(long)]
115    pub yes: bool,
116    /// JSON output (always on; accepted for CLI consistency).
117    #[arg(long, hide = true)]
118    pub json: bool,
119}
120
121#[derive(Serialize)]
122struct EmbeddingStatusOutput {
123    action: &'static str,
124    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that would be
125    /// invoked to process live embeddings. `"claude" | "codex"
126    /// | "none" | "auto"`. `"auto"` indicates the caller requested Auto and
127    /// the codex→claude→none chain would be iterated at runtime.
128    backend_invoked: &'static str,
129    counts: EmbeddingStatusCounts,
130    /// GAP-SG-41: real vector coverage in the persisted tables. The `counts`
131    /// above only reflect the async retry queue (empty on the synchronous REST
132    /// path), so `coverage` reports the actual rows in `memory_embeddings`,
133    /// `entity_embeddings` and `chunk_embeddings` versus their source rows.
134    coverage: EmbeddingCoverage,
135    elapsed_ms: u64,
136}
137
138#[derive(Serialize, Default)]
139struct EmbeddingStatusCounts {
140    pending: usize,
141    in_progress: usize,
142    done: usize,
143    abandoned: usize,
144}
145
146/// GAP-SG-41: actual persisted-vector coverage. Each `*_with_vec` field counts
147/// the rows that have an embedding; the `*_total` field counts the source rows
148/// (active memories / entities / chunks). When totals are non-zero the operator
149/// can audit coverage directly instead of inferring it from `hybrid-search`.
150#[derive(Serialize, Default)]
151struct EmbeddingCoverage {
152    memories_total: i64,
153    memories_with_vec: i64,
154    /// v1.1.1 (P6b): active memories WITHOUT a row in `memory_embeddings`
155    /// (LEFT JOIN, so orphaned vectors never mask a gap). Additive field —
156    /// the pre-existing totals keep their meaning.
157    memories_missing: i64,
158    entities_total: i64,
159    entities_with_vec: i64,
160    /// v1.1.1 (P6b): entities without a row in `entity_embeddings`.
161    entities_missing: i64,
162    chunks_total: i64,
163    chunks_with_vec: i64,
164    /// v1.1.1 (P6b): memory_chunks rows without a row in `chunk_embeddings`.
165    chunks_missing: i64,
166}
167
168/// Counts a table, returning 0 when the table is absent (legacy DB) instead of
169/// failing the whole status report.
170fn count_table(conn: &rusqlite::Connection, sql: &str) -> i64 {
171    match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
172        Ok(n) => n,
173        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
174        Err(e) => {
175            tracing::warn!(target: "embedding", error = %e, sql, "coverage count failed");
176            0
177        }
178    }
179}
180
181/// v1.1.1 (P6b): counts source rows without a vector via LEFT JOIN. When the
182/// embedding table does not exist (legacy DB) EVERY source row is missing, so
183/// the fallback is `total_when_absent` — never a silent 0 that would report
184/// full coverage on a table that is not there.
185fn count_missing(conn: &rusqlite::Connection, sql: &str, total_when_absent: i64) -> i64 {
186    match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
187        Ok(n) => n,
188        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => {
189            total_when_absent
190        }
191        Err(e) => {
192            tracing::warn!(target: "embedding", error = %e, sql, "coverage missing-count failed");
193            0
194        }
195    }
196}
197
198#[derive(Serialize)]
199struct EmbeddingListEntry {
200    pending_id: i64,
201    memory_id: i64,
202    name: String,
203    namespace: String,
204    backend_chain: String,
205    last_error: Option<String>,
206    last_exit_code: Option<i32>,
207    last_stderr_tail: Option<String>,
208    attempt_count: i32,
209    status: String,
210    updated_at: i64,
211}
212
213impl From<&PendingEmbedding> for EmbeddingListEntry {
214    fn from(p: &PendingEmbedding) -> Self {
215        Self {
216            pending_id: p.pending_id,
217            memory_id: p.memory_id,
218            name: p.name.clone(),
219            namespace: p.namespace.clone(),
220            backend_chain: p.backend_chain.clone(),
221            last_error: p.last_error.clone(),
222            last_exit_code: p.last_exit_code,
223            last_stderr_tail: p.last_stderr_tail.clone(),
224            attempt_count: p.attempt_count,
225            status: p.status.as_str().to_string(),
226            updated_at: p.updated_at,
227        }
228    }
229}
230
231#[derive(Serialize)]
232struct EmbeddingListOutput {
233    action: &'static str,
234    filter_status: String,
235    count: usize,
236    entries: Vec<EmbeddingListEntry>,
237    elapsed_ms: u64,
238}
239
240#[derive(Serialize)]
241struct EmbeddingAbandonOutput {
242    action: &'static str,
243    pending_id: i64,
244    status: &'static str,
245    elapsed_ms: u64,
246    yes: bool,
247}
248
249/// Run.
250pub fn run(args: EmbeddingArgs, llm_backend: LlmBackendChoice) -> Result<(), AppError> {
251    match args.cmd {
252        EmbeddingCmd::Status(a) => run_status(a, llm_backend),
253        EmbeddingCmd::List(a) => run_list(a),
254        EmbeddingCmd::Abandon(a) => run_abandon(a),
255    }
256}
257
258fn open_conn(db: Option<&str>) -> Result<(AppPaths, rusqlite::Connection), AppError> {
259    let paths = AppPaths::resolve(db)?;
260    let conn = open_rw(&paths.db)?;
261    Ok((paths, conn))
262}
263
264/// Shared by `embedding status` and `pending-embeddings status` (GAP-E2E-09).
265pub(crate) fn run_status(
266    args: EmbeddingStatusArgs,
267    llm_backend: LlmBackendChoice,
268) -> Result<(), AppError> {
269    let start = std::time::Instant::now();
270    let (_paths, conn) = open_conn(args.db.as_deref())?;
271
272    let counts = EmbeddingStatusCounts {
273        pending: pending_embeddings::list_by_status(
274            &conn,
275            PendingEmbeddingStatus::Pending,
276            100_000,
277        )?
278        .len(),
279        in_progress: pending_embeddings::list_by_status(
280            &conn,
281            PendingEmbeddingStatus::InProgress,
282            100_000,
283        )?
284        .len(),
285        done: pending_embeddings::list_by_status(&conn, PendingEmbeddingStatus::Done, 100_000)?
286            .len(),
287        abandoned: pending_embeddings::list_by_status(
288            &conn,
289            PendingEmbeddingStatus::Abandoned,
290            100_000,
291        )?
292        .len(),
293    };
294
295    let backend_invoked: &'static str = match llm_backend {
296        LlmBackendChoice::Claude => "claude",
297        LlmBackendChoice::Codex => "codex",
298        LlmBackendChoice::Opencode => "opencode",
299        LlmBackendChoice::None => "none",
300        LlmBackendChoice::OpenRouter => "openrouter",
301        LlmBackendChoice::Auto => "auto",
302    };
303
304    // GAP-SG-41: query the actual vector tables so coverage is observable even
305    // when the async queue is empty (the synchronous OpenRouter REST path never
306    // populates `pending_embeddings`).
307    let memories_total = count_table(
308        &conn,
309        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
310    );
311    let entities_total = count_table(&conn, "SELECT COUNT(*) FROM entities");
312    let chunks_total = count_table(&conn, "SELECT COUNT(*) FROM memory_chunks");
313    let coverage = EmbeddingCoverage {
314        memories_total,
315        memories_with_vec: count_table(&conn, "SELECT COUNT(*) FROM memory_embeddings"),
316        // v1.1.1 (P6b): missing counts via LEFT JOIN so orphaned vector rows
317        // never inflate coverage; absent embedding table means ALL missing.
318        memories_missing: count_missing(
319            &conn,
320            "SELECT COUNT(*) FROM memories m \
321             LEFT JOIN memory_embeddings me ON me.memory_id = m.id \
322             WHERE me.memory_id IS NULL AND m.deleted_at IS NULL",
323            memories_total,
324        ),
325        entities_total,
326        entities_with_vec: count_table(&conn, "SELECT COUNT(*) FROM entity_embeddings"),
327        entities_missing: count_missing(
328            &conn,
329            "SELECT COUNT(*) FROM entities e \
330             LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
331             WHERE ee.entity_id IS NULL",
332            entities_total,
333        ),
334        chunks_total,
335        chunks_with_vec: count_table(&conn, "SELECT COUNT(*) FROM chunk_embeddings"),
336        chunks_missing: count_missing(
337            &conn,
338            "SELECT COUNT(*) FROM memory_chunks c \
339             LEFT JOIN chunk_embeddings ce ON ce.chunk_id = c.id \
340             WHERE ce.chunk_id IS NULL",
341            chunks_total,
342        ),
343    };
344
345    let output = EmbeddingStatusOutput {
346        action: "embedding_status",
347        backend_invoked,
348        counts,
349        coverage,
350        elapsed_ms: start.elapsed().as_millis() as u64,
351    };
352    emit_json_compact(&output)
353}
354
355fn run_list(args: EmbeddingListArgs) -> Result<(), AppError> {
356    let start = std::time::Instant::now();
357    let (_paths, conn) = open_conn(args.db.as_deref())?;
358    let status: PendingEmbeddingStatus = args.status.into();
359    let rows = pending_embeddings::list_by_status(&conn, status, args.limit)?;
360    let count = rows.len();
361    let entries: Vec<EmbeddingListEntry> = rows.iter().map(EmbeddingListEntry::from).collect();
362    let output = EmbeddingListOutput {
363        action: "embedding_list",
364        filter_status: status.as_str().to_string(),
365        count,
366        entries,
367        elapsed_ms: start.elapsed().as_millis() as u64,
368    };
369    emit_json_compact(&output)
370}
371
372fn run_abandon(args: EmbeddingAbandonArgs) -> Result<(), AppError> {
373    let start = std::time::Instant::now();
374    let (_paths, conn) = open_conn(args.db.as_deref())?;
375    pending_embeddings::abandon(&conn, args.pending_id)?;
376    let output = EmbeddingAbandonOutput {
377        action: "embedding_abandon",
378        pending_id: args.pending_id,
379        status: PendingEmbeddingStatus::Abandoned.as_str(),
380        elapsed_ms: start.elapsed().as_millis() as u64,
381        yes: args.yes,
382    };
383    emit_json_compact(&output)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    // GAP-SG-41: the status output exposes real vector coverage, not only the
391    // async queue counts.
392    #[test]
393    fn embedding_status_output_includes_coverage() {
394        let output = EmbeddingStatusOutput {
395            action: "embedding_status",
396            backend_invoked: "openrouter",
397            counts: EmbeddingStatusCounts::default(),
398            coverage: EmbeddingCoverage {
399                memories_total: 10,
400                memories_with_vec: 9,
401                memories_missing: 1,
402                entities_total: 4,
403                entities_with_vec: 4,
404                entities_missing: 0,
405                chunks_total: 7,
406                chunks_with_vec: 7,
407                chunks_missing: 0,
408            },
409            elapsed_ms: 1,
410        };
411        let json = serde_json::to_value(&output).expect("serialize");
412        assert_eq!(json["coverage"]["memories_total"], 10);
413        assert_eq!(json["coverage"]["memories_with_vec"], 9);
414        assert_eq!(json["coverage"]["entities_with_vec"], 4);
415        assert_eq!(json["coverage"]["chunks_with_vec"], 7);
416        // v1.1.1 (P6b): the missing counters serialize alongside the totals.
417        assert_eq!(json["coverage"]["memories_missing"], 1);
418        assert_eq!(json["coverage"]["entities_missing"], 0);
419        assert_eq!(json["coverage"]["chunks_missing"], 0);
420    }
421
422    // v1.1.1 (P6b): the LEFT JOIN counts real gaps and the absent-table
423    // fallback reports EVERYTHING missing instead of a silent 0.
424    #[test]
425    fn count_missing_counts_gaps_and_falls_back_when_table_absent() {
426        let conn = rusqlite::Connection::open_in_memory().unwrap();
427        conn.execute_batch(
428            "CREATE TABLE entities (id INTEGER PRIMARY KEY, name TEXT);
429            CREATE TABLE entity_embeddings (
430                entity_id INTEGER PRIMARY KEY,
431                embedding BLOB NOT NULL
432            );",
433        )
434        .unwrap();
435        conn.execute(
436            "INSERT INTO entities (id, name) VALUES (1, 'a'), (2, 'b'), (3, 'c')",
437            [],
438        )
439        .unwrap();
440        conn.execute(
441            "INSERT INTO entity_embeddings (entity_id, embedding) VALUES (1, X'00')",
442            [],
443        )
444        .unwrap();
445
446        let missing = count_missing(
447            &conn,
448            "SELECT COUNT(*) FROM entities e \
449             LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
450             WHERE ee.entity_id IS NULL",
451            3,
452        );
453        assert_eq!(missing, 2, "2 of 3 entities lack a vector row");
454
455        // Absent embedding table: everything counts as missing.
456        let missing_absent = count_missing(
457            &conn,
458            "SELECT COUNT(*) FROM entities e \
459             LEFT JOIN chunk_embeddings ce ON ce.chunk_id = e.id \
460             WHERE ce.chunk_id IS NULL",
461            3,
462        );
463        assert_eq!(missing_absent, 3, "absent table must report all missing");
464    }
465
466    #[test]
467    fn status_filter_round_trip() {
468        for f in [
469            EmbeddingStatusFilter::Pending,
470            EmbeddingStatusFilter::InProgress,
471            EmbeddingStatusFilter::Done,
472            EmbeddingStatusFilter::Abandoned,
473        ] {
474            let s: PendingEmbeddingStatus = f.into();
475            assert_eq!(
476                s.as_str(),
477                match f {
478                    EmbeddingStatusFilter::Pending => "pending",
479                    EmbeddingStatusFilter::InProgress => "in_progress",
480                    EmbeddingStatusFilter::Done => "done",
481                    EmbeddingStatusFilter::Abandoned => "abandoned",
482                }
483            );
484        }
485    }
486}