use super::*;
use crate::error::DbResult;
use serde::Serialize;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FusionMethod {
#[default]
Weighted,
Rrf,
}
impl FusionMethod {
pub fn parse(s: &str) -> Option<Self> {
match s {
"weighted" => Some(FusionMethod::Weighted),
"rrf" => Some(FusionMethod::Rrf),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct HybridSearchOptions {
pub vector_weight: f32,
pub text_weight: f32,
pub limit: usize,
pub fusion: FusionMethod,
}
impl Default for HybridSearchOptions {
fn default() -> Self {
Self {
vector_weight: 0.5,
text_weight: 0.5,
limit: 10,
fusion: FusionMethod::Weighted,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct HybridSearchResult {
pub doc_key: String,
pub score: f32,
pub vector_score: Option<f32>,
pub text_score: Option<f32>,
pub sources: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document: Option<Value>,
}
impl Collection {
pub fn hybrid_search(
&self,
vector_index: &str,
fulltext_field: &str,
query_vector: &[f32],
text_query: &str,
opts: &HybridSearchOptions,
) -> DbResult<Vec<HybridSearchResult>> {
let limit = opts.limit;
let vector_results = self.vector_search(vector_index, query_vector, limit * 3, None)?;
let fulltext_results = self
.fulltext_search(
text_query,
Some(vec![fulltext_field.to_string()]),
limit * 3,
)
.unwrap_or_default();
let mut vector_scores: HashMap<String, f32> = HashMap::new();
if !vector_results.is_empty() {
let max_vec = vector_results
.iter()
.map(|r| r.score)
.fold(f32::NEG_INFINITY, f32::max);
let min_vec = vector_results
.iter()
.map(|r| r.score)
.fold(f32::INFINITY, f32::min);
let range = max_vec - min_vec;
for result in &vector_results {
let normalized = if range > 0.0 {
(result.score - min_vec) / range
} else {
1.0
};
vector_scores.insert(result.doc_key.clone(), normalized);
}
}
let mut text_scores: HashMap<String, f32> = HashMap::new();
if !fulltext_results.is_empty() {
let max_text = fulltext_results
.iter()
.map(|r| r.score)
.fold(f64::NEG_INFINITY, f64::max);
let min_text = fulltext_results
.iter()
.map(|r| r.score)
.fold(f64::INFINITY, f64::min);
let range = max_text - min_text;
for result in &fulltext_results {
let normalized = if range > 0.0 {
((result.score - min_text) / range) as f32
} else {
1.0
};
text_scores.insert(result.doc_key.clone(), normalized);
}
}
let orig_vector_scores: HashMap<String, f32> = vector_results
.iter()
.map(|r| (r.doc_key.clone(), r.score))
.collect();
let orig_text_scores: HashMap<String, f32> = fulltext_results
.iter()
.map(|r| (r.doc_key.clone(), r.score as f32))
.collect();
#[allow(clippy::type_complexity)]
let mut combined_results: Vec<(
String,
f32,
Option<f32>,
Option<f32>,
Vec<String>,
)> = Vec::new();
match opts.fusion {
FusionMethod::Rrf => {
let k: f32 = 60.0;
let mut rrf_scores: HashMap<String, f32> = HashMap::new();
let mut doc_sources: HashMap<String, Vec<String>> = HashMap::new();
for (rank, result) in vector_results.iter().enumerate() {
let rrf_score = 1.0 / (k + rank as f32 + 1.0);
*rrf_scores.entry(result.doc_key.clone()).or_insert(0.0) += rrf_score;
doc_sources
.entry(result.doc_key.clone())
.or_default()
.push("vector".to_string());
}
for (rank, result) in fulltext_results.iter().enumerate() {
let rrf_score = 1.0 / (k + rank as f32 + 1.0);
*rrf_scores.entry(result.doc_key.clone()).or_insert(0.0) += rrf_score;
doc_sources
.entry(result.doc_key.clone())
.or_default()
.push("fulltext".to_string());
}
for (doc_key, score) in rrf_scores {
let sources = doc_sources.remove(&doc_key).unwrap_or_default();
let vec_score = orig_vector_scores.get(&doc_key).copied();
let txt_score = orig_text_scores.get(&doc_key).copied();
combined_results.push((doc_key, score, vec_score, txt_score, sources));
}
}
FusionMethod::Weighted => {
let mut all_doc_keys: HashSet<String> = HashSet::new();
all_doc_keys.extend(vector_scores.keys().cloned());
all_doc_keys.extend(text_scores.keys().cloned());
for doc_key in all_doc_keys {
let vec_score = vector_scores.get(&doc_key).copied();
let txt_score = text_scores.get(&doc_key).copied();
let mut sources = Vec::new();
let mut combined_score = 0.0;
if let Some(vs) = vec_score {
combined_score += vs * opts.vector_weight;
sources.push("vector".to_string());
}
if let Some(ts) = txt_score {
combined_score += ts * opts.text_weight;
sources.push("fulltext".to_string());
}
combined_results.push((
doc_key.clone(),
combined_score,
orig_vector_scores.get(&doc_key).copied(),
orig_text_scores.get(&doc_key).copied(),
sources,
));
}
}
}
combined_results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
combined_results.truncate(limit);
Ok(combined_results
.into_iter()
.map(
|(doc_key, score, vector_score, text_score, sources)| HybridSearchResult {
document: self.get(&doc_key).ok().map(|d| d.to_value()),
doc_key,
score,
vector_score,
text_score,
sources,
},
)
.collect())
}
}