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", value_parser = crate::parsers::parse_hops_range_u32)]
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(
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#[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 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 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 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 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 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 let effective_k = if args.precise { 100_000 } else { args.k };
212 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 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 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 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 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 #[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 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 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 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}