Skip to main content

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}