use semtree_store::Hit;
use crate::{LexicalIndex, RagError, SearchEngine};
const RRF_K: f64 = 60.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMode {
Hybrid,
Semantic,
Lexical,
}
impl SearchMode {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"hybrid" => Some(Self::Hybrid),
"semantic" | "vector" => Some(Self::Semantic),
"lexical" | "keyword" | "bm25" => Some(Self::Lexical),
_ => None,
}
}
}
pub struct HybridSearcher {
engine: SearchEngine,
lexical: LexicalIndex,
}
impl HybridSearcher {
pub fn new(engine: SearchEngine, lexical: LexicalIndex) -> Self {
Self { engine, lexical }
}
pub async fn search(
&self,
query: &str,
limit: usize,
mode: SearchMode,
) -> Result<Vec<Hit>, RagError> {
match mode {
SearchMode::Semantic => self.engine.search(query, limit).await,
SearchMode::Lexical => Ok(self
.lexical
.search(query, limit)
.into_iter()
.map(|(id, score)| Hit { id, score })
.collect()),
SearchMode::Hybrid => {
let vector = self.engine.search(query, limit).await?;
let lexical = self.lexical.search(query, limit);
Ok(fuse_rrf(&vector, &lexical, limit))
}
}
}
}
fn fuse_rrf(vector: &[Hit], lexical: &[(String, f32)], limit: usize) -> Vec<Hit> {
use std::collections::HashMap;
let mut fused: HashMap<String, f64> = HashMap::new();
for (rank, hit) in vector.iter().enumerate() {
*fused.entry(hit.id.clone()).or_insert(0.0) += 1.0 / (RRF_K + (rank + 1) as f64);
}
for (rank, (id, _)) in lexical.iter().enumerate() {
*fused.entry(id.clone()).or_insert(0.0) += 1.0 / (RRF_K + (rank + 1) as f64);
}
let mut ranked: Vec<(String, f64)> = fused.into_iter().collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
ranked.truncate(limit);
ranked
.into_iter()
.map(|(id, score)| Hit {
id,
score: score as f32,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn hit(id: &str) -> Hit {
Hit {
id: id.to_string(),
score: 0.0,
}
}
#[test]
fn rrf_rewards_agreement() {
let vector = vec![hit("a"), hit("b"), hit("c")];
let lexical = vec![("d".to_string(), 9.0), ("b".to_string(), 8.0)];
let fused = fuse_rrf(&vector, &lexical, 10);
assert_eq!(fused.first().map(|h| h.id.as_str()), Some("b"));
}
#[test]
fn rrf_keeps_unique_hits_from_each_ranker() {
let vector = vec![hit("a")];
let lexical = vec![("b".to_string(), 1.0)];
let fused = fuse_rrf(&vector, &lexical, 10);
let ids: Vec<&str> = fused.iter().map(|h| h.id.as_str()).collect();
assert!(ids.contains(&"a"));
assert!(ids.contains(&"b"));
}
#[test]
fn mode_parsing() {
assert_eq!(SearchMode::parse("hybrid"), Some(SearchMode::Hybrid));
assert_eq!(SearchMode::parse("SEMANTIC"), Some(SearchMode::Semantic));
assert_eq!(SearchMode::parse("bm25"), Some(SearchMode::Lexical));
assert_eq!(SearchMode::parse("nope"), None);
}
}