pub mod crossencoder;
pub mod ripgrep;
use std::collections::HashMap;
use crate::adapters::lance::{LanceStore, SearchHit};
use crate::domain::common::Result;
pub use crossencoder::{
Crossencoder, DEFAULT_CROSSENCODER_MODEL, FastembedCrossencoder, Noop as NoopCrossencoder,
SUPPORTED_CROSSENCODER_MODELS, canonical_crossencoder_model,
};
pub use ripgrep::RipgrepHit;
pub const RIPGREP_WEIGHT: f32 = 1.0;
const RRF_K: f32 = 60.0;
pub async fn hybrid_search(
store: &LanceStore,
corpus: &str,
query: &str,
query_vec: &[f32],
limit: usize,
) -> Result<Vec<SearchHit>> {
store.hybrid_search(corpus, query, query_vec, limit).await
}
pub async fn hybrid_with_ripgrep(
store: &LanceStore,
corpus: &str,
corpus_paths: &[String],
query: &str,
query_vec: &[f32],
limit: usize,
) -> Result<Vec<SearchHit>> {
let hybrid_fut = hybrid_search(store, corpus, query, query_vec, limit);
let rg_fut = ripgrep::run(corpus_paths, query, limit);
let (hybrid_res, rg_res) = tokio::join!(hybrid_fut, rg_fut);
let mut hits = hybrid_res?;
let rg_hits = match rg_res {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "hallouminate::search", err = %e, "ripgrep pass failed; returning hybrid-only results");
Vec::new()
}
};
apply_rg_boost(&mut hits, &rg_hits);
Ok(hits)
}
pub async fn fts_with_ripgrep(
store: &LanceStore,
corpus: &str,
corpus_paths: &[String],
query: &str,
limit: usize,
) -> Result<Vec<SearchHit>> {
let fts_fut = store.fts_search(corpus, query, limit);
let rg_fut = ripgrep::run(corpus_paths, query, limit);
let (fts_res, rg_res) = tokio::join!(fts_fut, rg_fut);
let mut hits = fts_res?;
let rg_hits = match rg_res {
Ok(v) => v,
Err(e) => {
tracing::warn!(target: "hallouminate::search", err = %e, "ripgrep pass failed; returning fts-only results");
Vec::new()
}
};
apply_rg_boost(&mut hits, &rg_hits);
Ok(hits)
}
fn apply_rg_boost(hits: &mut [SearchHit], rg_hits: &[RipgrepHit]) {
if rg_hits.is_empty() || hits.is_empty() {
return;
}
let mut first_rank: HashMap<&str, usize> = HashMap::new();
for (rank, h) in rg_hits.iter().enumerate() {
first_rank.entry(h.file_ref.as_str()).or_insert(rank);
}
for hit in hits.iter_mut() {
if let Some(&rank) = first_rank.get(hit.file_ref.as_str()) {
hit.score += RIPGREP_WEIGHT / (rank as f32 + RRF_K);
}
}
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.chunk_id.cmp(&b.chunk_id))
});
}
#[cfg(test)]
mod tests {
use super::*;
fn hit(file_ref: &str, chunk_ord: usize, score: f32) -> SearchHit {
SearchHit {
chunk_id: format!("{file_ref}#{chunk_ord}"),
file_ref: file_ref.into(),
heading_path: vec![],
line_start: 1,
line_end: 2,
text: String::new(),
summary: String::new(),
keywords: vec![],
score,
mtime_ms: 0,
claim_marks: vec![],
z_score: None,
}
}
fn rg(file_ref: &str, line: u64) -> RipgrepHit {
RipgrepHit {
file_ref: file_ref.into(),
line,
snippet: String::new(),
}
}
#[test]
fn rg_boost_promotes_matched_file() {
let mut hits = vec![hit("/a.md", 0, 0.10), hit("/b.md", 0, 0.20)];
let rg_hits = vec![rg("/a.md", 5)];
apply_rg_boost(&mut hits, &rg_hits);
let a = hits.iter().find(|h| h.file_ref == "/a.md").unwrap();
let b = hits.iter().find(|h| h.file_ref == "/b.md").unwrap();
assert!((a.score - (0.10 + 1.0 / 60.0)).abs() < 1e-6);
assert!((b.score - 0.20).abs() < 1e-6);
}
#[test]
fn rg_boost_uses_first_occurrence_rank_when_file_matched_multiple_times() {
let mut hits = vec![hit("/a.md", 0, 0.0)];
let rg_hits = vec![
rg("/z.md", 1),
rg("/y.md", 1),
rg("/x.md", 1),
rg("/a.md", 10),
rg("/w.md", 1),
rg("/v.md", 1),
rg("/u.md", 1),
rg("/a.md", 20),
];
apply_rg_boost(&mut hits, &rg_hits);
let a = &hits[0];
let expected = 1.0 / (3.0 + 60.0);
assert!(
(a.score - expected).abs() < 1e-6,
"expected boost from first /a.md occurrence (rank 3); got {}",
a.score
);
}
#[test]
fn rg_boost_resorts_descending() {
let mut hits = vec![hit("/loser.md", 0, 0.50), hit("/winner.md", 0, 0.0)];
let rg_hits = vec![rg("/winner.md", 1)];
apply_rg_boost(&mut hits, &rg_hits);
assert!(hits[0].score >= hits[1].score, "must be sorted descending");
}
#[test]
fn rg_boost_noop_when_no_rg_hits() {
let mut hits = vec![hit("/a.md", 0, 0.30), hit("/b.md", 0, 0.10)];
let before: Vec<f32> = hits.iter().map(|h| h.score).collect();
apply_rg_boost(&mut hits, &[]);
let after: Vec<f32> = hits.iter().map(|h| h.score).collect();
assert_eq!(before, after, "empty rg list must leave scores untouched");
}
#[test]
fn rg_boost_handles_rg_only_files_silently() {
let mut hits = vec![hit("/a.md", 0, 0.10)];
let rg_hits = vec![rg("/never_indexed.md", 1)];
apply_rg_boost(&mut hits, &rg_hits);
assert_eq!(hits.len(), 1, "no synthetic hit appears");
assert!((hits[0].score - 0.10).abs() < 1e-6, "no boost applied");
}
}