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`")]
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, value_parser = crate::parsers::parse_list_limit_range)]
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 embedding backend that would be
125    /// invoked to process live embeddings. `"openrouter" | "none" | "auto"`.
126    ///
127    /// `"auto"` means the caller requested Auto and the chain is resolved at
128    /// runtime. Since v1.2.0 that chain is only OpenRouter→none: the
129    /// codex→claude→none chain this doc used to describe went away with the
130    /// subprocess backends.
131    backend_invoked: &'static str,
132    counts: EmbeddingStatusCounts,
133    /// GAP-SG-41: real vector coverage in the persisted tables. The `counts`
134    /// above only reflect the async retry queue (empty on the synchronous REST
135    /// path), so `coverage` reports the actual rows in `memory_embeddings`,
136    /// `entity_embeddings` and `chunk_embeddings` versus their source rows.
137    coverage: EmbeddingCoverage,
138    elapsed_ms: u64,
139}
140
141#[derive(Serialize, Default)]
142struct EmbeddingStatusCounts {
143    pending: usize,
144    in_progress: usize,
145    done: usize,
146    abandoned: usize,
147}
148
149/// GAP-SG-41: actual persisted-vector coverage. Each `*_with_vec` field counts
150/// the rows that have an embedding; the `*_total` field counts the source rows
151/// (active memories / entities / chunks). When totals are non-zero the operator
152/// can audit coverage directly instead of inferring it from `hybrid-search`.
153#[derive(Serialize, Default)]
154struct EmbeddingCoverage {
155    memories_total: i64,
156    memories_with_vec: i64,
157    /// v1.1.1 (P6b): active memories WITHOUT a row in `memory_embeddings`
158    /// (LEFT JOIN, so orphaned vectors never mask a gap). Additive field —
159    /// the pre-existing totals keep their meaning.
160    memories_missing: i64,
161    entities_total: i64,
162    entities_with_vec: i64,
163    /// v1.1.1 (P6b): entities without a row in `entity_embeddings`.
164    entities_missing: i64,
165    chunks_total: i64,
166    chunks_with_vec: i64,
167    /// v1.1.1 (P6b): memory_chunks rows without a row in `chunk_embeddings`.
168    chunks_missing: i64,
169}
170
171/// Counts a table, returning 0 when the table is absent (legacy DB) instead of
172/// failing the whole status report.
173fn count_table(conn: &rusqlite::Connection, sql: &str) -> i64 {
174    match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
175        Ok(n) => n,
176        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => 0,
177        Err(e) => {
178            tracing::warn!(target: "embedding", error = %e, sql, "coverage count failed");
179            0
180        }
181    }
182}
183
184/// v1.1.1 (P6b): counts source rows without a vector via LEFT JOIN. When the
185/// embedding table does not exist (legacy DB) EVERY source row is missing, so
186/// the fallback is `total_when_absent` — never a silent 0 that would report
187/// full coverage on a table that is not there.
188fn count_missing(conn: &rusqlite::Connection, sql: &str, total_when_absent: i64) -> i64 {
189    match conn.query_row(sql, [], |r| r.get::<_, i64>(0)) {
190        Ok(n) => n,
191        Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("no such table") => {
192            total_when_absent
193        }
194        Err(e) => {
195            tracing::warn!(target: "embedding", error = %e, sql, "coverage missing-count failed");
196            0
197        }
198    }
199}
200
201#[derive(Serialize)]
202struct EmbeddingListEntry {
203    pending_id: i64,
204    memory_id: i64,
205    name: String,
206    namespace: String,
207    backend_chain: String,
208    last_error: Option<String>,
209    last_exit_code: Option<i32>,
210    last_stderr_tail: Option<String>,
211    attempt_count: i32,
212    status: String,
213    updated_at: i64,
214}
215
216impl From<&PendingEmbedding> for EmbeddingListEntry {
217    fn from(p: &PendingEmbedding) -> Self {
218        Self {
219            pending_id: p.pending_id,
220            memory_id: p.memory_id,
221            name: p.name.clone(),
222            namespace: p.namespace.clone(),
223            backend_chain: p.backend_chain.clone(),
224            last_error: p.last_error.clone(),
225            last_exit_code: p.last_exit_code,
226            last_stderr_tail: p.last_stderr_tail.clone(),
227            attempt_count: p.attempt_count,
228            status: p.status.as_str().to_string(),
229            updated_at: p.updated_at,
230        }
231    }
232}
233
234#[derive(Serialize)]
235struct EmbeddingListOutput {
236    action: &'static str,
237    filter_status: String,
238    count: usize,
239    entries: Vec<EmbeddingListEntry>,
240    elapsed_ms: u64,
241}
242
243#[derive(Serialize)]
244struct EmbeddingAbandonOutput {
245    action: &'static str,
246    pending_id: i64,
247    status: &'static str,
248    elapsed_ms: u64,
249    yes: bool,
250}
251
252/// Run.
253pub fn run(args: EmbeddingArgs, llm_backend: LlmBackendChoice) -> Result<(), AppError> {
254    match args.cmd {
255        EmbeddingCmd::Status(a) => run_status(a, llm_backend),
256        EmbeddingCmd::List(a) => run_list(a),
257        EmbeddingCmd::Abandon(a) => run_abandon(a),
258    }
259}
260
261fn open_conn(db: Option<&str>) -> Result<(AppPaths, rusqlite::Connection), AppError> {
262    let paths = AppPaths::resolve(db)?;
263    let conn = open_rw(&paths.db)?;
264    Ok((paths, conn))
265}
266
267/// Shared by `embedding status` and `pending-embeddings status` (GAP-E2E-09).
268pub(crate) fn run_status(
269    args: EmbeddingStatusArgs,
270    llm_backend: LlmBackendChoice,
271) -> Result<(), AppError> {
272    let start = std::time::Instant::now();
273    let (_paths, conn) = open_conn(args.db.as_deref())?;
274
275    let counts = EmbeddingStatusCounts {
276        pending: pending_embeddings::list_by_status(
277            &conn,
278            PendingEmbeddingStatus::Pending,
279            100_000,
280        )?
281        .len(),
282        in_progress: pending_embeddings::list_by_status(
283            &conn,
284            PendingEmbeddingStatus::InProgress,
285            100_000,
286        )?
287        .len(),
288        done: pending_embeddings::list_by_status(&conn, PendingEmbeddingStatus::Done, 100_000)?
289            .len(),
290        abandoned: pending_embeddings::list_by_status(
291            &conn,
292            PendingEmbeddingStatus::Abandoned,
293            100_000,
294        )?
295        .len(),
296    };
297
298    let backend_invoked: &'static str = match llm_backend {
299        LlmBackendChoice::None => "none",
300        LlmBackendChoice::OpenRouter => "openrouter",
301    };
302
303    // GAP-SG-41: query the actual vector tables so coverage is observable even
304    // when the async queue is empty (the synchronous OpenRouter REST path never
305    // populates `pending_embeddings`).
306    let memories_total = count_table(
307        &conn,
308        "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
309    );
310    let entities_total = count_table(&conn, "SELECT COUNT(*) FROM entities");
311    let chunks_total = count_table(&conn, "SELECT COUNT(*) FROM memory_chunks");
312    let coverage = EmbeddingCoverage {
313        memories_total,
314        memories_with_vec: count_table(&conn, "SELECT COUNT(*) FROM memory_embeddings"),
315        // v1.1.1 (P6b): missing counts via LEFT JOIN so orphaned vector rows
316        // never inflate coverage; absent embedding table means ALL missing.
317        memories_missing: count_missing(
318            &conn,
319            "SELECT COUNT(*) FROM memories m \
320             LEFT JOIN memory_embeddings me ON me.memory_id = m.id \
321             WHERE me.memory_id IS NULL AND m.deleted_at IS NULL",
322            memories_total,
323        ),
324        entities_total,
325        entities_with_vec: count_table(&conn, "SELECT COUNT(*) FROM entity_embeddings"),
326        entities_missing: count_missing(
327            &conn,
328            "SELECT COUNT(*) FROM entities e \
329             LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
330             WHERE ee.entity_id IS NULL",
331            entities_total,
332        ),
333        chunks_total,
334        chunks_with_vec: count_table(&conn, "SELECT COUNT(*) FROM chunk_embeddings"),
335        chunks_missing: count_missing(
336            &conn,
337            "SELECT COUNT(*) FROM memory_chunks c \
338             LEFT JOIN chunk_embeddings ce ON ce.chunk_id = c.id \
339             WHERE ce.chunk_id IS NULL",
340            chunks_total,
341        ),
342    };
343
344    let output = EmbeddingStatusOutput {
345        action: "embedding_status",
346        backend_invoked,
347        counts,
348        coverage,
349        elapsed_ms: start.elapsed().as_millis() as u64,
350    };
351    emit_json_compact(&output)
352}
353
354fn run_list(args: EmbeddingListArgs) -> Result<(), AppError> {
355    let start = std::time::Instant::now();
356    let (_paths, conn) = open_conn(args.db.as_deref())?;
357    let status: PendingEmbeddingStatus = args.status.into();
358    let rows = pending_embeddings::list_by_status(&conn, status, args.limit)?;
359
360    // GAP-SG-201: this subcommand pages a countable set, so it declares the
361    // ceiling like `list` and `graph entities` do. Without it the surface
362    // reported `query_limited: null` and a `--count-only` over the page read as
363    // the whole queue.
364    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
365        applied: args.limit,
366        offset: 0,
367        source: crate::agent_surface::universe::CeilingSource::Flag,
368        kind: crate::agent_surface::universe::CeilingKind::Pagination,
369        universe_total: Some(pending_embeddings::count_by_status(&conn, status)?),
370    });
371
372    let count = rows.len();
373    let entries: Vec<EmbeddingListEntry> = rows.iter().map(EmbeddingListEntry::from).collect();
374    let output = EmbeddingListOutput {
375        action: "embedding_list",
376        filter_status: status.as_str().to_string(),
377        count,
378        entries,
379        elapsed_ms: start.elapsed().as_millis() as u64,
380    };
381    emit_json_compact(&output)
382}
383
384fn run_abandon(args: EmbeddingAbandonArgs) -> Result<(), AppError> {
385    let start = std::time::Instant::now();
386    let (_paths, conn) = open_conn(args.db.as_deref())?;
387    pending_embeddings::abandon(&conn, args.pending_id)?;
388    let output = EmbeddingAbandonOutput {
389        action: "embedding_abandon",
390        pending_id: args.pending_id,
391        status: PendingEmbeddingStatus::Abandoned.as_str(),
392        elapsed_ms: start.elapsed().as_millis() as u64,
393        yes: args.yes,
394    };
395    emit_json_compact(&output)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    // GAP-SG-41: the status output exposes real vector coverage, not only the
403    // async queue counts.
404    #[test]
405    fn embedding_status_output_includes_coverage() {
406        let output = EmbeddingStatusOutput {
407            action: "embedding_status",
408            backend_invoked: "openrouter",
409            counts: EmbeddingStatusCounts::default(),
410            coverage: EmbeddingCoverage {
411                memories_total: 10,
412                memories_with_vec: 9,
413                memories_missing: 1,
414                entities_total: 4,
415                entities_with_vec: 4,
416                entities_missing: 0,
417                chunks_total: 7,
418                chunks_with_vec: 7,
419                chunks_missing: 0,
420            },
421            elapsed_ms: 1,
422        };
423        let json = serde_json::to_value(&output).expect("serialize");
424        assert_eq!(json["coverage"]["memories_total"], 10);
425        assert_eq!(json["coverage"]["memories_with_vec"], 9);
426        assert_eq!(json["coverage"]["entities_with_vec"], 4);
427        assert_eq!(json["coverage"]["chunks_with_vec"], 7);
428        // v1.1.1 (P6b): the missing counters serialize alongside the totals.
429        assert_eq!(json["coverage"]["memories_missing"], 1);
430        assert_eq!(json["coverage"]["entities_missing"], 0);
431        assert_eq!(json["coverage"]["chunks_missing"], 0);
432    }
433
434    // v1.1.1 (P6b): the LEFT JOIN counts real gaps and the absent-table
435    // fallback reports EVERYTHING missing instead of a silent 0.
436    #[test]
437    fn count_missing_counts_gaps_and_falls_back_when_table_absent() {
438        let conn = rusqlite::Connection::open_in_memory().unwrap();
439        conn.execute_batch(
440            "CREATE TABLE entities (id INTEGER PRIMARY KEY, name TEXT);
441            CREATE TABLE entity_embeddings (
442                entity_id INTEGER PRIMARY KEY,
443                embedding BLOB NOT NULL
444            );",
445        )
446        .unwrap();
447        conn.execute(
448            "INSERT INTO entities (id, name) VALUES (1, 'a'), (2, 'b'), (3, 'c')",
449            [],
450        )
451        .unwrap();
452        conn.execute(
453            "INSERT INTO entity_embeddings (entity_id, embedding) VALUES (1, X'00')",
454            [],
455        )
456        .unwrap();
457
458        let missing = count_missing(
459            &conn,
460            "SELECT COUNT(*) FROM entities e \
461             LEFT JOIN entity_embeddings ee ON ee.entity_id = e.id \
462             WHERE ee.entity_id IS NULL",
463            3,
464        );
465        assert_eq!(missing, 2, "2 of 3 entities lack a vector row");
466
467        // Absent embedding table: everything counts as missing.
468        let missing_absent = count_missing(
469            &conn,
470            "SELECT COUNT(*) FROM entities e \
471             LEFT JOIN chunk_embeddings ce ON ce.chunk_id = e.id \
472             WHERE ce.chunk_id IS NULL",
473            3,
474        );
475        assert_eq!(missing_absent, 3, "absent table must report all missing");
476    }
477
478    #[test]
479    fn status_filter_round_trip() {
480        for f in [
481            EmbeddingStatusFilter::Pending,
482            EmbeddingStatusFilter::InProgress,
483            EmbeddingStatusFilter::Done,
484            EmbeddingStatusFilter::Abandoned,
485        ] {
486            let s: PendingEmbeddingStatus = f.into();
487            assert_eq!(
488                s.as_str(),
489                match f {
490                    EmbeddingStatusFilter::Pending => "pending",
491                    EmbeddingStatusFilter::InProgress => "in_progress",
492                    EmbeddingStatusFilter::Done => "done",
493                    EmbeddingStatusFilter::Abandoned => "abandoned",
494                }
495            );
496        }
497    }
498}