Skip to main content

sqlite_graphrag/commands/hybrid_search/
mod.rs

1//! Handler for the `hybrid-search` CLI subcommand.
2//!
3//! The pipeline runs in stages, one per submodule: `args` parses and
4//! validates the flags, `retrieval` produces the vector and FTS5 candidate
5//! lists, `fusion` combines them via RRF, `graph_expansion` widens the
6//! answer through the entity graph, and `envelope` describes the JSON shape
7//! that goes back to the caller.
8
9use crate::errors::AppError;
10use crate::output::{self, RecallItem};
11use crate::paths::AppPaths;
12use crate::storage::connection::open_ro;
13
14mod args;
15mod envelope;
16mod fusion;
17mod graph_expansion;
18mod retrieval;
19
20pub use args::HybridSearchArgs;
21pub use envelope::{HybridSearchItem, HybridSearchResponse, Weights};
22
23/// Run.
24#[tracing::instrument(skip_all, level = "debug", name = "hybrid_search")]
25pub fn run(
26    args: HybridSearchArgs,
27    backends: crate::cli::BackendChoice,
28    fail_on_degraded: bool,
29) -> Result<(), AppError> {
30    let start = std::time::Instant::now();
31    let _ = args.format;
32    tracing::debug!(target: "hybrid_search", query = %args.query, k = args.k, "fusing results");
33    // GAP-SG-201: reported, never refused. Reciprocal-rank fusion returns the k
34    // best matches, so k defines the answer instead of truncating a universe.
35    crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
36        applied: args.k,
37        offset: 0,
38        source: crate::agent_surface::universe::CeilingSource::Flag,
39        kind: crate::agent_surface::universe::CeilingKind::TopK,
40        universe_total: None,
41    });
42
43    args.validate_graph_flags()?;
44
45    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
46    let paths = AppPaths::resolve(args.db.as_deref())?;
47    crate::storage::connection::ensure_db_ready(&paths)?;
48
49    output::emit_progress_i18n(
50        "Computing query embedding...",
51        "Calculando embedding da consulta...",
52    );
53    let conn = open_ro(&paths.db)?;
54    let resolved = retrieval::resolve_query_embedding(&args, &paths.models, backends);
55    // `--fail-on-degraded` decides BEFORE any query runs: without this the read
56    // answered FTS-only with exit 0 and the flag was a placebo.
57    // `degradation_failure` exempts `--fallback-fts-only`, which is degradation
58    // the operator ASKED for.
59    if let Some(err) = crate::query_embedding::degradation_failure(
60        fail_on_degraded,
61        resolved.degraded,
62        resolved.reason_code,
63    ) {
64        return Err(err);
65    }
66    let crate::query_embedding::QueryEmbedding {
67        embedding,
68        degraded: vec_degraded,
69        error: vec_error,
70        backend_invoked,
71        reason_code: vec_degraded_code,
72    } = resolved;
73
74    let memory_type_str = args.r#type.map(|t| t.as_str());
75
76    let vec_results = retrieval::vector_candidates(
77        &conn,
78        embedding.as_ref(),
79        std::slice::from_ref(&namespace),
80        memory_type_str,
81        args.k,
82    )?;
83
84    let (fts_results, fts_degraded, fts_error, fts_auto_rebuilt) =
85        retrieval::fts_candidates(&conn, &args, &namespace, memory_type_str);
86
87    let results = fusion::fuse_candidates(&conn, &args, &vec_results, &fts_results)?;
88
89    let graph_matches: Vec<RecallItem> =
90        graph_expansion::expand(&conn, &args, embedding.as_ref(), &namespace, &results)?;
91
92    output::emit_json(&HybridSearchResponse {
93        query: args.query,
94        k: args.k,
95        rrf_k: args.rrf_k,
96        weights: Weights {
97            vec: args.weight_vec,
98            fts: args.weight_fts,
99        },
100        results,
101        graph_matches,
102        max_graph_results: crate::constants::hybrid_search_max_graph_results(
103            args.max_graph_results,
104        ),
105        fts_degraded,
106        fts_error,
107        fts_auto_rebuilt,
108        vec_degraded,
109        vec_error: vec_error.clone(),
110        warning: if vec_degraded {
111            Some(
112                "live query embedding unavailable; results are FTS5 BM25 only (semantic relevance reduced)"
113                    .to_string(),
114            )
115        } else {
116            None
117        },
118        backend_invoked,
119        vec_degraded_reason: if vec_degraded { vec_error } else { None },
120        vec_degraded_code: if vec_degraded {
121            vec_degraded_code
122        } else {
123            None
124        },
125        elapsed_ms: start.elapsed().as_millis() as u64,
126    })?;
127
128    Ok(())
129}
130
131#[cfg(test)]
132mod tests;