1use std::collections::HashMap;
23
24use crate::spec::CoreOptions;
25use crate::table::{RowRange, Table};
26use crate::vector_search::SearchResult;
27
28const RRF_K: f32 = 60.0;
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum HybridSearchRanker {
32 Rrf,
33 WeightedScore,
34 Mrr,
35}
36
37impl HybridSearchRanker {
38 pub const RRF: &'static str = "rrf";
39 pub const WEIGHTED_SCORE: &'static str = "weighted_score";
40 pub const MRR: &'static str = "mrr";
41
42 pub fn parse(ranker: &str) -> crate::Result<Self> {
43 match ranker.trim().to_ascii_lowercase().as_str() {
44 "" | Self::RRF => Ok(Self::Rrf),
45 Self::WEIGHTED_SCORE => Ok(Self::WeightedScore),
46 Self::MRR => Ok(Self::Mrr),
47 _ => Err(crate::Error::ConfigInvalid {
48 message: format!("Unsupported hybrid ranker: {ranker}"),
49 }),
50 }
51 }
52
53 pub fn as_str(self) -> &'static str {
54 match self {
55 Self::Rrf => Self::RRF,
56 Self::WeightedScore => Self::WEIGHTED_SCORE,
57 Self::Mrr => Self::MRR,
58 }
59 }
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub enum HybridSearchRouteKind {
64 Vector,
65 FullText,
66}
67
68#[derive(Clone, Debug)]
69pub struct HybridSearchRoute {
70 kind: HybridSearchRouteKind,
71 field_name: String,
72 vector: Option<Vec<f32>>,
73 full_text_query: Option<String>,
74 limit: usize,
75 weight: f32,
76 options: HashMap<String, String>,
77}
78
79impl HybridSearchRoute {
80 pub fn vector(
81 field_name: impl Into<String>,
82 vector: Vec<f32>,
83 limit: usize,
84 weight: f32,
85 options: HashMap<String, String>,
86 ) -> crate::Result<Self> {
87 let field_name = field_name.into();
88 Self::validate_common(&field_name, limit, weight)?;
89 if vector.is_empty() {
90 return Err(crate::Error::DataInvalid {
91 message: "Search vector cannot be empty".to_string(),
92 source: None,
93 });
94 }
95 Ok(Self {
96 kind: HybridSearchRouteKind::Vector,
97 field_name,
98 vector: Some(vector),
99 full_text_query: None,
100 limit,
101 weight,
102 options,
103 })
104 }
105
106 pub fn full_text(
107 field_name: impl Into<String>,
108 query: impl Into<String>,
109 limit: usize,
110 weight: f32,
111 options: HashMap<String, String>,
112 ) -> crate::Result<Self> {
113 if !options.is_empty() {
114 return Err(crate::Error::ConfigInvalid {
115 message: "Full-text hybrid route options are not supported yet".to_string(),
116 });
117 }
118
119 let field_name = field_name.into();
120 let query = query.into();
121 Self::validate_common(&field_name, limit, weight)?;
122 if query.is_empty() {
123 return Err(crate::Error::ConfigInvalid {
124 message: "Full-text route query cannot be empty".to_string(),
125 });
126 }
127
128 Ok(Self {
129 kind: HybridSearchRouteKind::FullText,
130 field_name,
131 vector: None,
132 full_text_query: Some(query),
133 limit,
134 weight,
135 options,
136 })
137 }
138
139 fn validate_common(field_name: &str, limit: usize, weight: f32) -> crate::Result<()> {
140 if field_name.is_empty() {
141 return Err(crate::Error::DataInvalid {
142 message: "Field name cannot be null or empty".to_string(),
143 source: None,
144 });
145 }
146 if limit == 0 {
147 return Err(crate::Error::ConfigInvalid {
148 message: "Limit must be positive".to_string(),
149 });
150 }
151 if !weight.is_finite() || weight <= 0.0 {
152 return Err(crate::Error::ConfigInvalid {
153 message: format!("Weight must be finite and positive, got: {weight}"),
154 });
155 }
156 Ok(())
157 }
158
159 pub fn kind(&self) -> HybridSearchRouteKind {
160 self.kind
161 }
162
163 pub fn field_name(&self) -> &str {
164 &self.field_name
165 }
166
167 pub fn vector_value(&self) -> Option<&[f32]> {
168 self.vector.as_deref()
169 }
170
171 pub fn full_text_query(&self) -> Option<&str> {
172 self.full_text_query.as_deref()
173 }
174
175 pub fn limit(&self) -> usize {
176 self.limit
177 }
178
179 pub fn weight(&self) -> f32 {
180 self.weight
181 }
182
183 pub fn options(&self) -> &HashMap<String, String> {
184 &self.options
185 }
186}
187
188pub struct HybridSearchBuilder<'a> {
189 table: &'a Table,
190 routes: Vec<HybridSearchRoute>,
191 limit: Option<usize>,
192 ranker: HybridSearchRanker,
193}
194
195impl<'a> HybridSearchBuilder<'a> {
196 pub(crate) fn new(table: &'a Table) -> Self {
197 Self {
198 table,
199 routes: Vec::new(),
200 limit: None,
201 ranker: HybridSearchRanker::Rrf,
202 }
203 }
204
205 pub fn add_route(&mut self, route: HybridSearchRoute) -> &mut Self {
206 self.routes.push(route);
207 self
208 }
209
210 pub fn add_vector_route(
211 &mut self,
212 field_name: &str,
213 vector: Vec<f32>,
214 limit: usize,
215 weight: f32,
216 options: HashMap<String, String>,
217 ) -> crate::Result<&mut Self> {
218 self.routes.push(HybridSearchRoute::vector(
219 field_name, vector, limit, weight, options,
220 )?);
221 Ok(self)
222 }
223
224 pub fn add_full_text_route(
225 &mut self,
226 field_name: &str,
227 query: &str,
228 limit: usize,
229 weight: f32,
230 options: HashMap<String, String>,
231 ) -> crate::Result<&mut Self> {
232 self.routes.push(HybridSearchRoute::full_text(
233 field_name, query, limit, weight, options,
234 )?);
235 Ok(self)
236 }
237
238 pub fn with_limit(&mut self, limit: usize) -> &mut Self {
239 self.limit = Some(limit);
240 self
241 }
242
243 pub fn with_ranker(&mut self, ranker: &str) -> crate::Result<&mut Self> {
244 self.ranker = HybridSearchRanker::parse(ranker)?;
245 Ok(self)
246 }
247
248 pub fn with_rrf_ranker(&mut self) -> &mut Self {
249 self.ranker = HybridSearchRanker::Rrf;
250 self
251 }
252
253 pub fn with_weighted_score_ranker(&mut self) -> &mut Self {
254 self.ranker = HybridSearchRanker::WeightedScore;
255 self
256 }
257
258 pub fn with_mrr_ranker(&mut self) -> &mut Self {
259 self.ranker = HybridSearchRanker::Mrr;
260 self
261 }
262
263 pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
264 self.execute_scored().await?.to_row_ranges()
265 }
266
267 pub async fn execute_scored(&self) -> crate::Result<SearchResult> {
268 CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
269 let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
270 message: "Limit must be set via with_limit()".to_string(),
271 })?;
272 if self.routes.is_empty() {
273 return Err(crate::Error::ConfigInvalid {
274 message: "Routes cannot be empty".to_string(),
275 });
276 }
277
278 let mut route_results = Vec::with_capacity(self.routes.len());
279 for route in &self.routes {
280 let result = match route.kind {
281 HybridSearchRouteKind::Vector => {
282 let mut builder = self.table.new_vector_search_builder();
283 builder
284 .with_vector_column(&route.field_name)
285 .with_query_vector(route.vector.clone().expect("validated vector route"))
286 .with_limit(route.limit)
287 .with_options(route.options.clone());
288 builder.execute_scored().await?
289 }
290 HybridSearchRouteKind::FullText => {
291 execute_full_text_route(self.table, route).await?
292 }
293 };
294 if !result.is_empty() {
295 route_results.push(WeightedRouteResult {
296 result,
297 weight: route.weight,
298 });
299 }
300 }
301
302 Ok(rank_results(self.ranker, &route_results, limit))
303 }
304}
305
306#[cfg(feature = "fulltext")]
307async fn execute_full_text_route(
308 table: &Table,
309 route: &HybridSearchRoute,
310) -> crate::Result<SearchResult> {
311 let mut builder = table.new_full_text_search_builder();
312 builder
313 .with_text_column(&route.field_name)
314 .with_query_text(
315 route
316 .full_text_query
317 .as_deref()
318 .expect("validated full-text route"),
319 )
320 .with_limit(route.limit);
321 let result = builder.execute_scored().await?;
322 Ok(SearchResult::new(result.row_ids, result.scores))
323}
324
325#[cfg(not(feature = "fulltext"))]
326async fn execute_full_text_route(
327 _table: &Table,
328 _route: &HybridSearchRoute,
329) -> crate::Result<SearchResult> {
330 Err(crate::Error::ConfigInvalid {
331 message: "Full-text hybrid routes require the fulltext feature".to_string(),
332 })
333}
334
335struct WeightedRouteResult {
336 result: SearchResult,
337 weight: f32,
338}
339
340fn rank_results(
341 ranker: HybridSearchRanker,
342 route_results: &[WeightedRouteResult],
343 limit: usize,
344) -> SearchResult {
345 match ranker {
346 HybridSearchRanker::Rrf => rrf(route_results, limit),
347 HybridSearchRanker::WeightedScore => weighted_score(route_results, limit),
348 HybridSearchRanker::Mrr => mrr(route_results, limit),
349 }
350}
351
352fn rrf(route_results: &[WeightedRouteResult], limit: usize) -> SearchResult {
353 let mut scores = HashMap::new();
354 for route_result in route_results {
355 for (rank, (row_id, _score)) in ranked_row_ids(&route_result.result).iter().enumerate() {
356 let contribution = route_result.weight / (RRF_K + rank as f32 + 1.0);
357 add_score(&mut scores, *row_id, contribution);
358 }
359 }
360 top_k(scores, limit)
361}
362
363fn mrr(route_results: &[WeightedRouteResult], limit: usize) -> SearchResult {
364 let mut scores = HashMap::new();
365 for route_result in route_results {
366 for (rank, (row_id, _score)) in ranked_row_ids(&route_result.result).iter().enumerate() {
367 let contribution = route_result.weight / (rank as f32 + 1.0);
368 add_score(&mut scores, *row_id, contribution);
369 }
370 }
371 top_k(scores, limit)
372}
373
374fn weighted_score(route_results: &[WeightedRouteResult], limit: usize) -> SearchResult {
375 let mut scores = HashMap::new();
376 for route_result in route_results {
377 let ranked = ranked_row_ids(&route_result.result);
378 if ranked.is_empty() {
379 continue;
380 }
381
382 let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
383 for (_row_id, score) in &ranked {
384 min = min.min(*score);
385 max = max.max(*score);
386 }
387 let range = max - min;
388
389 for (row_id, score) in ranked {
390 let normalized = if range > 0.0 {
391 (score - min) / range
392 } else {
393 1.0
394 };
395 add_score(&mut scores, row_id, route_result.weight * normalized);
396 }
397 }
398 top_k(scores, limit)
399}
400
401fn ranked_row_ids(result: &SearchResult) -> Vec<(u64, f32)> {
402 let mut best_scores = HashMap::new();
403 for (&row_id, &score) in result.row_ids.iter().zip(&result.scores) {
404 best_scores
405 .entry(row_id)
406 .and_modify(|old: &mut f32| {
407 if score > *old {
408 *old = score;
409 }
410 })
411 .or_insert(score);
412 }
413
414 let mut ranked: Vec<_> = best_scores.into_iter().collect();
415 ranked.sort_by(|(left_id, left_score), (right_id, right_score)| {
416 right_score
417 .partial_cmp(left_score)
418 .unwrap_or(std::cmp::Ordering::Equal)
419 .then_with(|| left_id.cmp(right_id))
420 });
421 ranked
422}
423
424fn add_score(scores: &mut HashMap<u64, f32>, row_id: u64, score: f32) {
425 scores
426 .entry(row_id)
427 .and_modify(|old_score| *old_score += score)
428 .or_insert(score);
429}
430
431fn top_k(scores: HashMap<u64, f32>, limit: usize) -> SearchResult {
432 if scores.is_empty() || limit == 0 {
433 return SearchResult::empty();
434 }
435
436 let mut entries: Vec<_> = scores.into_iter().collect();
437 entries.sort_by(|(left_id, left_score), (right_id, right_score)| {
438 right_score
439 .partial_cmp(left_score)
440 .unwrap_or(std::cmp::Ordering::Equal)
441 .then_with(|| left_id.cmp(right_id))
442 });
443 entries.truncate(limit);
444
445 let (row_ids, scores): (Vec<_>, Vec<_>) = entries.into_iter().unzip();
446 SearchResult::new(row_ids, scores)
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 fn route_result(row_ids: Vec<u64>, scores: Vec<f32>, weight: f32) -> WeightedRouteResult {
454 WeightedRouteResult {
455 result: SearchResult::new(row_ids, scores),
456 weight,
457 }
458 }
459
460 #[test]
461 fn test_rrf_prefers_overlap() {
462 let ranked = rank_results(
463 HybridSearchRanker::Rrf,
464 &[
465 route_result(vec![1, 2], vec![0.9, 0.8], 1.0),
466 route_result(vec![2, 3], vec![0.95, 0.1], 1.0),
467 ],
468 1,
469 );
470
471 assert_eq!(ranked.row_ids, vec![2]);
472 }
473
474 #[test]
475 fn test_weighted_score_min_max_normalizes_per_route() {
476 let ranked = rank_results(
477 HybridSearchRanker::WeightedScore,
478 &[
479 route_result(vec![1, 2, 3], vec![10.0, 5.0, 0.0], 2.0),
480 route_result(vec![1, 2, 3], vec![100.0, 50.0, 0.0], 1.0),
481 ],
482 3,
483 );
484
485 let scores: HashMap<_, _> = ranked.row_ids.into_iter().zip(ranked.scores).collect();
486 assert!((scores[&1] - 3.0).abs() < 1e-6);
487 assert!((scores[&2] - 1.5).abs() < 1e-6);
488 assert!((scores[&3] - 0.0).abs() < 1e-6);
489 }
490
491 #[test]
492 fn test_mrr_uses_reciprocal_rank_without_constant() {
493 let ranked = rank_results(
494 HybridSearchRanker::Mrr,
495 &[
496 route_result(vec![1, 2], vec![0.9, 0.8], 1.0),
497 route_result(vec![2, 3], vec![0.95, 0.1], 1.0),
498 ],
499 2,
500 );
501
502 assert_eq!(ranked.row_ids[0], 2);
503 assert!(ranked.scores[0] > ranked.scores[1]);
504 }
505}