1use 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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
64pub struct TextRetrieveResult {
65 pub hits: Vec<TextRetrieveHit>,
66 pub provenance: TextRetrieveProvenance,
67}
68
69impl SearchHit {
70 pub fn as_row(&self) -> Row {
72 Row {
73 row_id: self.row_id,
74 values: self.values.clone(),
75 }
76 }
77}
78
79#[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#[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#[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#[derive(Debug, Clone)]
135pub struct SearchSpec {
136 pub must: Vec<Condition>,
139 pub retrievers: Vec<SearchRetriever>,
140 pub fusion_constant: u32,
142 pub rerank: Option<SearchRerank>,
143 pub limit: usize,
144 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 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}