Skip to main content

mongreldb_kit/
search.rs

1//! Hybrid scored search (retrievers + fusion + optional exact rerank).
2//!
3//! This is the Kit-facing surface over the engine's
4//! [`mongreldb_core::query::SearchRequest`] path. Kit calls **core** directly
5//! (not the C ABI): the same engine path used by `mongreldb_table_search`,
6//! `/kit/search`, and the NAPI binding, so behaviour stays aligned across
7//! languages.
8//!
9//! Column references use Kit column **names**; ids are resolved against the
10//! active kit schema at call time.
11
12use crate::error::{KitError, Result};
13use crate::schema::Row;
14use mongreldb_core::query::{
15    Condition, Fusion, NamedRetriever, Rerank, Retriever, SearchHit as CoreSearchHit,
16    SearchRequest, SetMember, VectorMetric, MAX_FINAL_LIMIT, MAX_RETRIEVER_K,
17    MAX_RETRIEVER_NAME_BYTES, MAX_RETRIEVER_WEIGHT,
18};
19use mongreldb_kit_core::schema::Table as KitTable;
20use serde_json::{Map, Value};
21
22/// One ranked component score from a named retriever.
23#[derive(Debug, Clone, PartialEq, serde::Serialize)]
24pub struct SearchComponent {
25    pub retriever_name: String,
26    pub rank: usize,
27    pub raw_score_kind: String,
28    pub raw_score_value: f64,
29    pub contribution: f64,
30}
31
32/// One hybrid-search hit with scores and row payload.
33#[derive(Debug, Clone, PartialEq)]
34pub struct SearchHit {
35    pub row_id: u64,
36    pub values: Map<String, Value>,
37    pub fused_score: f64,
38    pub final_score: f64,
39    pub final_rank: usize,
40    pub exact_rerank_score: Option<f32>,
41    pub components: Vec<SearchComponent>,
42}
43
44impl SearchHit {
45    /// View as a ordinary kit [`Row`] (drops score metadata).
46    pub fn as_row(&self) -> Row {
47        Row {
48            row_id: self.row_id,
49            values: self.values.clone(),
50        }
51    }
52}
53
54/// Exact-vector metric for optional rerank.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SearchMetric {
57    Cosine,
58    DotProduct,
59    Euclidean,
60}
61
62impl From<SearchMetric> for VectorMetric {
63    fn from(m: SearchMetric) -> Self {
64        match m {
65            SearchMetric::Cosine => VectorMetric::Cosine,
66            SearchMetric::DotProduct => VectorMetric::DotProduct,
67            SearchMetric::Euclidean => VectorMetric::Euclidean,
68        }
69    }
70}
71
72/// A named weighted retriever (ANN / sparse / MinHash).
73#[derive(Debug, Clone)]
74pub enum SearchRetriever {
75    Ann {
76        column: String,
77        name: String,
78        weight: f64,
79        k: usize,
80        query: Vec<f32>,
81    },
82    Sparse {
83        column: String,
84        name: String,
85        weight: f64,
86        k: usize,
87        query: Vec<(u32, f32)>,
88    },
89    MinHash {
90        column: String,
91        name: String,
92        weight: f64,
93        k: usize,
94        members: Vec<String>,
95    },
96}
97
98/// Optional exact float-vector rerank after fusion.
99#[derive(Debug, Clone)]
100pub struct SearchRerank {
101    pub embedding_column: String,
102    pub query: Vec<f32>,
103    pub metric: SearchMetric,
104    pub candidate_limit: usize,
105    pub weight: f64,
106}
107
108/// Hybrid search request (name-based columns).
109#[derive(Debug, Clone)]
110pub struct SearchSpec {
111    /// Hard filters applied before / during retrieval (core conditions with
112    /// resolved column ids, or use helpers on [`Transaction`] to build them).
113    pub must: Vec<Condition>,
114    pub retrievers: Vec<SearchRetriever>,
115    /// Reciprocal-rank fusion constant (default 60).
116    pub fusion_constant: u32,
117    pub rerank: Option<SearchRerank>,
118    pub limit: usize,
119    /// Optional projection by column **name**.
120    pub projection: Option<Vec<String>>,
121}
122
123impl Default for SearchSpec {
124    fn default() -> Self {
125        Self {
126            must: Vec::new(),
127            retrievers: Vec::new(),
128            fusion_constant: 60,
129            rerank: None,
130            limit: 10,
131            projection: None,
132        }
133    }
134}
135
136pub(crate) fn resolve_column_id(table: &KitTable, name: &str) -> Result<u16> {
137    table
138        .column(name)
139        .map(|c| c.id as u16)
140        .ok_or_else(|| KitError::Validation(format!("unknown column \"{name}\"")))
141}
142
143pub(crate) fn build_core_request(table: &KitTable, spec: &SearchSpec) -> Result<SearchRequest> {
144    if spec.retrievers.is_empty() {
145        return Err(KitError::Validation(
146            "search requires at least one retriever".into(),
147        ));
148    }
149    if !(1..=MAX_FINAL_LIMIT).contains(&spec.limit) {
150        return Err(KitError::Validation(format!(
151            "search limit must be between 1 and {MAX_FINAL_LIMIT}"
152        )));
153    }
154
155    let mut retrievers = Vec::with_capacity(spec.retrievers.len());
156    for r in &spec.retrievers {
157        retrievers.push(build_named_retriever(table, r)?);
158    }
159
160    let rerank = match &spec.rerank {
161        None => None,
162        Some(rr) => {
163            if !(spec.limit..=MAX_RETRIEVER_K).contains(&rr.candidate_limit) {
164                return Err(KitError::Validation(
165                    "rerank candidate_limit is out of range".into(),
166                ));
167            }
168            if !rr.weight.is_finite() || !(0.0..=MAX_RETRIEVER_WEIGHT).contains(&rr.weight) {
169                return Err(KitError::Validation(
170                    "rerank weight must be finite, non-negative, and within limit".into(),
171                ));
172            }
173            let embedding_column = resolve_column_id(table, &rr.embedding_column)?;
174            Some(Rerank::ExactVector {
175                embedding_column,
176                query: rr.query.clone(),
177                metric: rr.metric.into(),
178                candidate_limit: rr.candidate_limit,
179                weight: rr.weight,
180            })
181        }
182    };
183
184    let projection = match &spec.projection {
185        None => None,
186        Some(names) => {
187            let mut ids = Vec::with_capacity(names.len());
188            for name in names {
189                ids.push(resolve_column_id(table, name)?);
190            }
191            Some(ids)
192        }
193    };
194
195    Ok(SearchRequest {
196        must: spec.must.clone(),
197        retrievers,
198        fusion: Fusion::ReciprocalRank {
199            constant: spec.fusion_constant.max(1),
200        },
201        rerank,
202        limit: spec.limit,
203        projection,
204    })
205}
206
207fn build_named_retriever(table: &KitTable, r: &SearchRetriever) -> Result<NamedRetriever> {
208    let (name, weight, retriever) = match r {
209        SearchRetriever::Ann {
210            column,
211            name,
212            weight,
213            k,
214            query,
215        } => {
216            validate_named(name, *weight, *k)?;
217            if query.is_empty() {
218                return Err(KitError::Validation(
219                    "Ann retriever requires a non-empty embedding".into(),
220                ));
221            }
222            let column_id = resolve_column_id(table, column)?;
223            (
224                name.clone(),
225                *weight,
226                Retriever::Ann {
227                    column_id,
228                    query: query.clone(),
229                    k: *k,
230                },
231            )
232        }
233        SearchRetriever::Sparse {
234            column,
235            name,
236            weight,
237            k,
238            query,
239        } => {
240            validate_named(name, *weight, *k)?;
241            let column_id = resolve_column_id(table, column)?;
242            (
243                name.clone(),
244                *weight,
245                Retriever::Sparse {
246                    column_id,
247                    query: query.clone(),
248                    k: *k,
249                },
250            )
251        }
252        SearchRetriever::MinHash {
253            column,
254            name,
255            weight,
256            k,
257            members,
258        } => {
259            validate_named(name, *weight, *k)?;
260            let column_id = resolve_column_id(table, column)?;
261            let members = members
262                .iter()
263                .map(|s| SetMember::String(s.clone()))
264                .collect();
265            (
266                name.clone(),
267                *weight,
268                Retriever::MinHash {
269                    column_id,
270                    members,
271                    k: *k,
272                },
273            )
274        }
275    };
276    Ok(NamedRetriever {
277        name,
278        weight,
279        retriever,
280    })
281}
282
283fn validate_named(name: &str, weight: f64, k: usize) -> Result<()> {
284    if name.is_empty() || name.len() > MAX_RETRIEVER_NAME_BYTES {
285        return Err(KitError::Validation(
286            "retriever name must be non-empty and within the byte limit".into(),
287        ));
288    }
289    if !(1..=MAX_RETRIEVER_K).contains(&k) {
290        return Err(KitError::Validation(format!(
291            "retriever k must be between 1 and {MAX_RETRIEVER_K}"
292        )));
293    }
294    if !weight.is_finite() || !(0.0..=MAX_RETRIEVER_WEIGHT).contains(&weight) {
295        return Err(KitError::Validation(
296            "retriever weight must be finite, non-negative, and within limit".into(),
297        ));
298    }
299    Ok(())
300}
301
302pub(crate) fn core_hit_to_kit(hit: CoreSearchHit, table: &KitTable) -> Result<SearchHit> {
303    // Build a temporary core row for the existing JSON conversion path.
304    let mut columns = std::collections::HashMap::new();
305    for (id, value) in &hit.cells {
306        columns.insert(*id, value.clone());
307    }
308    let core_row = mongreldb_core::memtable::Row {
309        row_id: hit.row_id,
310        committed_epoch: mongreldb_core::Epoch(0),
311        columns,
312        deleted: false,
313    };
314    let row = crate::schema::core_row_to_json(&core_row, table)?;
315    let components = hit
316        .components
317        .iter()
318        .map(|c| {
319            let (raw_score_kind, raw_score_value) = match c.raw_score {
320                mongreldb_core::query::RetrieverScore::AnnHammingDistance(d) => {
321                    ("ann_hamming_distance".into(), f64::from(d))
322                }
323                mongreldb_core::query::RetrieverScore::SparseDotProduct(v) => {
324                    ("sparse_dot_product".into(), v)
325                }
326                mongreldb_core::query::RetrieverScore::MinHashEstimatedJaccard(v) => {
327                    ("minhash_estimated_jaccard".into(), f64::from(v))
328                }
329            };
330            SearchComponent {
331                retriever_name: c.retriever_name.to_string(),
332                rank: c.rank,
333                raw_score_kind,
334                raw_score_value,
335                contribution: c.contribution,
336            }
337        })
338        .collect();
339    Ok(SearchHit {
340        row_id: hit.row_id.0,
341        values: row.values,
342        fused_score: hit.fused_score,
343        final_score: hit.final_score,
344        final_rank: hit.final_rank,
345        exact_rerank_score: hit.exact_rerank_score,
346        components,
347    })
348}