mod ann;
mod bm25;
mod dense;
mod meta;
mod reranker;
mod tokenizer;
pub use ann::AnnIndex;
pub use bm25::{BM25Index, BM25SearchResult};
pub use dense::DenseRetriever;
pub use meta::{read_metadata, write_metadata, IndexMetadata};
pub use reranker::Reranker;
use anyhow::Result;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Serialize)]
pub struct SearchMeta {
pub mode: String,
pub reranker_active: bool,
pub dense_active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_reason: Option<String>,
pub total_candidates: usize,
pub result_count: usize,
pub search_time_ms: u64,
}
pub struct HybridSearch {
bm25: BM25Index,
reranker: Option<Reranker>,
dense: Option<DenseRetriever>,
ann_index: Option<AnnIndex>,
embeddings: Vec<(String, Vec<f32>)>,
chunk_lookup: HashMap<String, BM25SearchResult>,
}
#[derive(Debug, Clone, Default)]
pub struct SearchFilters {
pub path: Option<String>,
pub language: Option<String>,
pub chunk_type: Option<String>,
pub symbol: Option<String>,
}
impl SearchFilters {
pub fn is_active(&self) -> bool {
self.path.is_some()
|| self.language.is_some()
|| self.chunk_type.is_some()
|| self.symbol.is_some()
}
fn matches(&self, result: &BM25SearchResult) -> bool {
if let Some(path) = &self.path {
if !result.file_path.contains(path) {
return false;
}
}
if let Some(lang) = &self.language {
let lang = lang.to_lowercase();
if result.language.to_lowercase() != lang {
return false;
}
}
if let Some(chunk_type) = &self.chunk_type {
let chunk_type = chunk_type.to_lowercase();
if result.chunk_type.to_lowercase() != chunk_type {
return false;
}
}
if let Some(symbol) = &self.symbol {
let symbol = symbol.to_lowercase();
let candidate = result.symbol_name.as_deref().unwrap_or("").to_lowercase();
if !candidate.contains(&symbol) {
return false;
}
}
true
}
}
impl HybridSearch {
pub fn new(
index_path: &Path,
reranker_model_path: Option<&Path>,
dense_model_path: Option<&Path>,
ann_index: Option<AnnIndex>,
embeddings: Vec<(String, Vec<f32>)>,
chunk_lookup: HashMap<String, BM25SearchResult>,
) -> Result<Self> {
let bm25 = BM25Index::new(index_path)?;
let reranker = if let Some(path) = reranker_model_path {
Some(Reranker::new(path)?)
} else {
None
};
let dense = if let Some(path) = dense_model_path {
Some(DenseRetriever::new(path)?)
} else {
None
};
Ok(Self {
bm25,
reranker,
dense,
ann_index,
embeddings,
chunk_lookup,
})
}
pub fn search(&mut self, query: &str, limit: usize) -> Result<(Vec<SearchResult>, SearchMeta)> {
self.search_with_filters(query, limit, None)
}
pub fn search_with_filters(
&mut self,
query: &str,
limit: usize,
filters: Option<&SearchFilters>,
) -> Result<(Vec<SearchResult>, SearchMeta)> {
let start = Instant::now();
let mut candidate_limit = if self.reranker.is_some() {
(limit * 10).max(100) } else {
limit
};
if filters.map(|f| f.is_active()).unwrap_or(false) {
candidate_limit = (candidate_limit * 5).min(5000);
}
let mut bm25_results = self.bm25.search(query, candidate_limit)?;
self.enrich_from_lookup(&mut bm25_results);
if let Some(filters) = filters {
if filters.is_active() {
bm25_results.retain(|r| filters.matches(r));
}
}
let dense_active = self.dense.is_some();
if let Some(dense) = &mut self.dense {
let dense_limit = candidate_limit.max(limit * 5);
let dense_results = if let Some(ann_index) = &self.ann_index {
match dense.embed_query(query) {
Ok(query_embedding) => ann_index.search(&query_embedding, dense_limit),
Err(_) => Vec::new(),
}
} else {
dense
.search_embeddings(query, &self.embeddings, dense_limit)
.unwrap_or_default()
};
for dense_hit in dense_results {
if bm25_results
.iter()
.any(|r| r.chunk_id == dense_hit.chunk_id)
{
continue;
}
if let Some(base) = self.chunk_lookup.get(&dense_hit.chunk_id) {
let mut candidate = base.clone();
candidate.score = dense_hit.score;
if filters.map(|f| f.matches(&candidate)).unwrap_or(true) {
bm25_results.push(candidate);
}
}
}
}
bm25_results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
let total_candidates = bm25_results.len();
let reranker_threshold = limit * 2;
let (results, reranker_active, fallback_reason) = if let Some(reranker) = &mut self.reranker
{
if bm25_results.len() > reranker_threshold {
let ranked = reranker.rerank(query, bm25_results, limit)?;
(ranked, true, None)
} else {
let n = bm25_results.len();
let reason = format!(
"not enough candidates for reranking: {} < {}",
n,
reranker_threshold + 1
);
let converted: Vec<SearchResult> = bm25_results
.into_iter()
.take(limit)
.map(|r| r.into())
.collect();
(converted, false, Some(reason))
}
} else {
let converted: Vec<SearchResult> = bm25_results
.into_iter()
.take(limit)
.map(|r| r.into())
.collect();
(converted, false, None)
};
let search_time_ms = start.elapsed().as_millis() as u64;
let result_count = results.len();
let mode = match (dense_active, reranker_active) {
(false, false) => "bm25".to_string(),
(false, true) => "bm25+rerank".to_string(),
(true, false) => "hybrid".to_string(),
(true, true) => "hybrid+rerank".to_string(),
};
let meta = SearchMeta {
mode,
reranker_active,
dense_active,
fallback_reason,
total_candidates,
result_count,
search_time_ms,
};
Ok((results, meta))
}
#[allow(dead_code)]
pub fn search_bm25(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
let mut results = self.bm25.search(query, limit)?;
self.enrich_from_lookup(&mut results);
Ok(results.into_iter().map(|r| r.into()).collect())
}
fn enrich_from_lookup(&self, results: &mut Vec<BM25SearchResult>) {
for result in results.iter_mut() {
if let Some(entry) = self.chunk_lookup.get(&result.chunk_id) {
result.parent_symbol = entry.parent_symbol.clone();
result.signature = entry.signature.clone();
result.doc_comment = entry.doc_comment.clone();
result.module_path = entry.module_path.clone();
}
}
}
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub chunk_id: String,
pub file_path: String,
pub content: String,
pub start_line: usize,
pub end_line: usize,
pub chunk_type: String,
pub language: String,
pub symbol_name: Option<String>,
pub score: f32,
pub parent_symbol: Option<String>,
pub signature: Option<String>,
pub doc_comment: Option<String>,
pub module_path: Option<String>,
}
impl From<BM25SearchResult> for SearchResult {
fn from(r: BM25SearchResult) -> Self {
Self {
chunk_id: r.chunk_id,
file_path: r.file_path,
content: r.content,
start_line: r.start_line,
end_line: r.end_line,
chunk_type: r.chunk_type,
language: r.language,
symbol_name: r.symbol_name,
score: r.score,
parent_symbol: r.parent_symbol,
signature: r.signature,
doc_comment: r.doc_comment,
module_path: r.module_path,
}
}
}