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