1use 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#[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 pub query: Option<String>,
43 #[arg(short = 'k', long, aliases = ["limit", "top-k"], default_value = "10", value_parser = crate::parsers::parse_k_range)]
51 pub k: usize,
52 #[arg(long, value_enum)]
56 pub r#type: Option<MemoryType>,
57 #[arg(long)]
59 pub namespace: Option<String>,
60 #[arg(long)]
62 pub no_graph: bool,
63 #[arg(long)]
69 pub precise: bool,
70 #[arg(long, default_value = "2")]
72 pub max_hops: u32,
73 #[arg(long, default_value = "0.3")]
75 pub min_weight: f64,
76 #[arg(long, value_name = "N")]
82 pub max_graph_results: Option<usize>,
83 #[arg(long, alias = "min-distance", default_value = "1.0")]
88 pub max_distance: f32,
89 #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
91 pub format: JsonOutputFormat,
92 #[arg(long)]
94 pub db: Option<String>,
95 #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
97 pub json: bool,
98 #[arg(long, conflicts_with = "namespace")]
103 pub all_namespaces: bool,
104 #[arg(
108 long,
109 help = "Skip live query embedding; use FTS5 BM25 + LIKE prefix only"
110 )]
111 pub fallback_fts_only: bool,
112 #[arg(long, default_value_t = false, help = "Print JSON Schema for recall output and exit")]
115 pub print_schema: bool,
116}
117
118#[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 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 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 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 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 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 let effective_k = if args.precise { 100_000 } else { args.k };
215
216 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 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 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 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 #[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 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 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 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}