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
44impl SearchHit {
45 pub fn as_row(&self) -> Row {
47 Row {
48 row_id: self.row_id,
49 values: self.values.clone(),
50 }
51 }
52}
53
54#[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#[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#[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#[derive(Debug, Clone)]
110pub struct SearchSpec {
111 pub must: Vec<Condition>,
114 pub retrievers: Vec<SearchRetriever>,
115 pub fusion_constant: u32,
117 pub rerank: Option<SearchRerank>,
118 pub limit: usize,
119 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 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}