Skip to main content

sqlite_graphrag/commands/deep_research/
mod.rs

1//! Handler for the `deep-research` CLI subcommand.
2//!
3//! Orchestrates parallel multi-hop GraphRAG search via query decomposition.
4//! The workload is I/O-bound (SQLite WAL reads), so tokio is used instead of
5//! rayon. Each sub-query opens its own read-only connection.
6//!
7//! `args` holds the CLI surface and `envelope` the serialisation shapes;
8//! `pipeline` decomposes the query and executes each sub-query. The fan-out
9//! orchestration itself stays here.
10
11use crate::errors::AppError;
12use crate::output;
13use crate::paths::AppPaths;
14use crate::storage::connection::open_ro;
15use crate::storage::{entities, memories};
16
17use serde::Serialize;
18
19use std::collections::HashSet;
20use std::sync::Arc;
21use tokio::sync::Semaphore;
22use tokio::task::JoinSet;
23
24mod args;
25mod envelope;
26mod pipeline;
27
28pub use args::DeepResearchArgs;
29use envelope::{
30    DeepResearchResponse, DeepResult, GraphContext, GraphContextEntity, GraphContextRel, MergedHit,
31    ResearchStats,
32};
33pub(super) use envelope::{EvidenceChain, EvidenceNode, SubQuery, SubQueryResult};
34use pipeline::{compute_sub_embeddings, execute_sub_query, resolve_sub_queries, RetrievalKnobs};
35
36#[cfg(test)]
37use pipeline::{decompose_query, decompose_query_with_sources};
38
39/// Sync entry point — builds a tokio runtime for the async fan-out.
40#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
41pub fn run(
42    args: DeepResearchArgs,
43    backends: crate::cli::BackendChoice,
44    fail_on_degraded: bool,
45) -> Result<(), AppError> {
46    tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
47
48    // GAP-001 (v1.1.04): resolve embeddings for every sub-query BEFORE the
49    // multi-thread runtime is built. `compute_sub_embeddings` calls the
50    // OpenRouter REST path, which internally does
51    // `shared_runtime()?.block_on(...)`; running that inside the worker
52    // threads of the runtime created below panics with
53    // "Cannot start a runtime from within a runtime". Doing the work
54    // synchronously here removes the nesting entirely.
55    let paths = AppPaths::resolve(args.db.as_deref())?;
56    crate::storage::connection::ensure_db_ready(&paths)?;
57    // Resolve sub-queries once (shared by embedding precompute + fan-out).
58    let sub_query_plan = resolve_sub_queries(&args)?;
59    let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
60    let (sub_embeddings, vec_degraded, degraded_reason_code) =
61        compute_sub_embeddings(&paths, &sub_query_texts, backends);
62    // Decided BEFORE the runtime is built and the whole fan-out is spent:
63    // without this the search answered FTS-only with exit 0 and the flag was a
64    // placebo. Failing here also avoids paying for the network calls of a search
65    // the operator already declared they do not want in degraded mode.
66    if let Some(err) = crate::query_embedding::degradation_failure(
67        fail_on_degraded,
68        vec_degraded,
69        degraded_reason_code,
70    ) {
71        return Err(err);
72    }
73
74    // GAP-SG-141 B2: no explicit worker count. Tokio already defaults to the
75    // number of cores available to the process, and the fan-out below scales
76    // with the sub-query plan, so a fixed width could only under-serve it. The
77    // embed runtime keeps its own knob because it sizes a DIFFERENT reactor.
78    let rt = tokio::runtime::Builder::new_multi_thread()
79        .enable_all()
80        .build()
81        .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
82    rt.block_on(run_async(
83        args,
84        backends,
85        sub_query_plan,
86        sub_embeddings,
87        vec_degraded,
88    ))
89}
90
91/// Main async logic: decompose, fan-out, assemble, emit JSON.
92///
93/// `sub_embeddings` and `vec_degraded` are computed synchronously in
94/// [`run`] before the tokio runtime is built (GAP-001, v1.1.04) to avoid
95/// a nested-runtime panic on the OpenRouter embedding path.
96/// `sub_queries` is also resolved in [`run`] so embedding precompute and
97/// fan-out share one plan (v1.1.05).
98async fn run_async(
99    args: DeepResearchArgs,
100    _backends: crate::cli::BackendChoice,
101    sub_queries: Vec<SubQuery>,
102    sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
103    vec_degraded: bool,
104) -> Result<(), AppError> {
105    let crate::cli::BackendChoice {
106        llm: _llm_backend,
107        embedding: _embedding_backend,
108    } = _backends;
109    let start = std::time::Instant::now();
110
111    if args.query.trim().is_empty() {
112        return Err(AppError::Validation(crate::i18n::validation::empty_query()));
113    }
114
115    if args.max_cost_usd.is_some() {
116        // `--mode` accepts only `none` since v1.2.0, so the guard on the mode
117        // was dead and the message pointed at two backends the product removed.
118        // The flag stays accepted and stays inert; saying so is the whole point.
119        tracing::warn!(
120            target: "deep_research",
121            "--max-cost-usd is inert: deep-research has no LLM mode, so nothing is billed"
122        );
123    }
124
125    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
126    let paths = AppPaths::resolve(args.db.as_deref())?;
127    crate::storage::connection::ensure_db_ready(&paths)?;
128
129    // Phase 1: sub-queries already resolved in `run` (heuristic / manual / aspects).
130    let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
131
132    // GAP-001 (v1.1.04): sub-query embeddings were already resolved in
133    // `run` before the tokio runtime was built. Using them here keeps the
134    // OpenRouter REST path out of the worker threads (nested-runtime panic).
135    // `vec_degraded` reflects per-sub-query FTS5 fallback (GAP-DEEPRESEARCH-001).
136    if vec_degraded {
137        tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
138    }
139
140    // Phase 2: Fan-out — parallel sub-query execution.
141    // Bounded concurrency: permits = min(cpus, free_ram / ram_per_task), capped
142    // at 8 and never above the number of sub-queries. `--max-concurrency`
143    // overrides the computed value, because a fixed number that ignores the host
144    // breaks on the next machine.
145    //
146    // WORKLOAD: I/O-bound. Each sub-query is one embedding round trip plus SQLite
147    // reads; the CPU term is a proxy for reasonable fan-out, not for saturation.
148    //
149    // ram_per_task uses `llm.worker_rss_mb` (default 350 MB), the same measured
150    // per-worker resident figure the LLM slot accounting already uses on this
151    // host, and only half of the free RAM is offered so a concurrent enrich
152    // drain is not squeezed out. The memory term was missing entirely before
153    // v1.2.8: the count was `cpus.min(8)` on any host, which on a loaded machine
154    // sized fan-out from a number that says nothing about what is left.
155    let cpu_count = std::thread::available_parallelism()
156        .map(|n| n.get())
157        .unwrap_or(4);
158    let ram_per_task_mb = crate::constants::llm_worker_rss_mb().max(1);
159    // `available_memory_mb` returns 0 when the reading is unavailable, which
160    // would floor the permits at 1 on a host that simply cannot report. Falling
161    // back to the CPU term keeps the previous behaviour in that case rather than
162    // silently serialising the fan-out.
163    let available_mb = crate::memory_guard::available_memory_mb();
164    let ram_permits = if available_mb == 0 {
165        cpu_count
166    } else {
167        usize::try_from(available_mb / 2 / ram_per_task_mb).unwrap_or(cpu_count)
168    };
169    let permits = args
170        .max_concurrency
171        .unwrap_or_else(|| cpu_count.min(ram_permits).min(8))
172        .min(sub_queries.len())
173        .max(1);
174    let semaphore = Arc::new(Semaphore::new(permits));
175    let timeout_dur = std::time::Duration::from_secs(args.timeout);
176
177    let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
178
179    for (idx, sq_text) in sub_query_texts.iter().enumerate() {
180        let sem = Arc::clone(&semaphore);
181        // GAP-DEEPRESEARCH-001 FIX: pass Optional embedding (None = FTS5-only).
182        let emb = sub_embeddings[idx].clone();
183        let ns = namespace.clone();
184        let db_path = paths.db.clone();
185        let query_text = sq_text.clone();
186        let knobs = RetrievalKnobs {
187            k: args.k,
188            max_hops: args.max_hops,
189            min_weight: args.min_weight,
190            rrf_k: args.rrf_k,
191            graph_decay: args.graph_decay,
192            graph_min_score: args.graph_min_score,
193            max_neighbors_per_hop: args.max_neighbors_per_hop,
194        };
195
196        join_set.spawn(async move {
197            let _permit = sem
198                .acquire_owned()
199                .await
200                .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
201
202            // Dereference the Arc to obtain a &[f32] slice for the sync function.
203            let result = tokio::time::timeout(timeout_dur, async move {
204                execute_sub_query(
205                    idx,
206                    &query_text,
207                    emb.as_ref().map(|v| v.as_slice()),
208                    &ns,
209                    &db_path,
210                    knobs,
211                )
212            })
213            .await;
214
215            match result {
216                Ok(inner) => inner.map_err(|e| (idx, e)),
217                Err(_) => Err((idx, "timeout".to_string())),
218            }
219        });
220    }
221
222    // Collect results incrementally.
223    let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
224    let mut failed_count = 0usize;
225    let mut timed_out_count = 0usize;
226
227    while let Some(join_result) = join_set.join_next().await {
228        match join_result {
229            Ok(Ok(sqr)) => sub_query_results.push(sqr),
230            Ok(Err((_idx, reason))) => {
231                if reason == "timeout" {
232                    timed_out_count += 1;
233                } else {
234                    failed_count += 1;
235                }
236                tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
237            }
238            Err(join_err) => {
239                failed_count += 1;
240                if join_err.is_panic() {
241                    tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
242                } else {
243                    tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
244                }
245            }
246        }
247    }
248
249    // Phase 3: Evidence assembly — merge, dedup, rank.
250    // Aggregate hits: memory_id -> (best_score, source, snippet, body, hop_distance, sub_query_ids)
251    let mut merged: crate::hash::AHashMap<i64, MergedHit> =
252        crate::hash::AHashMap::with_capacity_and_hasher(
253            sub_query_results.len() * args.k,
254            Default::default(),
255        );
256
257    for sqr in &sub_query_results {
258        for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
259            let entry = merged.entry(*mem_id).or_insert_with(|| {
260                (
261                    *score,
262                    source.clone(),
263                    snippet.clone(),
264                    body.clone(),
265                    *hop,
266                    Vec::new(),
267                )
268            });
269            // Keep best score.
270            if *score > entry.0 {
271                entry.0 = *score;
272                entry.1 = source.clone();
273                entry.2 = snippet.clone();
274                entry.3 = body.clone();
275                entry.4 = *hop;
276            }
277            if !entry.5.contains(&sqr.sub_query_id) {
278                entry.5.push(sqr.sub_query_id);
279            }
280        }
281    }
282
283    // Resolve memory names for merged results.
284    let conn = open_ro(&paths.db)?;
285    let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
286
287    // Sort by score descending.
288    let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
289    ranked.sort_by(|a, b| {
290        b.1 .0
291            .partial_cmp(&a.1 .0)
292            .unwrap_or(std::cmp::Ordering::Equal)
293    });
294    // GAP-SG-201: reported, never refused, and declared at the exact line that
295    // applies the ceiling. `--max-results` bounds a fused ranking rather than
296    // paging a countable table, so the top-k IS the answer the caller asked for;
297    // there is no universe to compare it against, which is why `universe_total`
298    // is `None` and no refusal can follow. Sixth and last of the read commands
299    // to declare its ceiling — until now `deep-research` was the one whose
300    // narrowness stayed invisible.
301    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
302        applied: args.max_results,
303        offset: 0,
304        source: crate::agent_surface::universe::CeilingSource::Flag,
305        kind: crate::agent_surface::universe::CeilingKind::TopK,
306        universe_total: None,
307    });
308    ranked.truncate(args.max_results);
309
310    for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
311        let name = match memories::read_full(&conn, mem_id)? {
312            Some(row) => row.name,
313            None => continue,
314        };
315        results.push(DeepResult {
316            name,
317            score,
318            source,
319            sub_query_ids: sq_ids,
320            snippet,
321            body: if args.with_bodies { Some(body) } else { None },
322            hop_distance: hop,
323        });
324    }
325
326    // GAP-09/10 FIX: Collect evidence chains from reconstructed BFS paths.
327    // The old code appended flat node pairs from a global SELECT; now each
328    // sub-query returns directed EvidenceChain structs (from, to, path).
329    let completed_count = sub_query_results.len();
330    let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
331    let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
332
333    for sqr in sub_query_results {
334        for chain in sqr.chains {
335            // Deduplicate chains by (from, to) pair.
336            let key = format!("{}->{}", chain.from, chain.to);
337            if seen_chain_keys.insert(key) {
338                evidence_chains.push(chain);
339            }
340        }
341    }
342
343    // Sort evidence chains by total_weight descending, discard single-hop trivial chains.
344    evidence_chains.retain(|c| c.depth >= 2);
345    evidence_chains.sort_by(|a, b| {
346        b.total_weight
347            .partial_cmp(&a.total_weight)
348            .unwrap_or(std::cmp::Ordering::Equal)
349    });
350
351    let unique_memories = results.len();
352    let evidence_count = evidence_chains.len();
353
354    // MEDIUM-01b: Build graph_context with entities and relationships from result memories.
355    let graph_context = if !results.is_empty() {
356        let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
357        let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
358        let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
359        let mut seen_entity_ids: crate::hash::AHashSet<i64> =
360            crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
361
362        for name in &result_names {
363            if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
364                if seen_entity_ids.insert(eid) {
365                    let etype: String = conn
366                        .query_row(
367                            "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
368                            rusqlite::params![eid],
369                            |r| r.get(0),
370                        )
371                        .unwrap_or_else(|_| "concept".to_string());
372                    let degree: u32 = conn
373                        .query_row(
374                            "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
375                            rusqlite::params![eid],
376                            |r| r.get(0),
377                        )
378                        .unwrap_or(0);
379                    ctx_entities.push(GraphContextEntity {
380                        name: name.to_string(),
381                        entity_type: etype,
382                        degree,
383                    });
384                }
385            }
386        }
387
388        let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
389        if entity_ids.len() >= 2 {
390            let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
391            let sql = format!(
392                "SELECT s.name, t.name, r.relation, r.weight \
393                 FROM relationships r \
394                 JOIN entities s ON s.id = r.source_id \
395                 JOIN entities t ON t.id = r.target_id \
396                 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
397                 LIMIT ?"
398            );
399            if let Ok(mut stmt) = conn.prepare(&sql) {
400                let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
401                    Vec::with_capacity(entity_ids.len() * 2 + 1);
402                for id in &entity_ids {
403                    params.push(Box::new(*id));
404                }
405                for id in &entity_ids {
406                    params.push(Box::new(*id));
407                }
408                params.push(Box::new(
409                    i64::try_from(crate::constants::K_DEEP_RESEARCH_GRAPH_EDGES_LIMIT)
410                        .unwrap_or(i64::MAX),
411                ));
412                let param_refs: Vec<&dyn rusqlite::types::ToSql> =
413                    params.iter().map(|p| p.as_ref()).collect();
414                if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
415                    Ok((
416                        r.get::<_, String>(0)?,
417                        r.get::<_, String>(1)?,
418                        r.get::<_, String>(2)?,
419                        r.get::<_, f64>(3)?,
420                    ))
421                }) {
422                    for row in rows.flatten() {
423                        ctx_rels.push(GraphContextRel {
424                            from: row.0,
425                            to: row.1,
426                            relation: row.2,
427                            weight: row.3,
428                        });
429                    }
430                }
431            }
432        }
433
434        if ctx_entities.is_empty() {
435            None
436        } else {
437            Some(GraphContext {
438                entities: ctx_entities,
439                relationships: ctx_rels,
440            })
441        }
442    } else {
443        None
444    };
445
446    tracing::debug!(target: "deep_research",
447        total_results = results.len(),
448        total_chains = evidence_chains.len(),
449        "assembly complete"
450    );
451
452    // Phase 4: JSON output (stdout and/or atomic --output).
453    let response = DeepResearchResponse {
454        query: args.query,
455        sub_queries,
456        results,
457        evidence_chains,
458        graph_context,
459        stats: ResearchStats {
460            sub_queries_total: sub_query_texts.len(),
461            sub_queries_completed: completed_count,
462            sub_queries_failed: failed_count,
463            sub_queries_timed_out: timed_out_count,
464            unique_memories_found: unique_memories,
465            evidence_chains_found: evidence_count,
466            elapsed_ms: start.elapsed().as_millis() as u64,
467            vec_degraded,
468        },
469    };
470
471    if let Some(path) = args.output.as_ref() {
472        // v1.1.05 Bug 2: atomic write avoids truncated envelopes under SIGTERM /
473        // shell redirect races. Full envelope goes to the file; stdout gets a
474        // small confirmation so pipelines can still check exit 0 + path.
475        // v1.1.8 GAP-CLI-DR-01..03: short `-o` is registered; fail-fast if the
476        // path was requested and the final file is missing or empty.
477        crate::atomic_io::write_json_atomic(path, &response)?;
478        if !path.exists() {
479            return Err(AppError::Validation(
480                crate::i18n::validation::deep_research_output_missing(&path.display().to_string()),
481            ));
482        }
483        let meta = std::fs::metadata(path).map_err(AppError::Io)?;
484        if meta.len() == 0 {
485            return Err(AppError::Validation(
486                crate::i18n::validation::deep_research_output_empty(&path.display().to_string()),
487            ));
488        }
489        let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
490        let digest = blake3::hash(&file_bytes).to_hex().to_string();
491        #[derive(Serialize)]
492        struct WrittenAck {
493            written: String,
494            bytes: u64,
495            blake3: String,
496            sub_queries_total: usize,
497            unique_memories_found: usize,
498            elapsed_ms: u64,
499        }
500        output::emit_json(&WrittenAck {
501            written: path.display().to_string(),
502            bytes: meta.len(),
503            blake3: digest,
504            sub_queries_total: response.stats.sub_queries_total,
505            unique_memories_found: response.stats.unique_memories_found,
506            elapsed_ms: response.stats.elapsed_ms,
507        })?;
508    } else {
509        output::emit_json(&response)?;
510    }
511
512    Ok(())
513}
514
515#[cfg(test)]
516mod tests;