use crate::errors::AppError;
use crate::output::{self, RecallItem};
use crate::paths::AppPaths;
use crate::storage::connection::open_ro;
mod args;
mod envelope;
mod fusion;
mod graph_expansion;
mod retrieval;
pub use args::HybridSearchArgs;
pub use envelope::{HybridSearchItem, HybridSearchResponse, Weights};
#[tracing::instrument(skip_all, level = "debug", name = "hybrid_search")]
pub fn run(
args: HybridSearchArgs,
backends: crate::cli::BackendChoice,
fail_on_degraded: bool,
) -> Result<(), AppError> {
let start = std::time::Instant::now();
let _ = args.format;
tracing::debug!(target: "hybrid_search", query = %args.query, k = args.k, "fusing results");
crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
applied: args.k,
offset: 0,
source: crate::agent_surface::universe::CeilingSource::Flag,
kind: crate::agent_surface::universe::CeilingKind::TopK,
universe_total: None,
});
args.validate_graph_flags()?;
let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
let paths = AppPaths::resolve(args.db.as_deref())?;
crate::storage::connection::ensure_db_ready(&paths)?;
output::emit_progress_i18n(
"Computing query embedding...",
"Calculando embedding da consulta...",
);
let conn = open_ro(&paths.db)?;
let resolved = retrieval::resolve_query_embedding(&args, &paths.models, backends);
if let Some(err) = crate::query_embedding::degradation_failure(
fail_on_degraded,
resolved.degraded,
resolved.reason_code,
) {
return Err(err);
}
let crate::query_embedding::QueryEmbedding {
embedding,
degraded: vec_degraded,
error: vec_error,
backend_invoked,
reason_code: vec_degraded_code,
} = resolved;
let memory_type_str = args.r#type.map(|t| t.as_str());
let vec_results = retrieval::vector_candidates(
&conn,
embedding.as_ref(),
std::slice::from_ref(&namespace),
memory_type_str,
args.k,
)?;
let (fts_results, fts_degraded, fts_error, fts_auto_rebuilt) =
retrieval::fts_candidates(&conn, &args, &namespace, memory_type_str);
let results = fusion::fuse_candidates(&conn, &args, &vec_results, &fts_results)?;
let graph_matches: Vec<RecallItem> =
graph_expansion::expand(&conn, &args, embedding.as_ref(), &namespace, &results)?;
output::emit_json(&HybridSearchResponse {
query: args.query,
k: args.k,
rrf_k: args.rrf_k,
weights: Weights {
vec: args.weight_vec,
fts: args.weight_fts,
},
results,
graph_matches,
max_graph_results: crate::constants::hybrid_search_max_graph_results(
args.max_graph_results,
),
fts_degraded,
fts_error,
fts_auto_rebuilt,
vec_degraded,
vec_error: vec_error.clone(),
warning: if vec_degraded {
Some(
"live query embedding unavailable; results are FTS5 BM25 only (semantic relevance reduced)"
.to_string(),
)
} else {
None
},
backend_invoked,
vec_degraded_reason: if vec_degraded { vec_error } else { None },
vec_degraded_code: if vec_degraded {
vec_degraded_code
} else {
None
},
elapsed_ms: start.elapsed().as_millis() as u64,
})?;
Ok(())
}
#[cfg(test)]
mod tests;