Skip to main content

sqlite_graphrag/commands/
recall.rs

1//! Handler for the `recall` CLI subcommand.
2
3use crate::cli::MemoryType;
4use crate::errors::AppError;
5use crate::graph::traverse_from_memories_with_hops;
6use crate::i18n::errors_msg;
7use crate::output::{self, JsonOutputFormat, RecallItem, RecallResponse};
8use crate::paths::AppPaths;
9use crate::storage::connection::open_ro;
10use crate::storage::entities;
11use crate::storage::memories;
12
13/// Arguments for the `recall` subcommand.
14///
15/// When `--namespace` is omitted the query runs against the `global` namespace,
16/// which is the default namespace used by `remember` when no `--namespace` flag
17/// is provided. Pass an explicit `--namespace` value to search a different
18/// isolated namespace.
19#[derive(clap::Args)]
20#[command(after_long_help = "EXAMPLES:\n  \
21    # Semantic search for top 5 matches\n  \
22    sqlite-graphrag recall \"authentication design\" --k 5\n\n  \
23    # Disable automatic graph expansion\n  \
24    sqlite-graphrag recall \"JWT tokens\" --k 3 --no-graph\n\n  \
25    # Limit graph traversal depth and minimum edge weight\n  \
26    sqlite-graphrag recall \"auth\" --k 5 --max-hops 2 --min-weight 0.3\n\n  \
27    # Filter by memory type\n  \
28    sqlite-graphrag recall \"deployment\" --type decision --k 10\n\n  \
29    # Cap results by distance threshold\n  \
30    sqlite-graphrag recall \"API design\" --k 5 --max-distance 0.8\n\n  \
31NOTES:\n  \
32    When --no-graph is active, graph traversal is skipped and every result has\n  \
33    source=\"direct\". The source field is therefore redundant with --no-graph and\n  \
34    may be ignored by callers in that mode.")]
35pub struct RecallArgs {
36    #[arg(
37        allow_hyphen_values = true,
38        required_unless_present = "print_schema",
39        help = "Search query string (semantic vector search via sqlite-vec)"
40    )]
41    /// Search query text.
42    pub query: Option<String>,
43    /// Maximum number of direct vector matches to return.
44    ///
45    /// Note: this flag controls only `direct_matches`. Graph traversal results
46    /// (`graph_matches`) are unbounded by default; use `--max-graph-results` to
47    /// cap them independently. The `results` field aggregates both lists.
48    /// Validated to the inclusive range `1..=4096` (the upper bound matches
49    /// `sqlite-vec`'s knn limit; out-of-range values are rejected at parse time).
50    #[arg(short = 'k', long, aliases = ["limit", "top-k"], default_value = "10", value_parser = crate::parsers::parse_k_range)]
51    pub k: usize,
52    /// Filter by memory.type. Note: distinct from graph entity_type
53    /// (project/tool/person/file/concept/incident/decision/memory/dashboard/issue_tracker/organization/location/date)
54    /// used in --entities-file.
55    #[arg(long, value_enum)]
56    pub r#type: Option<MemoryType>,
57    /// Namespace scope.
58    #[arg(long)]
59    pub namespace: Option<String>,
60    /// No graph.
61    #[arg(long)]
62    pub no_graph: bool,
63    /// Disable -k cap and return all direct matches without truncation.
64    ///
65    /// When set, the `-k`/`--k` flag is ignored for `direct_matches` and the
66    /// response includes every match above the distance threshold. Useful when
67    /// callers need the complete set rather than a top-N preview.
68    #[arg(long)]
69    pub precise: bool,
70    /// Max hops.
71    #[arg(long, default_value = "2")]
72    pub max_hops: u32,
73    /// Min weight.
74    #[arg(long, default_value = "0.3")]
75    pub min_weight: f64,
76    /// Cap the size of `graph_matches` to at most N entries.
77    ///
78    /// Defaults to unbounded (`None`) so existing pipelines see the same shape
79    /// as in v1.0.22 and earlier. Set this when a query touches a dense graph
80    /// neighbourhood and the caller only needs a top-N preview. Added in v1.0.23.
81    #[arg(long, value_name = "N")]
82    pub max_graph_results: Option<usize>,
83    /// Filter results by maximum distance. Results with distance greater than this value
84    /// are excluded. If all matches exceed this threshold, the command exits with code 4
85    /// (`not found`) per the documented public contract.
86    /// Default `1.0` disables the filter and preserves the top-k behavior.
87    #[arg(long, alias = "min-distance", default_value = "1.0")]
88    pub max_distance: f32,
89    /// Output format.
90    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
91    pub format: JsonOutputFormat,
92    /// Path to the SQLite database file.
93    #[arg(long)]
94    pub db: Option<String>,
95    /// Accept `--json` as a no-op because output is already JSON by default.
96    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
97    pub json: bool,
98    /// Search across all namespaces instead of a single namespace.
99    ///
100    /// Cannot be combined with `--namespace`. When set, the query runs against
101    /// every namespace and results include a `namespace` field to identify origin.
102    #[arg(long, conflicts_with = "namespace")]
103    pub all_namespaces: bool,
104    /// G58 (v1.0.80): skip the live query embedding and use FTS5 BM25 +
105    /// LIKE prefix exclusively. Useful in CI/CD with tight OAuth quota and
106    /// in deterministic regression tests that need stable ranking.
107    #[arg(
108        long,
109        help = "Skip live query embedding; use FTS5 BM25 + LIKE prefix only"
110    )]
111    pub fallback_fts_only: bool,
112    /// Emit the JSON Schema for this command's stdout envelope and exit 0
113    /// without opening the database or embedding (agent-native R-AN-01).
114    #[arg(long, default_value_t = false, help = "Print JSON Schema for recall output and exit")]
115    pub print_schema: bool,
116}
117
118/// Run.
119#[tracing::instrument(skip_all, level = "debug", name = "recall")]
120pub fn run(
121    args: RecallArgs,
122    llm_backend: crate::cli::LlmBackendChoice,
123    embedding_backend: crate::cli::EmbeddingBackendChoice,
124) -> Result<(), AppError> {
125    if args.print_schema {
126        return crate::print_schema::emit(crate::print_schema::SchemaId::Recall);
127    }
128    let start = std::time::Instant::now();
129    let _ = args.format;
130    let query = args.query.as_deref().unwrap_or("").to_string();
131    tracing::debug!(target: "recall", query = %query, k = args.k, "searching");
132
133    // G20: reject graph-specific flags when --no-graph is active
134    if args.no_graph {
135        if args.max_hops != 2 {
136            return Err(AppError::Validation(
137                "--max-hops has no effect with --no-graph; remove one".to_string(),
138            ));
139        }
140        if (args.min_weight - 0.3).abs() > f64::EPSILON {
141            return Err(AppError::Validation(
142                "--min-weight has no effect with --no-graph; remove one".to_string(),
143            ));
144        }
145    }
146
147    if query.trim().is_empty() {
148        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
149    }
150    // Resolve the list of namespaces to search:
151    // - empty vec  => all namespaces (sentinel used by knn_search)
152    // - single vec => one namespace (default or --namespace value)
153    let namespaces: Vec<String> = if args.all_namespaces {
154        Vec::new()
155    } else {
156        vec![crate::namespace::resolve_namespace(
157            args.namespace.as_deref(),
158        )?]
159    };
160    // Single namespace string used for graph traversal and error messages.
161    let namespace_for_graph = namespaces
162        .first()
163        .cloned()
164        .unwrap_or_else(|| "global".to_string());
165    let paths = AppPaths::resolve(args.db.as_deref())?;
166
167    crate::storage::connection::ensure_db_ready(&paths)?;
168
169    output::emit_progress_i18n(
170        "Computing query embedding...",
171        "Calculando embedding da consulta...",
172    );
173    let conn = open_ro(&paths.db)?;
174    // G58 (v1.0.80): when the live embedding fails (timeout, OAuth contention,
175    // rate limit, missing CLI), fall back to FTS5 BM25 + LIKE prefix and
176    // surface the degradation through `vec_degraded` + `vec_error` + `warning`
177    // on the response envelope. The `--fallback-fts-only` flag forces the
178    // skip without even attempting the embedding subprocess.
179    // v1.0.84 (ADR-0042): tuple de 4 elementos. `backend_invoked` carrega
180    // o discriminador do backend que efetivamente invocou o LLM (ou `None`
181    // when the caller asked for `--fallback-fts-only` and never invoked the subprocess).
182    let (embedding, vec_degraded, vec_error, backend_invoked) = if args.fallback_fts_only {
183        (
184            None,
185            true,
186            Some("fallback_fts_only requested".to_string()),
187            None,
188        )
189    } else {
190        // v1.0.82 (GAP-003): forward --llm-backend to embed_with_fallback.
191        // v1.0.84 (ADR-0042): extrai o backend que efetivamente invocou o
192        // LLM to populate `backend_invoked` in the response envelope.
193        // v1.0.85 (G58 / ADR-0043): retry determinístico em OAuthQuota
194        // (codex ↔ claude) e backoff 750ms em SlotExhausted antes de
195        // accept degradation to pure FTS5.
196        match crate::embedder::try_embed_query_with_embedding_choice(
197            &paths.models,
198            &query,
199            embedding_backend,
200            llm_backend,
201        ) {
202            Ok((v, backend)) => (Some(v), false, None, Some(backend.as_str())),
203            Err(reason) => {
204                let msg = reason.to_string();
205                tracing::warn!(target: "recall", fallback_reason = %msg, reason_code = %reason.reason_code(), "live embedding failed; falling back to FTS5");
206                (None, true, Some(msg), None)
207            }
208        }
209    };
210
211    let memory_type_str = args.r#type.map(|t| t.as_str());
212    // When --precise is set, lift the -k cap so every match is returned; the
213    // max_distance filter below will trim irrelevant results instead.
214    let effective_k = if args.precise { 100_000 } else { args.k };
215
216    // G58: if the embedding is unavailable, route the entire direct path
217    // through FTS5 BM25 + LIKE prefix. Graph traversal is suppressed because
218    // it depends on the KNN results to seed the expansion; without the
219    // embedding, no seed exists.
220    let (direct_matches, memory_ids): (Vec<RecallItem>, Vec<i64>) =
221        if let Some(emb) = embedding.as_ref() {
222            let knn_results =
223                memories::knn_search(&conn, emb, &namespaces, memory_type_str, effective_k)?;
224            let mut items: Vec<RecallItem> = Vec::with_capacity(knn_results.len());
225            let mut memory_ids: Vec<i64> = Vec::with_capacity(knn_results.len());
226            for (memory_id, distance) in knn_results {
227                let row = {
228                    let mut stmt = conn.prepare_cached(
229                        "SELECT id, namespace, name, type, description, body, body_hash,
230                            session_id, source, metadata, created_at, updated_at
231                     FROM memories WHERE id=?1 AND deleted_at IS NULL",
232                    )?;
233                    stmt.query_row(rusqlite::params![memory_id], |r| {
234                        Ok(memories::MemoryRow {
235                            id: r.get(0)?,
236                            namespace: r.get(1)?,
237                            name: r.get(2)?,
238                            memory_type: r.get(3)?,
239                            description: r.get(4)?,
240                            body: r.get(5)?,
241                            body_hash: r.get(6)?,
242                            session_id: r.get(7)?,
243                            source: r.get(8)?,
244                            metadata: r.get(9)?,
245                            created_at: r.get(10)?,
246                            updated_at: r.get(11)?,
247                            deleted_at: None,
248                        })
249                    })
250                    .ok()
251                };
252                if let Some(row) = row {
253                    let snippet: String = row.body.chars().take(300).collect();
254                    items.push(RecallItem {
255                        memory_id: row.id,
256                        name: row.name,
257                        namespace: row.namespace,
258                        memory_type: row.memory_type,
259                        description: row.description,
260                        snippet,
261                        distance,
262                        score: RecallItem::score_from_distance(distance),
263                        source: "direct".to_string(),
264                        graph_depth: None,
265                    });
266                    memory_ids.push(memory_id);
267                }
268            }
269            (items, memory_ids)
270        } else {
271            // FTS5 BM25 + LIKE prefix fallback path. The same `fts_search` helper
272            // is used as in `hybrid-search`; distance is approximated by
273            // 1.0 / (rank + 1) so the score is in (0, 1] and comparable to the
274            // vector path's `1.0 - distance`. Note: only the FIRST effective_k
275            // results are kept to preserve the top-N contract.
276            let fts_rows = memories::fts_search(
277                &conn,
278                &query,
279                &namespace_for_graph,
280                memory_type_str,
281                effective_k,
282            )?;
283            let mut items: Vec<RecallItem> = Vec::with_capacity(fts_rows.len());
284            for (rank, row) in fts_rows.into_iter().enumerate() {
285                let dist = 1.0 - 1.0 / (rank as f32 + 1.0);
286                let snippet: String = row.body.chars().take(300).collect();
287                items.push(RecallItem {
288                    memory_id: row.id,
289                    name: row.name,
290                    namespace: row.namespace,
291                    memory_type: row.memory_type,
292                    description: row.description,
293                    snippet,
294                    distance: dist,
295                    score: RecallItem::score_from_distance(dist),
296                    source: "fts_fallback".to_string(),
297                    graph_depth: None,
298                });
299            }
300            (items, Vec::new())
301        };
302
303    let mut graph_matches = Vec::with_capacity(8);
304    if let Some(emb) = (!args.no_graph).then_some(()).and(embedding.as_ref()) {
305        let entity_knn = entities::knn_search(&conn, emb, &namespace_for_graph, 5)?;
306        let entity_ids: Vec<i64> = entity_knn.iter().map(|(id, _)| *id).collect();
307
308        let all_seed_ids: Vec<i64> = memory_ids
309            .iter()
310            .chain(entity_ids.iter())
311            .copied()
312            .collect();
313
314        if !all_seed_ids.is_empty() {
315            let graph_memory_ids = traverse_from_memories_with_hops(
316                &conn,
317                &all_seed_ids,
318                &namespace_for_graph,
319                args.min_weight,
320                args.max_hops,
321            )?;
322
323            for (graph_mem_id, hop) in graph_memory_ids {
324                // v1.0.23: respect the optional cap on graph results so dense
325                // neighbourhoods do not flood the response unintentionally.
326                if let Some(cap) = args.max_graph_results {
327                    if graph_matches.len() >= cap {
328                        break;
329                    }
330                }
331                let row = {
332                    let mut stmt = conn.prepare_cached(
333                        "SELECT id, namespace, name, type, description, body, body_hash,
334                                session_id, source, metadata, created_at, updated_at
335                         FROM memories WHERE id=?1 AND deleted_at IS NULL",
336                    )?;
337                    stmt.query_row(rusqlite::params![graph_mem_id], |r| {
338                        Ok(memories::MemoryRow {
339                            id: r.get(0)?,
340                            namespace: r.get(1)?,
341                            name: r.get(2)?,
342                            memory_type: r.get(3)?,
343                            description: r.get(4)?,
344                            body: r.get(5)?,
345                            body_hash: r.get(6)?,
346                            session_id: r.get(7)?,
347                            source: r.get(8)?,
348                            metadata: r.get(9)?,
349                            created_at: r.get(10)?,
350                            updated_at: r.get(11)?,
351                            deleted_at: None,
352                        })
353                    })
354                    .ok()
355                };
356                if let Some(row) = row {
357                    let snippet: String = row.body.chars().take(300).collect();
358                    let graph_distance = 1.0 - 1.0 / (hop as f32 + 1.0);
359                    graph_matches.push(RecallItem {
360                        memory_id: row.id,
361                        name: row.name,
362                        namespace: row.namespace,
363                        memory_type: row.memory_type,
364                        description: row.description,
365                        snippet,
366                        distance: graph_distance,
367                        score: RecallItem::score_from_distance(graph_distance),
368                        source: "graph".to_string(),
369                        graph_depth: Some(hop),
370                    });
371                }
372            }
373        }
374    }
375
376    // Filtrar por max_distance se < 1.0 (ativado). Se nenhum hit dentro do threshold, exit 4.
377    if args.max_distance < 1.0 && !vec_degraded {
378        let has_relevant = direct_matches
379            .iter()
380            .any(|item| item.distance <= args.max_distance);
381        if !has_relevant {
382            return Err(AppError::NotFound(errors_msg::no_recall_results(
383                args.max_distance,
384                &query,
385                &namespace_for_graph,
386            )));
387        }
388    }
389
390    let results: Vec<RecallItem> = direct_matches
391        .iter()
392        .cloned()
393        .chain(graph_matches.iter().cloned())
394        .collect();
395
396    let warning = if vec_degraded {
397        Some(
398            "live query embedding unavailable; results are FTS5 BM25 only (semantic relevance reduced)"
399                .to_string(),
400        )
401    } else {
402        None
403    };
404
405    output::emit_json(&RecallResponse {
406        query,
407        k: args.k,
408        direct_matches,
409        graph_matches,
410        results,
411        elapsed_ms: start.elapsed().as_millis() as u64,
412        vec_degraded,
413        vec_error: vec_error.clone(),
414        warning,
415        backend_invoked,
416        vec_degraded_reason: if vec_degraded { vec_error } else { None },
417    })?;
418
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use crate::output::{RecallItem, RecallResponse};
425
426    fn make_item(name: &str, distance: f32, source: &str) -> RecallItem {
427        RecallItem {
428            memory_id: 1,
429            name: name.to_string(),
430            namespace: "global".to_string(),
431            memory_type: "fact".to_string(),
432            description: "desc".to_string(),
433            snippet: "snippet".to_string(),
434            distance,
435            score: RecallItem::score_from_distance(distance),
436            source: source.to_string(),
437            graph_depth: if source == "graph" { Some(0) } else { None },
438        }
439    }
440
441    // Bug M-A5: every RecallItem carries a non-null cosine similarity score.
442    #[test]
443    fn recall_item_score_is_present_and_finite_for_direct_match() {
444        let item = make_item("mem", 0.25, "direct");
445        let json = serde_json::to_value(&item).expect("serialization failed");
446        let score = json["score"].as_f64().expect("score must be a number");
447        assert!(
448            (0.0..=1.0).contains(&score),
449            "score must be in [0, 1], got {score}"
450        );
451        assert!(
452            (score - 0.75).abs() < 1e-6,
453            "score must equal 1 - distance for canonical case"
454        );
455    }
456
457    #[test]
458    fn recall_item_score_clamps_distance_outside_unit_range() {
459        // Pathological distances must not yield score outside [0, 1] or NaN.
460        assert_eq!(RecallItem::score_from_distance(2.0), 0.0);
461        assert_eq!(RecallItem::score_from_distance(-0.5), 1.0);
462        assert_eq!(RecallItem::score_from_distance(f32::NAN), 0.0);
463    }
464
465    #[test]
466    fn recall_response_serializes_required_fields() {
467        let resp = RecallResponse {
468            query: "rust memory".to_string(),
469            k: 5,
470            direct_matches: vec![make_item("mem-a", 0.12, "direct")],
471            graph_matches: vec![],
472            results: vec![make_item("mem-a", 0.12, "direct")],
473            elapsed_ms: 42,
474            vec_degraded: false,
475            vec_error: None,
476            warning: None,
477            backend_invoked: None,
478            vec_degraded_reason: None,
479        };
480
481        let json = serde_json::to_value(&resp).expect("serialization failed");
482        assert_eq!(json["query"], "rust memory");
483        assert_eq!(json["k"], 5);
484        assert_eq!(json["elapsed_ms"], 42u64);
485        assert!(json["direct_matches"].is_array());
486        assert!(json["graph_matches"].is_array());
487        assert!(json["results"].is_array());
488    }
489
490    #[test]
491    fn recall_item_serializes_renamed_type() {
492        let item = make_item("mem-test", 0.25, "direct");
493        let json = serde_json::to_value(&item).expect("serialization failed");
494
495        // The memory_type field is renamed to "type" in JSON
496        assert_eq!(json["type"], "fact");
497        assert_eq!(json["distance"], 0.25f32);
498        assert_eq!(json["source"], "direct");
499    }
500
501    #[test]
502    fn recall_response_results_contains_direct_and_graph() {
503        let direct = make_item("d-mem", 0.10, "direct");
504        let graph = make_item("g-mem", 0.0, "graph");
505
506        let resp = RecallResponse {
507            query: "query".to_string(),
508            k: 10,
509            direct_matches: vec![direct.clone()],
510            graph_matches: vec![graph.clone()],
511            results: vec![direct, graph],
512            elapsed_ms: 10,
513            vec_degraded: false,
514            vec_error: None,
515            warning: None,
516            backend_invoked: None,
517            vec_degraded_reason: None,
518        };
519
520        let json = serde_json::to_value(&resp).expect("serialization failed");
521        assert_eq!(json["direct_matches"].as_array().unwrap().len(), 1);
522        assert_eq!(json["graph_matches"].as_array().unwrap().len(), 1);
523        assert_eq!(json["results"].as_array().unwrap().len(), 2);
524        assert_eq!(json["results"][0]["source"], "direct");
525        assert_eq!(json["results"][1]["source"], "graph");
526    }
527
528    #[test]
529    fn recall_response_empty_serializes_empty_arrays() {
530        let resp = RecallResponse {
531            query: "nothing".to_string(),
532            k: 3,
533            direct_matches: vec![],
534            graph_matches: vec![],
535            results: vec![],
536            elapsed_ms: 1,
537            vec_degraded: false,
538            vec_error: None,
539            warning: None,
540            backend_invoked: None,
541            vec_degraded_reason: None,
542        };
543
544        let json = serde_json::to_value(&resp).expect("serialization failed");
545        assert_eq!(json["direct_matches"].as_array().unwrap().len(), 0);
546        assert_eq!(json["results"].as_array().unwrap().len(), 0);
547    }
548
549    #[test]
550    fn graph_matches_distance_uses_hop_count_proxy() {
551        // Verify the hop-count proxy formula: 1.0 - 1.0 / (hop + 1.0)
552        // hop=0 → 0.0 (seed-level entity, identity distance)
553        // hop=1 → 0.5
554        // hop=2 → ≈ 0.667
555        // hop=3 → 0.75
556        let cases: &[(u32, f32)] = &[(0, 0.0), (1, 0.5), (2, 0.6667), (3, 0.75)];
557        for &(hop, expected) in cases {
558            let d = 1.0_f32 - 1.0 / (hop as f32 + 1.0);
559            assert!(
560                (d - expected).abs() < 0.001,
561                "hop={hop} expected={expected} got={d}"
562            );
563        }
564    }
565}