Skip to main content

paimon/table/
hybrid_search_builder.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Hybrid search builder for combining multiple search routes.
19//!
20//! Reference: `org.apache.paimon.table.source.HybridSearchBuilder`.
21
22use 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}