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