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", value_parser = crate::parsers::parse_hops_range_u32)]
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(
115        long,
116        default_value_t = false,
117        help = "Print JSON Schema for recall output and exit"
118    )]
119    pub print_schema: bool,
120}
121
122/// Run.
123#[tracing::instrument(skip_all, level = "debug", name = "recall")]
124pub fn run(
125    args: RecallArgs,
126    backends: crate::cli::BackendChoice,
127    fail_on_degraded: bool,
128) -> Result<(), AppError> {
129    if args.print_schema {
130        return crate::print_schema::emit(crate::print_schema::SchemaId::Recall);
131    }
132    let start = std::time::Instant::now();
133    let _ = args.format;
134    let query = args.query.as_deref().unwrap_or("").to_string();
135    tracing::debug!(target: "recall", query = %query, k = args.k, "searching");
136
137    // G20: reject graph-specific flags when --no-graph is active
138    if args.no_graph {
139        if args.max_hops != 2 {
140            return Err(AppError::Validation(
141                "--max-hops has no effect with --no-graph; remove one".to_string(),
142            ));
143        }
144        if (args.min_weight - 0.3).abs() > f64::EPSILON {
145            return Err(AppError::Validation(
146                "--min-weight has no effect with --no-graph; remove one".to_string(),
147            ));
148        }
149    }
150
151    if query.trim().is_empty() {
152        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
153    }
154    // Resolve the list of namespaces to search:
155    // - empty vec  => all namespaces (sentinel used by knn_search)
156    // - single vec => one namespace (default or --namespace value)
157    let namespaces: Vec<String> = if args.all_namespaces {
158        Vec::new()
159    } else {
160        vec![crate::namespace::resolve_namespace(
161            args.namespace.as_deref(),
162        )?]
163    };
164    // Single namespace string used for graph traversal and error messages.
165    let namespace_for_graph = namespaces
166        .first()
167        .cloned()
168        .unwrap_or_else(|| "global".to_string());
169    let paths = AppPaths::resolve(args.db.as_deref())?;
170
171    crate::storage::connection::ensure_db_ready(&paths)?;
172
173    output::emit_progress_i18n(
174        "Computing query embedding...",
175        "Calculando embedding da consulta...",
176    );
177    let conn = open_ro(&paths.db)?;
178    // G58 (v1.0.80): when the live embedding fails, fall back to FTS5 BM25 +
179    // LIKE prefix and surface the degradation through `vec_degraded` +
180    // `vec_error` + `warning` on the response envelope. Shared with
181    // `hybrid-search` so both commands degrade under identical conditions.
182    let resolved = crate::query_embedding::resolve_query_embedding(
183        args.fallback_fts_only,
184        &paths.models,
185        &query,
186        backends,
187        "recall",
188    );
189    // `--fail-on-degraded` decides BEFORE any query runs: without this the read
190    // answered FTS-only with exit 0 and the flag was a placebo.
191    if let Some(err) = crate::query_embedding::degradation_failure(
192        fail_on_degraded,
193        resolved.degraded,
194        resolved.reason_code,
195    ) {
196        return Err(err);
197    }
198    let crate::query_embedding::QueryEmbedding {
199        embedding,
200        degraded: vec_degraded,
201        error: vec_error,
202        backend_invoked,
203        reason_code: vec_degraded_code,
204    } = resolved;
205
206    let memory_type_str = args.r#type.map(|t| t.as_str());
207    // When --precise is set, lift the -k cap so every match is returned; the
208    // max_distance filter below will trim irrelevant results instead.
209    let effective_k = if args.precise { 100_000 } else { args.k };
210    // GAP-SG-201: reported, never refused. `-k` bounds a ranking rather than
211    // paging a table, so the top-k IS the answer the caller asked for; the
212    // record exists so its narrowness stops being invisible.
213    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
214        applied: effective_k,
215        offset: 0,
216        source: crate::agent_surface::universe::CeilingSource::Flag,
217        kind: crate::agent_surface::universe::CeilingKind::TopK,
218        universe_total: None,
219    });
220
221    // G58: if the embedding is unavailable, route the entire direct path
222    // through FTS5 BM25 + LIKE prefix. Graph traversal is suppressed because
223    // it depends on the KNN results to seed the expansion; without the
224    // embedding, no seed exists.
225    let (direct_matches, memory_ids): (Vec<RecallItem>, Vec<i64>) =
226        if let Some(emb) = embedding.as_ref() {
227            let knn_results =
228                memories::knn_search(&conn, emb, &namespaces, memory_type_str, effective_k)?;
229            let mut items: Vec<RecallItem> = Vec::with_capacity(knn_results.len());
230            let mut memory_ids: Vec<i64> = Vec::with_capacity(knn_results.len());
231            for (memory_id, distance) in knn_results {
232                let row = {
233                    let mut stmt = conn.prepare_cached(
234                        "SELECT id, namespace, name, type, description, body, body_hash,
235                            session_id, source, metadata, created_at, updated_at
236                     FROM memories WHERE id=?1 AND deleted_at IS NULL",
237                    )?;
238                    stmt.query_row(rusqlite::params![memory_id], |r| {
239                        Ok(memories::MemoryRow {
240                            id: r.get(0)?,
241                            namespace: r.get(1)?,
242                            name: r.get(2)?,
243                            memory_type: r.get(3)?,
244                            description: r.get(4)?,
245                            body: r.get(5)?,
246                            body_hash: r.get(6)?,
247                            session_id: r.get(7)?,
248                            source: r.get(8)?,
249                            metadata: r.get(9)?,
250                            created_at: r.get(10)?,
251                            updated_at: r.get(11)?,
252                            deleted_at: None,
253                        })
254                    })
255                    .ok()
256                };
257                if let Some(row) = row {
258                    let snippet: String = row.body.chars().take(300).collect();
259                    items.push(RecallItem {
260                        memory_id: row.id,
261                        name: row.name,
262                        namespace: row.namespace,
263                        memory_type: row.memory_type,
264                        description: row.description,
265                        snippet,
266                        distance,
267                        score: RecallItem::score_from_distance(distance),
268                        source: "direct".to_string(),
269                        graph_depth: None,
270                    });
271                    memory_ids.push(memory_id);
272                }
273            }
274            (items, memory_ids)
275        } else {
276            // FTS5 BM25 + LIKE prefix fallback path. The same `fts_search` helper
277            // is used as in `hybrid-search`; distance is approximated by
278            // 1.0 / (rank + 1) so the score is in (0, 1] and comparable to the
279            // vector path's `1.0 - distance`. Note: only the FIRST effective_k
280            // results are kept to preserve the top-N contract.
281            let fts_rows = memories::fts_search(
282                &conn,
283                &query,
284                &namespace_for_graph,
285                memory_type_str,
286                effective_k,
287            )?;
288            let mut items: Vec<RecallItem> = Vec::with_capacity(fts_rows.len());
289            for (rank, row) in fts_rows.into_iter().enumerate() {
290                let dist = 1.0 - 1.0 / (rank as f32 + 1.0);
291                let snippet: String = row.body.chars().take(300).collect();
292                items.push(RecallItem {
293                    memory_id: row.id,
294                    name: row.name,
295                    namespace: row.namespace,
296                    memory_type: row.memory_type,
297                    description: row.description,
298                    snippet,
299                    distance: dist,
300                    score: RecallItem::score_from_distance(dist),
301                    source: "fts_fallback".to_string(),
302                    graph_depth: None,
303                });
304            }
305            (items, Vec::new())
306        };
307
308    let mut graph_matches = Vec::with_capacity(8);
309    if let Some(emb) = (!args.no_graph).then_some(()).and(embedding.as_ref()) {
310        let entity_knn = entities::knn_search(&conn, emb, &namespace_for_graph, 5)?;
311        let entity_ids: Vec<i64> = entity_knn.iter().map(|(id, _)| *id).collect();
312
313        let all_seed_ids: Vec<i64> = memory_ids
314            .iter()
315            .chain(entity_ids.iter())
316            .copied()
317            .collect();
318
319        if !all_seed_ids.is_empty() {
320            let graph_memory_ids = traverse_from_memories_with_hops(
321                &conn,
322                &all_seed_ids,
323                &namespace_for_graph,
324                args.min_weight,
325                args.max_hops,
326            )?;
327
328            for (graph_mem_id, hop) in graph_memory_ids {
329                // v1.0.23: respect the optional cap on graph results so dense
330                // neighbourhoods do not flood the response unintentionally.
331                if let Some(cap) = args.max_graph_results {
332                    if graph_matches.len() >= cap {
333                        break;
334                    }
335                }
336                let row = {
337                    let mut stmt = conn.prepare_cached(
338                        "SELECT id, namespace, name, type, description, body, body_hash,
339                                session_id, source, metadata, created_at, updated_at
340                         FROM memories WHERE id=?1 AND deleted_at IS NULL",
341                    )?;
342                    stmt.query_row(rusqlite::params![graph_mem_id], |r| {
343                        Ok(memories::MemoryRow {
344                            id: r.get(0)?,
345                            namespace: r.get(1)?,
346                            name: r.get(2)?,
347                            memory_type: r.get(3)?,
348                            description: r.get(4)?,
349                            body: r.get(5)?,
350                            body_hash: r.get(6)?,
351                            session_id: r.get(7)?,
352                            source: r.get(8)?,
353                            metadata: r.get(9)?,
354                            created_at: r.get(10)?,
355                            updated_at: r.get(11)?,
356                            deleted_at: None,
357                        })
358                    })
359                    .ok()
360                };
361                if let Some(row) = row {
362                    let snippet: String = row.body.chars().take(300).collect();
363                    let graph_distance = 1.0 - 1.0 / (hop as f32 + 1.0);
364                    graph_matches.push(RecallItem {
365                        memory_id: row.id,
366                        name: row.name,
367                        namespace: row.namespace,
368                        memory_type: row.memory_type,
369                        description: row.description,
370                        snippet,
371                        distance: graph_distance,
372                        score: RecallItem::score_from_distance(graph_distance),
373                        source: "graph".to_string(),
374                        graph_depth: Some(hop),
375                    });
376                }
377            }
378        }
379    }
380
381    // Filtrar por max_distance se < 1.0 (ativado). Se nenhum hit dentro do threshold, exit 4.
382    if args.max_distance < 1.0 && !vec_degraded {
383        let has_relevant = direct_matches
384            .iter()
385            .any(|item| item.distance <= args.max_distance);
386        if !has_relevant {
387            return Err(AppError::NotFound(errors_msg::no_recall_results(
388                args.max_distance,
389                &query,
390                &namespace_for_graph,
391            )));
392        }
393    }
394
395    let results: Vec<RecallItem> = direct_matches
396        .iter()
397        .cloned()
398        .chain(graph_matches.iter().cloned())
399        .collect();
400
401    let warning = if vec_degraded {
402        Some(
403            "live query embedding unavailable; results are FTS5 BM25 only (semantic relevance reduced)"
404                .to_string(),
405        )
406    } else {
407        None
408    };
409
410    output::emit_json(&RecallResponse {
411        query,
412        k: args.k,
413        direct_matches,
414        graph_matches,
415        results,
416        elapsed_ms: start.elapsed().as_millis() as u64,
417        vec_degraded,
418        vec_error: vec_error.clone(),
419        warning,
420        backend_invoked,
421        vec_degraded_reason: if vec_degraded { vec_error } else { None },
422        vec_degraded_code: if vec_degraded {
423            vec_degraded_code
424        } else {
425            None
426        },
427    })?;
428
429    Ok(())
430}
431
432#[cfg(test)]
433mod tests {
434    use crate::output::{RecallItem, RecallResponse};
435
436    fn make_item(name: &str, distance: f32, source: &str) -> RecallItem {
437        RecallItem {
438            memory_id: 1,
439            name: name.to_string(),
440            namespace: "global".to_string(),
441            memory_type: "fact".to_string(),
442            description: "desc".to_string(),
443            snippet: "snippet".to_string(),
444            distance,
445            score: RecallItem::score_from_distance(distance),
446            source: source.to_string(),
447            graph_depth: if source == "graph" { Some(0) } else { None },
448        }
449    }
450
451    // Bug M-A5: every RecallItem carries a non-null cosine similarity score.
452    #[test]
453    fn recall_item_score_is_present_and_finite_for_direct_match() {
454        let item = make_item("mem", 0.25, "direct");
455        let json = serde_json::to_value(&item).expect("serialization failed");
456        let score = json["score"].as_f64().expect("score must be a number");
457        assert!(
458            (0.0..=1.0).contains(&score),
459            "score must be in [0, 1], got {score}"
460        );
461        assert!(
462            (score - 0.75).abs() < 1e-6,
463            "score must equal 1 - distance for canonical case"
464        );
465    }
466
467    #[test]
468    fn recall_item_score_clamps_distance_outside_unit_range() {
469        // Pathological distances must not yield score outside [0, 1] or NaN.
470        assert_eq!(RecallItem::score_from_distance(2.0), 0.0);
471        assert_eq!(RecallItem::score_from_distance(-0.5), 1.0);
472        assert_eq!(RecallItem::score_from_distance(f32::NAN), 0.0);
473    }
474
475    #[test]
476    fn recall_response_serializes_required_fields() {
477        let resp = RecallResponse {
478            query: "rust memory".to_string(),
479            k: 5,
480            direct_matches: vec![make_item("mem-a", 0.12, "direct")],
481            graph_matches: vec![],
482            results: vec![make_item("mem-a", 0.12, "direct")],
483            elapsed_ms: 42,
484            vec_degraded: false,
485            vec_error: None,
486            warning: None,
487            backend_invoked: None,
488            vec_degraded_reason: None,
489            vec_degraded_code: None,
490        };
491
492        let json = serde_json::to_value(&resp).expect("serialization failed");
493        assert_eq!(json["query"], "rust memory");
494        assert_eq!(json["k"], 5);
495        assert_eq!(json["elapsed_ms"], 42u64);
496        assert!(json["direct_matches"].is_array());
497        assert!(json["graph_matches"].is_array());
498        assert!(json["results"].is_array());
499    }
500
501    #[test]
502    fn recall_item_serializes_renamed_type() {
503        let item = make_item("mem-test", 0.25, "direct");
504        let json = serde_json::to_value(&item).expect("serialization failed");
505
506        // The memory_type field is renamed to "type" in JSON
507        assert_eq!(json["type"], "fact");
508        assert_eq!(json["distance"], 0.25f32);
509        assert_eq!(json["source"], "direct");
510    }
511
512    #[test]
513    fn recall_response_results_contains_direct_and_graph() {
514        let direct = make_item("d-mem", 0.10, "direct");
515        let graph = make_item("g-mem", 0.0, "graph");
516
517        let resp = RecallResponse {
518            query: "query".to_string(),
519            k: 10,
520            direct_matches: vec![direct.clone()],
521            graph_matches: vec![graph.clone()],
522            results: vec![direct, graph],
523            elapsed_ms: 10,
524            vec_degraded: false,
525            vec_error: None,
526            warning: None,
527            backend_invoked: None,
528            vec_degraded_reason: None,
529            vec_degraded_code: None,
530        };
531
532        let json = serde_json::to_value(&resp).expect("serialization failed");
533        assert_eq!(json["direct_matches"].as_array().unwrap().len(), 1);
534        assert_eq!(json["graph_matches"].as_array().unwrap().len(), 1);
535        assert_eq!(json["results"].as_array().unwrap().len(), 2);
536        assert_eq!(json["results"][0]["source"], "direct");
537        assert_eq!(json["results"][1]["source"], "graph");
538    }
539
540    #[test]
541    fn recall_response_empty_serializes_empty_arrays() {
542        let resp = RecallResponse {
543            query: "nothing".to_string(),
544            k: 3,
545            direct_matches: vec![],
546            graph_matches: vec![],
547            results: vec![],
548            elapsed_ms: 1,
549            vec_degraded: false,
550            vec_error: None,
551            warning: None,
552            backend_invoked: None,
553            vec_degraded_reason: None,
554            vec_degraded_code: None,
555        };
556
557        let json = serde_json::to_value(&resp).expect("serialization failed");
558        assert_eq!(json["direct_matches"].as_array().unwrap().len(), 0);
559        assert_eq!(json["results"].as_array().unwrap().len(), 0);
560    }
561
562    #[test]
563    fn graph_matches_distance_uses_hop_count_proxy() {
564        // Verify the hop-count proxy formula: 1.0 - 1.0 / (hop + 1.0)
565        // hop=0 → 0.0 (seed-level entity, identity distance)
566        // hop=1 → 0.5
567        // hop=2 → ≈ 0.667
568        // hop=3 → 0.75
569        let cases: &[(u32, f32)] = &[(0, 0.0), (1, 0.5), (2, 0.6667), (3, 0.75)];
570        for &(hop, expected) in cases {
571            let d = 1.0_f32 - 1.0 / (hop as f32 + 1.0);
572            assert!(
573                (d - expected).abs() < 0.001,
574                "hop={hop} expected={expected} got={d}"
575            );
576        }
577    }
578}