1use crate::errors::AppError;
8use crate::output;
9use crate::paths::AppPaths;
10use crate::storage::connection::open_ro;
11use crate::storage::{entities, memories};
12
13use serde::Serialize;
14use std::collections::HashSet;
15use std::sync::Arc;
16use tokio::sync::Semaphore;
17use tokio::task::JoinSet;
18
19mod pipeline;
20use pipeline::{compute_sub_embeddings, execute_sub_query, resolve_sub_queries};
21
22#[cfg(test)]
23use pipeline::{decompose_query, decompose_query_with_sources};
24
25#[derive(clap::Args)]
27#[command(
28 about = "Deep parallel multi-hop GraphRAG research via query decomposition",
29 after_long_help = "CONTRACT:\n \
30 stdout = pretty JSON envelope only (machine-readable).\n \
31 stderr = tracing / progress / diagnostics only.\n \
32 Never redirect with `&>` or `2>&1` into the same file as stdout — that\n \
33 contaminates the JSON and breaks jaq/jq. Prefer:\n \
34 sqlite-graphrag deep-research \"q\" > out.json 2>/dev/null\n \
35 or --output out.json (atomic write via atomwrite algorithm).\n\n\
36EXAMPLES:\n \
37 # Basic deep research (single-token queries auto-expand into aspects)\n \
38 sqlite-graphrag deep-research \"danilo\"\n\n \
39 # With custom parameters\n \
40 sqlite-graphrag deep-research \"auth\" --k 20 --max-hops 3 --max-sub-queries 7\n\n \
41 # Include full memory bodies in output\n \
42 sqlite-graphrag deep-research \"auth\" --with-bodies\n\n \
43 # Manual sub-queries (one query per line)\n \
44 sqlite-graphrag deep-research \"danilo\" --sub-query-strategy manual \\\n \
45 --sub-queries-file aspects.txt\n\n \
46 # Atomic JSON file (crash-safe; preferred for large --with-bodies runs)\n \
47 sqlite-graphrag deep-research \"auth\" --output /tmp/dr.json\n\n \
48 # Tune RRF and graph scoring\n \
49 sqlite-graphrag deep-research \"auth and deployment\" --rrf-k 60 --graph-decay 0.7"
50)]
51pub struct DeepResearchArgs {
52 #[arg(
54 value_name = "QUERY",
55 allow_hyphen_values = true,
56 help = "Research query to decompose and search"
57 )]
58 pub query: String,
59 #[arg(
61 long,
62 short,
63 aliases = ["limit", "top-k"],
64 default_value_t = 20,
65 help = "Results per sub-query (Recall@20 captures 95%+ relevant hits)"
66 )]
67 pub k: usize,
68 #[arg(
70 long,
71 default_value_t = 7,
72 help = "Maximum sub-queries (covers complex multi-hop queries)"
73 )]
74 pub max_sub_queries: usize,
75 #[arg(
77 long,
78 default_value_t = 3,
79 help = "Multi-hop graph traversal depth (sweet spot: 2-3 hops)"
80 )]
81 pub max_hops: usize,
82 #[arg(
84 long,
85 default_value_t = 0.3,
86 help = "Minimum edge weight for graph traversal"
87 )]
88 pub min_weight: f64,
89 #[arg(long, help = "Maximum concurrent sub-queries (default: min(cpus, 8))")]
91 pub max_concurrency: Option<usize>,
92 #[arg(long, default_value_t = 30, help = "Timeout per sub-query in seconds")]
94 pub timeout: u64,
95 #[arg(
97 long,
98 default_value_t = false,
99 help = "Include full memory bodies in results"
100 )]
101 pub with_bodies: bool,
102 #[arg(
104 long,
105 default_value_t = 50,
106 help = "Maximum results after deduplication"
107 )]
108 pub max_results: usize,
109 #[arg(
111 long,
112 default_value_t = 60.0,
113 help = "RRF k parameter (higher = less weight on top ranks)"
114 )]
115 pub rrf_k: f64,
116 #[arg(
118 long,
119 default_value_t = 0.7,
120 help = "Graph score decay factor per hop (0.0-1.0)"
121 )]
122 pub graph_decay: f64,
123 #[arg(
125 long,
126 default_value_t = 0.05,
127 help = "Minimum score threshold for graph-expanded results"
128 )]
129 pub graph_min_score: f64,
130 #[arg(
132 long,
133 help = "Limit neighbours per entity per hop for graph traversal (default: unlimited)"
134 )]
135 pub max_neighbors_per_hop: Option<usize>,
136 #[arg(long, help = "Namespace (flag / XDG namespace.default / global)")]
138 pub namespace: Option<String>,
139 #[arg(long, default_value = "none", value_parser = ["none"], hide = true)]
141 pub mode: String,
142 #[arg(
144 long,
145 value_name = "USD",
146 help = "Max LLM cost in USD (effective with --mode claude-code/codex)"
147 )]
148 pub max_cost_usd: Option<f64>,
149 #[arg(long, hide = true)]
151 pub json: bool,
152 #[arg(long)]
154 pub db: Option<String>,
155 #[arg(
158 long,
159 default_value = "heuristic",
160 value_parser = ["heuristic", "manual"],
161 help = "Sub-query strategy: heuristic (default) or manual"
162 )]
163 pub sub_query_strategy: String,
164 #[arg(
167 long,
168 value_name = "PATH",
169 help = "File with one sub-query per line (manual strategy)"
170 )]
171 pub sub_queries_file: Option<std::path::PathBuf>,
172 #[arg(
177 short = 'o',
178 long,
179 value_name = "PATH",
180 help = "Atomic JSON output path (atomwrite algorithm; short -o)"
181 )]
182 pub output: Option<std::path::PathBuf>,
183}
184
185#[derive(Serialize)]
186pub(super) struct SubQuery {
187 pub(super) id: usize,
188 pub(super) text: String,
189 pub(super) source: &'static str,
190}
191
192#[derive(Serialize)]
193struct DeepResult {
194 name: String,
195 score: f64,
196 source: String,
197 sub_query_ids: Vec<usize>,
198 snippet: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 body: Option<String>,
201 hop_distance: Option<usize>,
202}
203
204#[derive(Serialize, Clone)]
206pub(super) struct EvidenceNode {
207 pub(super) entity: String,
208 #[serde(skip_serializing_if = "Option::is_none")]
209 pub(super) relation: Option<String>,
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub(super) weight: Option<f64>,
212}
213
214#[derive(Serialize)]
223pub(super) struct EvidenceChain {
224 pub(super) from: String,
225 pub(super) to: String,
226 pub(super) path: Vec<EvidenceNode>,
227 pub(super) total_weight: f64,
228 pub(super) depth: usize,
229 pub(super) sub_query_ids: Vec<usize>,
230}
231
232#[derive(Serialize)]
233struct ResearchStats {
234 sub_queries_total: usize,
235 sub_queries_completed: usize,
236 sub_queries_failed: usize,
237 sub_queries_timed_out: usize,
238 unique_memories_found: usize,
239 evidence_chains_found: usize,
240 elapsed_ms: u64,
241 vec_degraded: bool,
242}
243
244#[derive(Serialize)]
245struct GraphContextEntity {
246 name: String,
247 entity_type: String,
248 degree: u32,
249}
250
251#[derive(Serialize)]
252struct GraphContextRel {
253 from: String,
254 to: String,
255 relation: String,
256 weight: f64,
257}
258
259#[derive(Serialize)]
260struct GraphContext {
261 entities: Vec<GraphContextEntity>,
262 relationships: Vec<GraphContextRel>,
263}
264
265#[derive(Serialize)]
266struct DeepResearchResponse {
267 query: String,
268 sub_queries: Vec<SubQuery>,
269 results: Vec<DeepResult>,
270 evidence_chains: Vec<EvidenceChain>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 graph_context: Option<GraphContext>,
273 stats: ResearchStats,
274}
275
276type MergedHit = (f64, String, String, String, Option<usize>, Vec<usize>);
278
279pub(super) struct SubQueryResult {
281 pub(super) sub_query_id: usize,
282 pub(super) hits: Vec<(i64, f64, String, String, String, Option<usize>)>,
284 pub(super) chains: Vec<EvidenceChain>,
286}
287
288#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
290pub fn run(
291 args: DeepResearchArgs,
292 llm_backend: crate::cli::LlmBackendChoice,
293 embedding_backend: crate::cli::EmbeddingBackendChoice,
294) -> Result<(), AppError> {
295 tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
296
297 let paths = AppPaths::resolve(args.db.as_deref())?;
305 crate::storage::connection::ensure_db_ready(&paths)?;
306 let sub_query_plan = resolve_sub_queries(&args)?;
308 let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
309 let (sub_embeddings, vec_degraded) =
310 compute_sub_embeddings(&paths, &sub_query_texts, embedding_backend, llm_backend);
311
312 let rt = tokio::runtime::Builder::new_multi_thread()
313 .worker_threads(2)
314 .enable_all()
315 .build()
316 .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
317 rt.block_on(run_async(
318 args,
319 llm_backend,
320 embedding_backend,
321 sub_query_plan,
322 sub_embeddings,
323 vec_degraded,
324 ))
325}
326
327async fn run_async(
335 args: DeepResearchArgs,
336 _llm_backend: crate::cli::LlmBackendChoice,
337 _embedding_backend: crate::cli::EmbeddingBackendChoice,
338 sub_queries: Vec<SubQuery>,
339 sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
340 vec_degraded: bool,
341) -> Result<(), AppError> {
342 let start = std::time::Instant::now();
343
344 if args.query.trim().is_empty() {
345 return Err(AppError::Validation(crate::i18n::validation::empty_query()));
346 }
347
348 if args.max_cost_usd.is_some() && args.mode == "none" {
349 tracing::warn!(target: "deep_research", "--max-cost-usd has no effect without --mode claude-code/codex");
350 }
351
352 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
353 let paths = AppPaths::resolve(args.db.as_deref())?;
354 crate::storage::connection::ensure_db_ready(&paths)?;
355
356 let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
358
359 if vec_degraded {
364 tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
365 }
366
367 let cpu_count = std::thread::available_parallelism()
369 .map(|n| n.get())
370 .unwrap_or(4);
371 let permits = args
372 .max_concurrency
373 .unwrap_or_else(|| cpu_count.min(8))
374 .min(sub_queries.len())
375 .max(1);
376 let semaphore = Arc::new(Semaphore::new(permits));
377 let timeout_dur = std::time::Duration::from_secs(args.timeout);
378
379 let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
380
381 for (idx, sq_text) in sub_query_texts.iter().enumerate() {
382 let sem = Arc::clone(&semaphore);
383 let emb = sub_embeddings[idx].clone();
385 let ns = namespace.clone();
386 let db_path = paths.db.clone();
387 let query_text = sq_text.clone();
388 let k = args.k;
389 let max_hops = args.max_hops;
390 let min_weight = args.min_weight;
391 let rrf_k = args.rrf_k;
392 let graph_decay = args.graph_decay;
393 let graph_min_score = args.graph_min_score;
394 let max_neighbors_per_hop = args.max_neighbors_per_hop;
395
396 join_set.spawn(async move {
397 let _permit = sem
398 .acquire_owned()
399 .await
400 .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
401
402 let result = tokio::time::timeout(timeout_dur, async move {
404 execute_sub_query(
405 idx,
406 &query_text,
407 emb.as_ref().map(|v| v.as_slice()),
408 &ns,
409 &db_path,
410 k,
411 max_hops,
412 min_weight,
413 rrf_k,
414 graph_decay,
415 graph_min_score,
416 max_neighbors_per_hop,
417 )
418 })
419 .await;
420
421 match result {
422 Ok(inner) => inner.map_err(|e| (idx, e)),
423 Err(_) => Err((idx, "timeout".to_string())),
424 }
425 });
426 }
427
428 let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
430 let mut failed_count = 0usize;
431 let mut timed_out_count = 0usize;
432
433 while let Some(join_result) = join_set.join_next().await {
434 match join_result {
435 Ok(Ok(sqr)) => sub_query_results.push(sqr),
436 Ok(Err((_idx, reason))) => {
437 if reason == "timeout" {
438 timed_out_count += 1;
439 } else {
440 failed_count += 1;
441 }
442 tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
443 }
444 Err(join_err) => {
445 failed_count += 1;
446 if join_err.is_panic() {
447 tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
448 } else {
449 tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
450 }
451 }
452 }
453 }
454
455 let mut merged: crate::hash::AHashMap<i64, MergedHit> =
458 crate::hash::AHashMap::with_capacity_and_hasher(
459 sub_query_results.len() * args.k,
460 Default::default(),
461 );
462
463 for sqr in &sub_query_results {
464 for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
465 let entry = merged.entry(*mem_id).or_insert_with(|| {
466 (
467 *score,
468 source.clone(),
469 snippet.clone(),
470 body.clone(),
471 *hop,
472 Vec::new(),
473 )
474 });
475 if *score > entry.0 {
477 entry.0 = *score;
478 entry.1 = source.clone();
479 entry.2 = snippet.clone();
480 entry.3 = body.clone();
481 entry.4 = *hop;
482 }
483 if !entry.5.contains(&sqr.sub_query_id) {
484 entry.5.push(sqr.sub_query_id);
485 }
486 }
487 }
488
489 let conn = open_ro(&paths.db)?;
491 let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
492
493 let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
495 ranked.sort_by(|a, b| {
496 b.1 .0
497 .partial_cmp(&a.1 .0)
498 .unwrap_or(std::cmp::Ordering::Equal)
499 });
500 ranked.truncate(args.max_results);
501
502 for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
503 let name = match memories::read_full(&conn, mem_id)? {
504 Some(row) => row.name,
505 None => continue,
506 };
507 results.push(DeepResult {
508 name,
509 score,
510 source,
511 sub_query_ids: sq_ids,
512 snippet,
513 body: if args.with_bodies { Some(body) } else { None },
514 hop_distance: hop,
515 });
516 }
517
518 let completed_count = sub_query_results.len();
522 let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
523 let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
524
525 for sqr in sub_query_results {
526 for chain in sqr.chains {
527 let key = format!("{}->{}", chain.from, chain.to);
529 if seen_chain_keys.insert(key) {
530 evidence_chains.push(chain);
531 }
532 }
533 }
534
535 evidence_chains.retain(|c| c.depth >= 2);
537 evidence_chains.sort_by(|a, b| {
538 b.total_weight
539 .partial_cmp(&a.total_weight)
540 .unwrap_or(std::cmp::Ordering::Equal)
541 });
542
543 let unique_memories = results.len();
544 let evidence_count = evidence_chains.len();
545
546 let graph_context = if !results.is_empty() {
548 let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
549 let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
550 let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
551 let mut seen_entity_ids: crate::hash::AHashSet<i64> =
552 crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
553
554 for name in &result_names {
555 if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
556 if seen_entity_ids.insert(eid) {
557 let etype: String = conn
558 .query_row(
559 "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
560 rusqlite::params![eid],
561 |r| r.get(0),
562 )
563 .unwrap_or_else(|_| "concept".to_string());
564 let degree: u32 = conn
565 .query_row(
566 "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
567 rusqlite::params![eid],
568 |r| r.get(0),
569 )
570 .unwrap_or(0);
571 ctx_entities.push(GraphContextEntity {
572 name: name.to_string(),
573 entity_type: etype,
574 degree,
575 });
576 }
577 }
578 }
579
580 let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
581 if entity_ids.len() >= 2 {
582 let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
583 let sql = format!(
584 "SELECT s.name, t.name, r.relation, r.weight \
585 FROM relationships r \
586 JOIN entities s ON s.id = r.source_id \
587 JOIN entities t ON t.id = r.target_id \
588 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
589 LIMIT 50"
590 );
591 if let Ok(mut stmt) = conn.prepare(&sql) {
592 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
593 Vec::with_capacity(entity_ids.len() * 2);
594 for id in &entity_ids {
595 params.push(Box::new(*id));
596 }
597 for id in &entity_ids {
598 params.push(Box::new(*id));
599 }
600 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
601 params.iter().map(|p| p.as_ref()).collect();
602 if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
603 Ok((
604 r.get::<_, String>(0)?,
605 r.get::<_, String>(1)?,
606 r.get::<_, String>(2)?,
607 r.get::<_, f64>(3)?,
608 ))
609 }) {
610 for row in rows.flatten() {
611 ctx_rels.push(GraphContextRel {
612 from: row.0,
613 to: row.1,
614 relation: row.2,
615 weight: row.3,
616 });
617 }
618 }
619 }
620 }
621
622 if ctx_entities.is_empty() {
623 None
624 } else {
625 Some(GraphContext {
626 entities: ctx_entities,
627 relationships: ctx_rels,
628 })
629 }
630 } else {
631 None
632 };
633
634 tracing::debug!(target: "deep_research",
635 total_results = results.len(),
636 total_chains = evidence_chains.len(),
637 "assembly complete"
638 );
639
640 let response = DeepResearchResponse {
642 query: args.query,
643 sub_queries,
644 results,
645 evidence_chains,
646 graph_context,
647 stats: ResearchStats {
648 sub_queries_total: sub_query_texts.len(),
649 sub_queries_completed: completed_count,
650 sub_queries_failed: failed_count,
651 sub_queries_timed_out: timed_out_count,
652 unique_memories_found: unique_memories,
653 evidence_chains_found: evidence_count,
654 elapsed_ms: start.elapsed().as_millis() as u64,
655 vec_degraded,
656 },
657 };
658
659 if let Some(path) = args.output.as_ref() {
660 crate::atomic_io::write_json_atomic(path, &response)?;
666 if !path.exists() {
667 return Err(AppError::Validation(
668 crate::i18n::validation::deep_research_output_missing(&path.display().to_string()),
669 ));
670 }
671 let meta = std::fs::metadata(path).map_err(AppError::Io)?;
672 if meta.len() == 0 {
673 return Err(AppError::Validation(
674 crate::i18n::validation::deep_research_output_empty(&path.display().to_string()),
675 ));
676 }
677 let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
678 let digest = blake3::hash(&file_bytes).to_hex().to_string();
679 #[derive(Serialize)]
680 struct WrittenAck {
681 written: String,
682 bytes: u64,
683 blake3: String,
684 sub_queries_total: usize,
685 unique_memories_found: usize,
686 elapsed_ms: u64,
687 }
688 output::emit_json(&WrittenAck {
689 written: path.display().to_string(),
690 bytes: meta.len(),
691 blake3: digest,
692 sub_queries_total: response.stats.sub_queries_total,
693 unique_memories_found: response.stats.unique_memories_found,
694 elapsed_ms: response.stats.elapsed_ms,
695 })?;
696 } else {
697 output::emit_json(&response)?;
698 }
699
700 Ok(())
701}
702
703#[cfg(test)]
707mod tests;