laurus/engine/search.rs
1use std::collections::HashMap;
2
3use crate::lexical::query::Query;
4use crate::lexical::search::searcher::{LexicalSearchQuery, SortField};
5// Re-export VectorSearchQuery so engine.rs and query.rs can refer to it
6// via `self::search::VectorSearchQuery` without reaching into vector internals.
7use crate::vector::VectorScoreMode;
8pub use crate::vector::search::searcher::VectorSearchQuery;
9
10// ── Query types (what to search for) ─────────────────────────────────────────
11
12/// Unified search query specification.
13///
14/// Determines **what** to search for. Search parameters (limits, score
15/// thresholds, fusion, etc.) are separate fields on [`SearchRequest`].
16///
17/// Four variants cover all search modes:
18///
19/// - [`Dsl`](Self::Dsl) — unified query DSL string, parsed at search time.
20/// - [`Lexical`](Self::Lexical) — lexical (BM25) search only.
21/// - [`Vector`](Self::Vector) — vector (nearest-neighbor) search only.
22/// - [`Hybrid`](Self::Hybrid) — both lexical and vector search with fusion.
23#[derive(Debug)]
24#[allow(clippy::large_enum_variant)]
25pub enum SearchQuery {
26 /// Unified query DSL string — parsed at search time by
27 /// [`UnifiedQueryParser`](super::query::UnifiedQueryParser).
28 ///
29 /// Supports lexical, vector, and hybrid queries in a single string:
30 ///
31 /// - **Lexical**: `title:hello`, `"exact phrase"`, `AND`/`OR`, `term~2`,
32 /// `[a TO z]`, etc.
33 /// - **Vector**: `field:"text"`, `field:text^0.8` (with boost).
34 /// - **Hybrid**: mix both — `title:hello content:"cute kitten"^0.8`.
35 Dsl(String),
36
37 /// Pre-built lexical (BM25) search query.
38 Lexical(LexicalSearchQuery),
39
40 /// Pre-built vector (nearest-neighbor) search query.
41 Vector(VectorSearchQuery),
42
43 /// Hybrid search combining lexical and vector components.
44 ///
45 /// Results are merged using the [`fusion_algorithm`](SearchRequest::fusion_algorithm)
46 /// specified on the [`SearchRequest`]. The [`mode`](HybridMode) controls
47 /// whether results are unioned (OR) or intersected (AND).
48 Hybrid {
49 /// Lexical search component.
50 lexical: LexicalSearchQuery,
51 /// Vector search component.
52 vector: VectorSearchQuery,
53 /// Controls how lexical and vector results are combined.
54 /// Defaults to [`HybridMode::Union`].
55 mode: HybridMode,
56 },
57}
58
59/// Controls how lexical and vector results are combined in hybrid search.
60///
61/// - [`Union`](Self::Union) — documents from **either** lexical or vector
62/// results are included (OR semantics). This is the default.
63/// - [`Intersection`](Self::Intersection) — only documents appearing in
64/// **both** result sets are included (AND semantics). Triggered by
65/// the `+` prefix on vector field clauses in the query DSL, e.g.
66/// `title:hello +embedding:"cute kitten"`.
67#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
68pub enum HybridMode {
69 /// Documents from either source are included (default).
70 #[default]
71 Union,
72 /// Only documents appearing in BOTH result sets are included.
73 Intersection,
74}
75
76// ── Option types (how to search) ─────────────────────────────────────────────
77
78/// Parameters controlling lexical search behavior.
79///
80/// These are separated from the query itself so that the same options can
81/// be applied regardless of how the query was specified (DSL string or
82/// pre-built query object).
83#[derive(Debug, Clone)]
84pub struct LexicalSearchOptions {
85 /// Per-field boost factors for relevance scoring.
86 ///
87 /// Example: `{"title": 2.0, "body": 1.0}` gives title matches twice
88 /// the weight of body matches.
89 pub field_boosts: HashMap<String, f32>,
90
91 /// Minimum score threshold. Results below this score are discarded.
92 /// Defaults to `0.0` (no threshold).
93 pub min_score: f32,
94
95 /// Timeout for the search operation in milliseconds.
96 /// `None` means no timeout.
97 pub timeout_ms: Option<u64>,
98
99 /// Enable parallel search across index segments for better performance
100 /// on multi-core systems. Defaults to `false`.
101 pub parallel: bool,
102
103 /// Sort results by field value or by relevance score.
104 /// Defaults to [`SortField::Score`].
105 pub sort_by: SortField,
106}
107
108impl Default for LexicalSearchOptions {
109 fn default() -> Self {
110 Self {
111 field_boosts: HashMap::new(),
112 min_score: 0.0,
113 timeout_ms: None,
114 parallel: false,
115 sort_by: SortField::Score,
116 }
117 }
118}
119
120/// Parameters controlling vector search behavior.
121///
122/// These are separated from the query itself so that the same options can
123/// be applied regardless of how the query was specified (payloads or
124/// pre-embedded vectors).
125#[derive(Debug, Clone)]
126pub struct VectorSearchOptions {
127 /// How to combine scores from multiple query vectors.
128 /// Defaults to [`VectorScoreMode::WeightedSum`].
129 pub score_mode: VectorScoreMode,
130
131 /// Minimum score threshold. Results below this score are discarded.
132 /// Defaults to `0.0` (no threshold).
133 pub min_score: f32,
134
135 /// Optional Stage 2 rerank factor (Issue #481).
136 ///
137 /// When `Some(factor)`, the underlying vector index searcher widens
138 /// the int8 candidate fetch to `top_k * factor` and rescores the
139 /// candidates against the original full-precision vectors via the
140 /// LRS1 sidecar. Honored only on HNSW fields whose schema enabled
141 /// `rerank_storage`; other configurations silently ignore the value.
142 /// `None` keeps Stage 1 behavior (int8-only).
143 pub rerank_factor: Option<usize>,
144
145 /// Per-query override for the HNSW `ef_search` candidate-list size
146 /// (Issue [#644](https://github.com/mosuka/laurus/issues/644)).
147 ///
148 /// When `None` (the default), the searcher uses the schema-level
149 /// [`HnswOption::default_ef_search`](crate::vector::core::field::HnswOption::default_ef_search)
150 /// or its internal fallback (`50`). Ignored by non-HNSW index types.
151 /// The effective `ef_search` is always lifted to at least `top_k`
152 /// (and `top_k * rerank_factor` when both are set) so the candidate
153 /// heap is never undersized for the requested `top_k`.
154 pub ef_search: Option<usize>,
155}
156
157impl Default for VectorSearchOptions {
158 fn default() -> Self {
159 Self {
160 score_mode: VectorScoreMode::WeightedSum,
161 min_score: 0.0,
162 rerank_factor: None,
163 ef_search: None,
164 }
165 }
166}
167
168// ── SearchRequest ────────────────────────────────────────────────────────────
169
170/// Unified search request combining query specification with pagination,
171/// options, and fusion settings.
172///
173/// The query specifies **what** to search for ([`SearchQuery`]), while
174/// [`lexical_options`](Self::lexical_options) and
175/// [`vector_options`](Self::vector_options) control **how** to search.
176///
177/// Use [`SearchRequestBuilder`] for a fluent construction API.
178pub struct SearchRequest {
179 /// The search query specification.
180 pub query: SearchQuery,
181
182 /// Maximum number of results to return. Defaults to `10`.
183 pub limit: usize,
184
185 /// Number of results to skip before returning (for pagination).
186 /// Defaults to `0`.
187 pub offset: usize,
188
189 /// Fusion algorithm for combining lexical and vector scores.
190 ///
191 /// Only used when both lexical and vector search components are
192 /// present (i.e., [`SearchQuery::Hybrid`] or a [`SearchQuery::Dsl`]
193 /// that contains both clause types). Defaults to
194 /// [`FusionAlgorithm::RRF { k: 60.0 }`](FusionAlgorithm::RRF) when
195 /// `None`.
196 pub fusion_algorithm: Option<FusionAlgorithm>,
197
198 /// Optional filter query (lexical) to restrict the search space.
199 ///
200 /// When set, the filter is evaluated first and **both** lexical and
201 /// vector searches are restricted to documents matching this filter.
202 pub filter_query: Option<Box<dyn Query>>,
203
204 /// Parameters controlling lexical search behavior.
205 pub lexical_options: LexicalSearchOptions,
206
207 /// Parameters controlling vector search behavior.
208 pub vector_options: VectorSearchOptions,
209}
210
211/// Algorithm used to combine lexical and vector scores in hybrid search.
212///
213/// The default fusion algorithm (when none is specified in a
214/// [`SearchRequest`]) is [`RRF`](Self::RRF) with `k = 60.0`.
215#[derive(Debug, Clone, Copy)]
216pub enum FusionAlgorithm {
217 /// Reciprocal Rank Fusion (RRF).
218 ///
219 /// Combines results based on rank position rather than raw scores,
220 /// making it effective when score magnitudes are not comparable
221 /// (e.g. BM25 vs cosine similarity). The score for each document is
222 /// `sum(1 / (k + rank))` across the result lists.
223 RRF {
224 /// Smoothing constant `k`. Higher values reduce the influence of
225 /// top-ranked documents. Typical default is `60.0`.
226 k: f64,
227 },
228
229 /// Weighted Sum with automatic min-max score normalization.
230 ///
231 /// Before weighting, the engine independently normalizes lexical and
232 /// vector scores to the `[0.0, 1.0]` range using min-max normalization
233 /// over their respective result sets.
234 WeightedSum {
235 /// Weight for the normalized lexical score (clamped to `0.0..=1.0`).
236 lexical_weight: f32,
237 /// Weight for the normalized vector score (clamped to `0.0..=1.0`).
238 vector_weight: f32,
239 },
240}
241
242impl Default for SearchRequest {
243 fn default() -> Self {
244 Self {
245 query: SearchQuery::Dsl(String::new()),
246 limit: 10,
247 offset: 0,
248 fusion_algorithm: None,
249 filter_query: None,
250 lexical_options: LexicalSearchOptions::default(),
251 vector_options: VectorSearchOptions::default(),
252 }
253 }
254}
255
256// ── SearchRequestBuilder ─────────────────────────────────────────────────────
257
258/// Fluent builder for constructing a [`SearchRequest`].
259///
260/// Supports three construction patterns:
261///
262/// 1. **DSL string** (via [`query_dsl`](Self::query_dsl)): Pass a unified
263/// query DSL string. The engine parses it at search time.
264/// 2. **Single mode** (via [`lexical_query`](Self::lexical_query) or
265/// [`vector_query`](Self::vector_query)): Set one search mode.
266/// 3. **Hybrid** (via both [`lexical_query`](Self::lexical_query) and
267/// [`vector_query`](Self::vector_query)): Set both for hybrid search.
268///
269/// If [`query_dsl`](Self::query_dsl) is called, the builder produces a
270/// [`SearchQuery::Dsl`] variant. Otherwise, it determines the variant from
271/// which query methods were called.
272pub struct SearchRequestBuilder {
273 dsl: Option<String>,
274 lexical_query: Option<LexicalSearchQuery>,
275 vector_query: Option<VectorSearchQuery>,
276 limit: usize,
277 offset: usize,
278 fusion_algorithm: Option<FusionAlgorithm>,
279 filter_query: Option<Box<dyn Query>>,
280 lexical_options: LexicalSearchOptions,
281 vector_options: VectorSearchOptions,
282}
283
284impl Default for SearchRequestBuilder {
285 fn default() -> Self {
286 Self::new()
287 }
288}
289
290impl SearchRequestBuilder {
291 /// Create a new builder with default settings.
292 pub fn new() -> Self {
293 Self {
294 dsl: None,
295 lexical_query: None,
296 vector_query: None,
297 limit: 10,
298 offset: 0,
299 fusion_algorithm: None,
300 filter_query: None,
301 lexical_options: LexicalSearchOptions::default(),
302 vector_options: VectorSearchOptions::default(),
303 }
304 }
305
306 // ── Query setters ────────────────────────────────────────────────────
307
308 /// Set a unified query DSL string.
309 ///
310 /// When set, the built request uses [`SearchQuery::Dsl`] and any
311 /// lexical/vector queries set via other methods are ignored.
312 pub fn query_dsl(mut self, dsl: impl Into<String>) -> Self {
313 self.dsl = Some(dsl.into());
314 self
315 }
316
317 /// Set the lexical search query.
318 ///
319 /// If [`vector_query`](Self::vector_query) is also set, the result is
320 /// [`SearchQuery::Hybrid`]. Otherwise [`SearchQuery::Lexical`].
321 pub fn lexical_query(mut self, query: LexicalSearchQuery) -> Self {
322 self.lexical_query = Some(query);
323 self
324 }
325
326 /// Set the vector search query.
327 ///
328 /// If [`lexical_query`](Self::lexical_query) is also set, the result is
329 /// [`SearchQuery::Hybrid`]. Otherwise [`SearchQuery::Vector`].
330 pub fn vector_query(mut self, query: VectorSearchQuery) -> Self {
331 self.vector_query = Some(query);
332 self
333 }
334
335 // ── Pagination & fusion ──────────────────────────────────────────────
336
337 /// Set the maximum number of results to return.
338 pub fn limit(mut self, limit: usize) -> Self {
339 self.limit = limit;
340 self
341 }
342
343 /// Set the number of results to skip (for pagination).
344 pub fn offset(mut self, offset: usize) -> Self {
345 self.offset = offset;
346 self
347 }
348
349 /// Set the fusion algorithm for hybrid search.
350 ///
351 /// For [`FusionAlgorithm::WeightedSum`], the weights are clamped to
352 /// `0.0..=1.0` to prevent NaN/Inf propagation.
353 pub fn fusion_algorithm(mut self, fusion: FusionAlgorithm) -> Self {
354 let fusion = match fusion {
355 FusionAlgorithm::WeightedSum {
356 lexical_weight,
357 vector_weight,
358 } => FusionAlgorithm::WeightedSum {
359 lexical_weight: lexical_weight.clamp(0.0, 1.0),
360 vector_weight: vector_weight.clamp(0.0, 1.0),
361 },
362 other => other,
363 };
364 self.fusion_algorithm = Some(fusion);
365 self
366 }
367
368 /// Set a filter query to restrict the search space.
369 ///
370 /// The filter applies to **both** lexical and vector searches.
371 pub fn filter_query(mut self, query: Box<dyn Query>) -> Self {
372 self.filter_query = Some(query);
373 self
374 }
375
376 // ── Lexical options ──────────────────────────────────────────────────
377
378 /// Add a field-level boost for lexical search.
379 pub fn add_field_boost(mut self, field: impl Into<String>, boost: f32) -> Self {
380 self.lexical_options
381 .field_boosts
382 .insert(field.into(), boost);
383 self
384 }
385
386 /// Set the minimum score threshold for lexical search.
387 pub fn lexical_min_score(mut self, min_score: f32) -> Self {
388 self.lexical_options.min_score = min_score;
389 self
390 }
391
392 /// Set the timeout for lexical search in milliseconds.
393 pub fn lexical_timeout_ms(mut self, timeout_ms: u64) -> Self {
394 self.lexical_options.timeout_ms = Some(timeout_ms);
395 self
396 }
397
398 /// Enable or disable parallel lexical search.
399 pub fn lexical_parallel(mut self, parallel: bool) -> Self {
400 self.lexical_options.parallel = parallel;
401 self
402 }
403
404 /// Set the sort order for lexical search results.
405 pub fn sort_by(mut self, sort_by: SortField) -> Self {
406 self.lexical_options.sort_by = sort_by;
407 self
408 }
409
410 // ── Vector options ───────────────────────────────────────────────────
411
412 /// Set the score combination mode for vector search.
413 pub fn vector_score_mode(mut self, score_mode: VectorScoreMode) -> Self {
414 self.vector_options.score_mode = score_mode;
415 self
416 }
417
418 /// Set the minimum score threshold for vector search.
419 pub fn vector_min_score(mut self, min_score: f32) -> Self {
420 self.vector_options.min_score = min_score;
421 self
422 }
423
424 /// Set the Stage 2 rerank factor (Issue #481) for vector search.
425 ///
426 /// Honored by HNSW fields whose schema enabled `rerank_storage`.
427 /// Other vector configurations silently ignore the value.
428 pub fn vector_rerank_factor(mut self, factor: usize) -> Self {
429 self.vector_options.rerank_factor = Some(factor);
430 self
431 }
432
433 /// Override the HNSW `ef_search` candidate-list size for this
434 /// search (Issue [#644](https://github.com/mosuka/laurus/issues/644)).
435 ///
436 /// When unset, the searcher uses the schema-level
437 /// [`HnswOption::default_ef_search`](crate::vector::core::field::HnswOption::default_ef_search)
438 /// or its internal fallback (`50`). Per-query overrides always
439 /// take precedence. Ignored by non-HNSW index types.
440 pub fn vector_ef_search(mut self, ef: usize) -> Self {
441 self.vector_options.ef_search = Some(ef);
442 self
443 }
444
445 // ── Build ────────────────────────────────────────────────────────────
446
447 /// Consume the builder and return the constructed [`SearchRequest`].
448 pub fn build(self) -> SearchRequest {
449 let query = if let Some(dsl) = self.dsl {
450 SearchQuery::Dsl(dsl)
451 } else {
452 match (self.lexical_query, self.vector_query) {
453 (Some(lexical), Some(vector)) => SearchQuery::Hybrid {
454 lexical,
455 vector,
456 mode: HybridMode::default(),
457 },
458 (Some(lexical), None) => SearchQuery::Lexical(lexical),
459 (None, Some(vector)) => SearchQuery::Vector(vector),
460 (None, None) => SearchQuery::Dsl(String::new()),
461 }
462 };
463
464 SearchRequest {
465 query,
466 limit: self.limit,
467 offset: self.offset,
468 fusion_algorithm: self.fusion_algorithm,
469 filter_query: self.filter_query,
470 lexical_options: self.lexical_options,
471 vector_options: self.vector_options,
472 }
473 }
474}
475
476// ── SearchResult ─────────────────────────────────────────────────────────────
477
478/// A single result from an [`Engine`](super::Engine) search.
479#[derive(Debug, Clone)]
480pub struct SearchResult {
481 /// External document ID (the `_id` field value).
482 pub id: String,
483 /// Relevance score. The meaning depends on the search mode:
484 /// - Lexical only: BM25 score.
485 /// - Vector only: similarity score (e.g. cosine similarity).
486 /// - Hybrid: fused score produced by the [`FusionAlgorithm`].
487 pub score: f32,
488 /// The stored fields of the document, or `None` if the document could
489 /// not be retrieved (e.g. it was deleted between scoring and retrieval).
490 pub document: Option<crate::data::Document>,
491}