Skip to main content

summa_core/query/vector/
sparse.rs

1//! Sparse vector queries with geometric nomination and exact forward scoring.
2
3use crate::dsl::Field;
4use crate::query::{MatchedPositions, ScoredPosition};
5use crate::segment::SegmentReader;
6use crate::{DocId, Score, TERMINATED};
7
8use super::combiner::MultiValueCombiner;
9use crate::query::traits::{CountFuture, Query, Scorer, ScorerFuture};
10
11const DEFAULT_SPARSE_OVER_FETCH_FACTOR: f32 = crate::query::MAX_CANDIDATE_OVERSUBSCRIPTION as f32;
12
13enum SparseQueryInfos {
14    Local(Vec<crate::query::SparseTermQueryInfo>),
15    Shared(std::sync::Arc<[crate::query::SparseTermQueryInfo]>),
16}
17
18impl SparseQueryInfos {
19    fn as_slice(&self) -> &[crate::query::SparseTermQueryInfo] {
20        match self {
21            Self::Local(infos) => infos,
22            Self::Shared(infos) => infos,
23        }
24    }
25}
26
27/// Sparse vector query for similarity search
28#[derive(Debug, Clone)]
29pub struct SparseVectorQuery {
30    /// Field containing the sparse vectors
31    pub field: Field,
32    /// Query vector as (dimension_id, weight) pairs
33    pub vector: Vec<(u32, f32)>,
34    /// How to combine scores for multi-valued documents
35    pub combiner: MultiValueCombiner,
36    pub heap_factor: f32,
37    pub over_fetch_factor: f32,
38    pub lsp_gamma: Option<usize>,
39    /// Minimum abs(weight) for query dimensions (0.0 = no filtering)
40    /// Dimensions below this threshold are dropped from candidate generation.
41    /// Seismic still uses the bounded full query when scoring visited candidates.
42    pub weight_threshold: f32,
43    /// Maximum candidate-generation dimensions (None = implementation cap).
44    /// Keeps only the top-k dimensions by abs(weight); Seismic final scoring uses
45    /// up to `MAX_QUERY_TERMS` dimensions from the full query.
46    pub max_query_dims: Option<usize>,
47    /// Fraction of query dimensions to keep (0.0-1.0), same semantics as
48    /// indexing-time `pruning`: sort by abs(weight) descending,
49    /// keep top fraction. Seismic applies it to candidate generation and scores
50    /// visited candidates with the bounded full query. None or 1.0 = no pruning.
51    pub pruning: Option<f32>,
52    /// Minimum number of query dimensions before pruning and weight_threshold
53    /// filtering are applied. Protects short queries from losing signal.
54    /// Default: 4. Set to 0 to always apply.
55    pub min_query_dims: usize,
56    /// Number of highest-weight dimensions used to nominate Seismic candidates.
57    pub seismic_cut: usize,
58    /// Summary pruning factor. Zero visits every nominated cluster.
59    pub seismic_factor: f32,
60    /// Scan all forward vectors with the same scorer.
61    pub exhaustive: bool,
62    /// Cached pruned vector; None = use `vector` as-is (no pruning applied)
63    pruned: Option<Vec<(u32, f32)>>,
64}
65
66impl std::fmt::Display for SparseVectorQuery {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        let dims = self.pruned_dims();
69        write!(f, "Sparse({}, dims={}", self.field.0, dims.len())?;
70        if self.vector.len() != dims.len() {
71            write!(f, ", orig={}", self.vector.len())?;
72        }
73        write!(f, ")")
74    }
75}
76
77impl SparseVectorQuery {
78    /// Create a new sparse vector query
79    ///
80    /// Default combiner is [`MultiValueCombiner::default`] (LogSumExp, temperature 1.5) — a
81    /// softmax-weighted smooth maximum. A document's score follows its
82    /// strongest ordinals; ordinal *count* contributes nothing on its own,
83    /// so many-chunk documents cannot outrank a focused strong match.
84    pub fn new(field: Field, vector: Vec<(u32, f32)>) -> Self {
85        let defaults = crate::structures::SparseQueryConfig::default();
86        let mut q = Self {
87            field,
88            vector,
89            combiner: MultiValueCombiner::default(),
90            heap_factor: 1.0,
91            over_fetch_factor: DEFAULT_SPARSE_OVER_FETCH_FACTOR,
92            lsp_gamma: None,
93            weight_threshold: 0.0,
94            max_query_dims: Some(crate::query::MAX_QUERY_TERMS),
95            pruning: None,
96            min_query_dims: 4,
97            seismic_cut: defaults.seismic_cut,
98            seismic_factor: defaults.seismic_factor,
99            exhaustive: defaults.exhaustive,
100            pruned: None,
101        };
102        q.pruned = q.compute_pruned_vector();
103        q
104    }
105
106    /// Effective query dimensions after pruning. Returns `vector` if no pruning is configured.
107    pub(crate) fn pruned_dims(&self) -> &[(u32, f32)] {
108        self.pruned.as_deref().unwrap_or(&self.vector)
109    }
110
111    fn validate(&self, reader: &SegmentReader) -> crate::Result<()> {
112        let entry = reader
113            .schema()
114            .get_field_entry(self.field)
115            .ok_or_else(|| crate::Error::FieldNotFound(self.field.0.to_string()))?;
116        if entry.field_type != crate::dsl::FieldType::SparseVector {
117            return Err(crate::Error::InvalidFieldType {
118                expected: "sparse_vector".to_string(),
119                got: format!("{:?}", entry.field_type),
120            });
121        }
122        if self.vector.iter().any(|(_, weight)| !weight.is_finite()) {
123            return Err(crate::Error::Query(
124                "sparse query contains a non-finite weight".to_string(),
125            ));
126        }
127        if self.pruned_dims().len() > crate::query::MAX_QUERY_TERMS {
128            return Err(crate::Error::Query(format!(
129                "sparse query contains more than {} effective dimensions",
130                crate::query::MAX_QUERY_TERMS
131            )));
132        }
133
134        if !self.heap_factor.is_finite() || !(0.0..=1.0).contains(&self.heap_factor) {
135            return Err(crate::Error::Query(format!(
136                "sparse heap_factor must be finite and in [0, 1], got {}",
137                self.heap_factor
138            )));
139        }
140        if !self.over_fetch_factor.is_finite()
141            || !(1.0..=DEFAULT_SPARSE_OVER_FETCH_FACTOR).contains(&self.over_fetch_factor)
142        {
143            return Err(crate::Error::Query(format!(
144                "sparse over_fetch_factor must be finite and in [1, {DEFAULT_SPARSE_OVER_FETCH_FACTOR}], got {}",
145                self.over_fetch_factor
146            )));
147        }
148        crate::query::seismic::validate_options(self.seismic_cut, self.seismic_factor)?;
149        self.combiner.validate().map_err(crate::Error::Query)
150    }
151
152    /// Configure Seismic nomination dimensions and summary pruning.
153    pub fn with_heap_factor(mut self, heap_factor: f32) -> Self {
154        self.heap_factor = heap_factor.clamp(0.0, 1.0);
155        self
156    }
157
158    pub fn with_over_fetch_factor(mut self, factor: f32) -> Self {
159        self.over_fetch_factor = factor.clamp(1.0, DEFAULT_SPARSE_OVER_FETCH_FACTOR);
160        self
161    }
162
163    pub fn with_lsp_gamma(mut self, gamma: usize) -> Self {
164        self.lsp_gamma = Some(gamma);
165        self
166    }
167
168    pub fn with_seismic_cut(mut self, cut: usize) -> Self {
169        self.seismic_cut = cut;
170        self
171    }
172    pub fn with_seismic_factor(mut self, factor: f32) -> Self {
173        self.seismic_factor = factor;
174        self
175    }
176    pub fn with_exhaustive(mut self, exhaustive: bool) -> Self {
177        self.exhaustive = exhaustive;
178        self
179    }
180
181    /// Set the multi-value score combiner
182    pub fn with_combiner(mut self, combiner: MultiValueCombiner) -> Self {
183        self.combiner = combiner;
184        self
185    }
186
187    /// Set minimum weight threshold for query dimensions
188    /// Dimensions with abs(weight) below this are dropped before search.
189    pub fn with_weight_threshold(mut self, threshold: f32) -> Self {
190        self.weight_threshold = threshold;
191        self.pruned = self.compute_pruned_vector();
192        self
193    }
194
195    /// Set maximum number of query dimensions (top-k by weight)
196    pub fn with_max_query_dims(mut self, max_dims: usize) -> Self {
197        // The query planner bounds scratch to MAX_QUERY_TERMS. Keep
198        // this invariant here even when an SDL or RPC override asks for more.
199        self.max_query_dims = Some(max_dims.min(crate::query::MAX_QUERY_TERMS));
200        self.pruned = self.compute_pruned_vector();
201        self
202    }
203
204    /// Set pruning fraction (0.0-1.0): keep top fraction of query dims by weight.
205    /// Same semantics as indexing-time `pruning`.
206    pub fn with_pruning(mut self, fraction: f32) -> Self {
207        self.pruning = Some(fraction.clamp(0.0, 1.0));
208        self.pruned = self.compute_pruned_vector();
209        self
210    }
211
212    /// Set minimum query dimensions before pruning/filtering are applied.
213    /// Queries with fewer dimensions than this skip weight_threshold and pruning.
214    pub fn with_min_query_dims(mut self, min_dims: usize) -> Self {
215        self.min_query_dims = min_dims;
216        self.pruned = self.compute_pruned_vector();
217        self
218    }
219
220    /// Apply weight_threshold, pruning, and max_query_dims. `None` aliases the
221    /// original query vector and avoids a second allocation on the default
222    /// unpruned path.
223    fn compute_pruned_vector(&self) -> Option<Vec<(u32, f32)>> {
224        let original_len = self.vector.len();
225        let max_dims = self
226            .max_query_dims
227            .unwrap_or(crate::query::MAX_QUERY_TERMS)
228            .min(crate::query::MAX_QUERY_TERMS);
229        let filtering_enabled = self.weight_threshold > 0.0 && original_len > self.min_query_dims;
230        let pruning_enabled = self
231            .pruning
232            .is_some_and(|fraction| fraction < 1.0 && original_len > self.min_query_dims);
233        if !filtering_enabled && !pruning_enabled && original_len <= max_dims {
234            return None;
235        }
236
237        // Step 1: weight_threshold — drop dimensions below minimum weight
238        // Skip when query has fewer than min_query_dims dimensions
239        let mut v: Vec<(u32, f32)> = if filtering_enabled {
240            self.vector
241                .iter()
242                .copied()
243                .filter(|(_, w)| w.abs() >= self.weight_threshold)
244                .collect()
245        } else {
246            self.vector.clone()
247        };
248        let after_threshold = v.len();
249
250        // Step 2: pruning — keep top fraction by abs(weight), same as indexing
251        // Skip when query has fewer than min_query_dims dimensions
252        let mut sorted_by_weight = false;
253        if let Some(fraction) = self.pruning
254            && fraction < 1.0
255            && v.len() > self.min_query_dims
256        {
257            v.sort_unstable_by(|a, b| b.1.abs().total_cmp(&a.1.abs()).then_with(|| a.0.cmp(&b.0)));
258            sorted_by_weight = true;
259            let keep = ((v.len() as f64 * fraction as f64).ceil() as usize).max(1);
260            v.truncate(keep);
261        }
262        let after_pruning = v.len();
263
264        // Step 3: max_query_dims — absolute cap on dimensions.  The hard
265        // MAX_QUERY_TERMS bound is a correctness requirement, not merely a
266        // tuning default: both sparse executors represent query terms in u64.
267        if v.len() > max_dims {
268            if !sorted_by_weight {
269                v.sort_unstable_by(|a, b| {
270                    b.1.abs().total_cmp(&a.1.abs()).then_with(|| a.0.cmp(&b.0))
271                });
272            }
273            v.truncate(max_dims);
274        }
275
276        if v.len() < original_len && log::log_enabled!(log::Level::Debug) {
277            let src: Vec<_> = self
278                .vector
279                .iter()
280                .map(|(d, w)| format!("({},{:.4})", d, w))
281                .collect();
282            let pruned_fmt: Vec<_> = v.iter().map(|(d, w)| format!("({},{:.4})", d, w)).collect();
283            log::debug!(
284                "[sparse query] field={}: pruned {}->{} dims \
285                 (threshold: {}->{}, pruning: {}->{}, max_dims: {}->{}), \
286                 source=[{}], pruned=[{}]",
287                self.field.0,
288                original_len,
289                v.len(),
290                original_len,
291                after_threshold,
292                after_threshold,
293                after_pruning,
294                after_pruning,
295                v.len(),
296                src.join(", "),
297                pruned_fmt.join(", "),
298            );
299        }
300
301        Some(v)
302    }
303
304    /// Create from separate indices and weights vectors
305    pub fn from_indices_weights(field: Field, indices: Vec<u32>, weights: Vec<f32>) -> Self {
306        let vector: Vec<(u32, f32)> = indices.into_iter().zip(weights).collect();
307        Self::new(field, vector)
308    }
309
310    /// Create from raw text using a HuggingFace tokenizer (single segment)
311    ///
312    /// This method tokenizes the text and creates a sparse vector query.
313    /// For multi-segment indexes, use `from_text_with_stats` instead.
314    ///
315    /// # Arguments
316    /// * `field` - The sparse vector field to search
317    /// * `text` - Raw text to tokenize
318    /// * `tokenizer_name` - HuggingFace tokenizer path (e.g., "bert-base-uncased")
319    /// * `weighting` - Weighting strategy for tokens
320    /// * `sparse_index` - Optional sparse index for IDF lookup (required for IDF weighting)
321    #[cfg(feature = "native")]
322    pub fn from_text(
323        field: Field,
324        text: &str,
325        tokenizer_name: &str,
326        weighting: crate::structures::QueryWeighting,
327        sparse_index: Option<&crate::segment::SparseIndex>,
328    ) -> crate::Result<Self> {
329        use crate::structures::QueryWeighting;
330        use crate::tokenizer::tokenizer_cache;
331
332        let tokenizer = tokenizer_cache().get_or_load(tokenizer_name)?;
333        let token_ids = tokenizer.tokenize_unique(text)?;
334
335        let weights: Vec<f32> = match weighting {
336            QueryWeighting::One => vec![1.0f32; token_ids.len()],
337            QueryWeighting::Idf => {
338                if let Some(index) = sparse_index {
339                    index.idf_weights(&token_ids)
340                } else {
341                    vec![1.0f32; token_ids.len()]
342                }
343            }
344            QueryWeighting::IdfFile => {
345                use crate::tokenizer::idf_weights_cache;
346                if let Some(idf) = idf_weights_cache().get_or_load(tokenizer_name, None) {
347                    token_ids.iter().map(|&id| idf.get(id)).collect()
348                } else {
349                    vec![1.0f32; token_ids.len()]
350                }
351            }
352        };
353
354        let vector: Vec<(u32, f32)> = token_ids.into_iter().zip(weights).collect();
355        Ok(Self::new(field, vector))
356    }
357
358    /// Create from raw text using global statistics (multi-segment)
359    ///
360    /// This is the recommended method for multi-segment indexes as it uses
361    /// aggregated IDF values across all segments for consistent ranking.
362    ///
363    /// # Arguments
364    /// * `field` - The sparse vector field to search
365    /// * `text` - Raw text to tokenize
366    /// * `tokenizer` - Pre-loaded HuggingFace tokenizer
367    /// * `weighting` - Weighting strategy for tokens
368    /// * `global_stats` - Global statistics for IDF computation
369    #[cfg(feature = "native")]
370    pub fn from_text_with_stats(
371        field: Field,
372        text: &str,
373        tokenizer: &crate::tokenizer::HfTokenizer,
374        weighting: crate::structures::QueryWeighting,
375        global_stats: Option<&crate::query::GlobalStats>,
376    ) -> crate::Result<Self> {
377        use crate::structures::QueryWeighting;
378
379        let token_ids = tokenizer.tokenize_unique(text)?;
380
381        let weights: Vec<f32> = match weighting {
382            QueryWeighting::One => vec![1.0f32; token_ids.len()],
383            QueryWeighting::Idf => {
384                if let Some(stats) = global_stats {
385                    // Clamp to zero: negative weights don't make sense for IDF
386                    stats
387                        .sparse_idf_weights(field, &token_ids)
388                        .into_iter()
389                        .map(|w| w.max(0.0))
390                        .collect()
391                } else {
392                    vec![1.0f32; token_ids.len()]
393                }
394            }
395            QueryWeighting::IdfFile => {
396                // IdfFile requires a tokenizer name for HF model lookup;
397                // this code path doesn't have one, so fall back to 1.0
398                vec![1.0f32; token_ids.len()]
399            }
400        };
401
402        let vector: Vec<(u32, f32)> = token_ids.into_iter().zip(weights).collect();
403        Ok(Self::new(field, vector))
404    }
405
406    /// Create from raw text, loading tokenizer from index directory
407    ///
408    /// This method supports the `index://` prefix for tokenizer paths,
409    /// loading tokenizer.json from the index directory.
410    ///
411    /// # Arguments
412    /// * `field` - The sparse vector field to search
413    /// * `text` - Raw text to tokenize
414    /// * `tokenizer_bytes` - Tokenizer JSON bytes (pre-loaded from directory)
415    /// * `weighting` - Weighting strategy for tokens
416    /// * `global_stats` - Global statistics for IDF computation
417    #[cfg(feature = "native")]
418    pub fn from_text_with_tokenizer_bytes(
419        field: Field,
420        text: &str,
421        tokenizer_bytes: &[u8],
422        weighting: crate::structures::QueryWeighting,
423        global_stats: Option<&crate::query::GlobalStats>,
424    ) -> crate::Result<Self> {
425        use crate::structures::QueryWeighting;
426        use crate::tokenizer::HfTokenizer;
427
428        let tokenizer = HfTokenizer::from_bytes(tokenizer_bytes)?;
429        let token_ids = tokenizer.tokenize_unique(text)?;
430
431        let weights: Vec<f32> = match weighting {
432            QueryWeighting::One => vec![1.0f32; token_ids.len()],
433            QueryWeighting::Idf => {
434                if let Some(stats) = global_stats {
435                    // Clamp to zero: negative weights don't make sense for IDF
436                    stats
437                        .sparse_idf_weights(field, &token_ids)
438                        .into_iter()
439                        .map(|w| w.max(0.0))
440                        .collect()
441                } else {
442                    vec![1.0f32; token_ids.len()]
443                }
444            }
445            QueryWeighting::IdfFile => {
446                // IdfFile requires a tokenizer name for HF model lookup;
447                // this code path doesn't have one, so fall back to 1.0
448                vec![1.0f32; token_ids.len()]
449            }
450        };
451
452        let vector: Vec<(u32, f32)> = token_ids.into_iter().zip(weights).collect();
453        Ok(Self::new(field, vector))
454    }
455}
456
457impl SparseVectorQuery {
458    fn sparse_infos_for_plan(
459        &self,
460        plan: Option<&std::sync::Arc<crate::query::bmp::LspSegmentPlan>>,
461    ) -> SparseQueryInfos {
462        match plan {
463            Some(plan) => SparseQueryInfos::Shared(std::sync::Arc::clone(&plan.infos)),
464            None => SparseQueryInfos::Local(self.sparse_infos()),
465        }
466    }
467
468    /// Build a bounded full-query decomposition and mark the pruned terms used
469    /// for candidate generation. Seismic scores visited documents with the
470    /// full list.
471    fn sparse_infos(&self) -> Vec<crate::query::SparseTermQueryInfo> {
472        let candidate_dims: Option<rustc_hash::FxHashSet<u32>> = self
473            .pruned
474            .as_ref()
475            .map(|dimensions| dimensions.iter().map(|&(dimension, _)| dimension).collect());
476        let make_info = |(dim_id, weight)| crate::query::SparseTermQueryInfo {
477            field: self.field,
478            dim_id,
479            weight,
480            candidate: candidate_dims
481                .as_ref()
482                .is_none_or(|dimensions| dimensions.contains(&dim_id)),
483            combiner: self.combiner,
484            heap_factor: if self.exhaustive {
485                1.0
486            } else {
487                self.heap_factor
488            },
489            over_fetch_factor: self.over_fetch_factor,
490            lsp_gamma: if self.exhaustive {
491                Some(0)
492            } else {
493                self.lsp_gamma
494            },
495            seismic_cut: self.seismic_cut,
496            seismic_factor: self.seismic_factor,
497            exhaustive: self.exhaustive,
498        };
499        if self.vector.len() <= crate::query::MAX_QUERY_TERMS {
500            return self.vector.iter().copied().map(make_info).collect();
501        }
502
503        let mut scoring_dims = self.vector.clone();
504        scoring_dims.sort_unstable_by(|left, right| {
505            right
506                .1
507                .abs()
508                .total_cmp(&left.1.abs())
509                .then_with(|| left.0.cmp(&right.0))
510        });
511        scoring_dims.truncate(crate::query::MAX_QUERY_TERMS);
512        scoring_dims.into_iter().map(make_info).collect()
513    }
514}
515
516impl Query for SparseVectorQuery {
517    fn as_doc_bitset_with_options(
518        &self,
519        reader: &SegmentReader,
520        options: &crate::query::ScorerOptions,
521    ) -> Option<crate::query::DocBitset> {
522        self.validate(reader).ok()?;
523        let index = reader.seismic_index(self.field)?;
524        let terms: Vec<_> = self
525            .sparse_infos()
526            .iter()
527            .map(|info| (info.dim_id, info.weight))
528            .collect();
529        crate::query::seismic::membership(index, reader.num_docs(), &terms, options)
530    }
531    fn candidate_query(&self) -> crate::Result<crate::query::CandidateQuery> {
532        Ok(crate::query::CandidateQuery::new(
533            self.field,
534            crate::query::candidate_scoring::ScoreComponent::Sparse(
535                self.sparse_infos()
536                    .into_iter()
537                    .map(|info| (info.dim_id, info.weight))
538                    .collect(),
539            ),
540        )
541        .with_combiner(self.combiner))
542    }
543    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
544        self.scorer_with_options(reader, limit, crate::query::ScorerOptions::with_positions())
545    }
546
547    fn scorer_with_options<'a>(
548        &self,
549        reader: &'a SegmentReader,
550        limit: usize,
551        options: crate::query::ScorerOptions,
552    ) -> ScorerFuture<'a> {
553        let validation = self.validate(reader);
554        let infos = self.sparse_infos_for_plan(options.lsp_plan.as_ref());
555
556        Box::pin(async move {
557            validation?;
558            let infos = infos.as_slice();
559            if let Some(scorer) =
560                crate::query::planner::build_sparse_memory_scorer(infos, reader, limit, &options)?
561            {
562                return Ok(scorer);
563            }
564            if let Some((executor, info)) = crate::query::planner::build_sparse_maxscore_executor(
565                infos, reader, limit, None, &options,
566            ) {
567                let raw = executor.execute().await?;
568                return Ok(crate::query::planner::combine_sparse_results(
569                    raw,
570                    info.combiner,
571                    info.field,
572                    limit,
573                ));
574            }
575            Ok(Box::new(crate::query::EmptyScorer) as Box<dyn Scorer>)
576        })
577    }
578
579    #[cfg(feature = "sync")]
580    fn scorer_sync<'a>(
581        &self,
582        reader: &'a SegmentReader,
583        limit: usize,
584    ) -> crate::Result<Box<dyn Scorer + 'a>> {
585        self.scorer_sync_with_options(reader, limit, crate::query::ScorerOptions::with_positions())
586    }
587
588    #[cfg(feature = "sync")]
589    fn scorer_sync_with_options<'a>(
590        &self,
591        reader: &'a SegmentReader,
592        limit: usize,
593        options: crate::query::ScorerOptions,
594    ) -> crate::Result<Box<dyn Scorer + 'a>> {
595        self.validate(reader)?;
596        let infos = self.sparse_infos_for_plan(options.lsp_plan.as_ref());
597        let infos = infos.as_slice();
598        if let Some(scorer) =
599            crate::query::planner::build_sparse_memory_scorer(infos, reader, limit, &options)?
600        {
601            return Ok(scorer);
602        }
603        if let Some((executor, info)) = crate::query::planner::build_sparse_maxscore_executor(
604            infos, reader, limit, None, &options,
605        ) {
606            let raw = executor.execute_sync()?;
607            return Ok(crate::query::planner::combine_sparse_results(
608                raw,
609                info.combiner,
610                info.field,
611                limit,
612            ));
613        }
614        Ok(Box::new(crate::query::EmptyScorer) as Box<dyn Scorer + 'a>)
615    }
616
617    fn count_estimate<'a>(&self, _reader: &'a SegmentReader) -> CountFuture<'a> {
618        Box::pin(async move { Ok(u32::MAX) })
619    }
620
621    fn decompose(&self) -> crate::query::QueryDecomposition {
622        let infos = self.sparse_infos();
623        if infos.is_empty() {
624            crate::query::QueryDecomposition::Opaque
625        } else {
626            crate::query::QueryDecomposition::SparseTerms(infos)
627        }
628    }
629}
630
631// ── SparseTermQuery: single sparse dimension query (like TermQuery for text) ──
632
633/// Query for a single sparse vector dimension.
634///
635/// Analogous to `TermQuery` for text: searches one dimension's posting list
636/// with a given weight. Multiple `SparseTermQuery` instances are combined as
637/// `BooleanQuery` SHOULD clauses to form a full sparse vector search.
638#[derive(Debug, Clone)]
639pub struct SparseTermQuery {
640    pub field: Field,
641    pub dim_id: u32,
642    pub weight: f32,
643    /// Multi-value combiner for ordinal deduplication
644    pub combiner: MultiValueCombiner,
645    pub heap_factor: f32,
646    pub over_fetch_factor: f32,
647    pub lsp_gamma: Option<usize>,
648    pub seismic_cut: usize,
649    pub seismic_factor: f32,
650    pub exhaustive: bool,
651}
652
653impl std::fmt::Display for SparseTermQuery {
654    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
655        write!(
656            f,
657            "SparseTerm({}, dim={}, w={:.3})",
658            self.field.0, self.dim_id, self.weight
659        )
660    }
661}
662
663impl SparseTermQuery {
664    pub fn new(field: Field, dim_id: u32, weight: f32) -> Self {
665        let defaults = crate::structures::SparseQueryConfig::default();
666        Self {
667            field,
668            dim_id,
669            weight,
670            combiner: MultiValueCombiner::default(),
671            heap_factor: 1.0,
672            over_fetch_factor: DEFAULT_SPARSE_OVER_FETCH_FACTOR,
673            lsp_gamma: None,
674            seismic_cut: defaults.seismic_cut,
675            seismic_factor: defaults.seismic_factor,
676            exhaustive: defaults.exhaustive,
677        }
678    }
679
680    pub fn with_heap_factor(mut self, heap_factor: f32) -> Self {
681        self.heap_factor = heap_factor.clamp(0.0, 1.0);
682        self
683    }
684
685    pub fn with_over_fetch_factor(mut self, factor: f32) -> Self {
686        self.over_fetch_factor = factor.clamp(1.0, DEFAULT_SPARSE_OVER_FETCH_FACTOR);
687        self
688    }
689
690    pub fn with_lsp_gamma(mut self, gamma: usize) -> Self {
691        self.lsp_gamma = Some(gamma);
692        self
693    }
694
695    pub fn with_seismic_cut(mut self, cut: usize) -> Self {
696        self.seismic_cut = cut;
697        self
698    }
699    pub fn with_seismic_factor(mut self, factor: f32) -> Self {
700        self.seismic_factor = factor;
701        self
702    }
703    pub fn with_exhaustive(mut self, exhaustive: bool) -> Self {
704        self.exhaustive = exhaustive;
705        self
706    }
707
708    pub fn with_combiner(mut self, combiner: MultiValueCombiner) -> Self {
709        self.combiner = combiner;
710        self
711    }
712
713    fn validate(&self, reader: &SegmentReader) -> crate::Result<()> {
714        let entry = reader
715            .schema()
716            .get_field_entry(self.field)
717            .ok_or_else(|| crate::Error::FieldNotFound(self.field.0.to_string()))?;
718        if entry.field_type != crate::dsl::FieldType::SparseVector {
719            return Err(crate::Error::InvalidFieldType {
720                expected: "sparse_vector".to_string(),
721                got: format!("{:?}", entry.field_type),
722            });
723        }
724        if !self.weight.is_finite() {
725            return Err(crate::Error::Query(
726                "sparse term query weight must be finite".to_string(),
727            ));
728        }
729
730        if !self.heap_factor.is_finite() || !(0.0..=1.0).contains(&self.heap_factor) {
731            return Err(crate::Error::Query(format!(
732                "sparse heap_factor must be finite and in [0, 1], got {}",
733                self.heap_factor
734            )));
735        }
736        if !self.over_fetch_factor.is_finite()
737            || !(1.0..=DEFAULT_SPARSE_OVER_FETCH_FACTOR).contains(&self.over_fetch_factor)
738        {
739            return Err(crate::Error::Query(format!(
740                "sparse over_fetch_factor must be finite and in [1, {DEFAULT_SPARSE_OVER_FETCH_FACTOR}], got {}",
741                self.over_fetch_factor
742            )));
743        }
744        crate::query::seismic::validate_options(self.seismic_cut, self.seismic_factor)?;
745        self.combiner.validate().map_err(crate::Error::Query)
746    }
747
748    fn sparse_info(&self) -> crate::query::SparseTermQueryInfo {
749        crate::query::SparseTermQueryInfo {
750            field: self.field,
751            dim_id: self.dim_id,
752            weight: self.weight,
753            candidate: true,
754            combiner: self.combiner,
755            heap_factor: if self.exhaustive {
756                1.0
757            } else {
758                self.heap_factor
759            },
760            over_fetch_factor: self.over_fetch_factor,
761            lsp_gamma: if self.exhaustive {
762                Some(0)
763            } else {
764                self.lsp_gamma
765            },
766            seismic_cut: self.seismic_cut,
767            seismic_factor: self.seismic_factor,
768            exhaustive: self.exhaustive,
769        }
770    }
771
772    /// Execute an in-memory sparse backend for this single dimension.
773    fn make_scorer<'a>(
774        &self,
775        reader: &'a SegmentReader,
776        limit: usize,
777        options: &crate::query::ScorerOptions,
778    ) -> crate::Result<Box<dyn Scorer + 'a>> {
779        let infos = [self.sparse_info()];
780        Ok(
781            crate::query::planner::build_sparse_memory_scorer(&infos, reader, limit, options)?
782                .unwrap_or_else(|| Box::new(crate::query::EmptyScorer)),
783        )
784    }
785
786    fn make_maxscore_scorer<'a>(
787        &self,
788        reader: &'a SegmentReader,
789    ) -> crate::Result<Option<SparseTermScorer<'a>>> {
790        let si = match reader.sparse_index(self.field) {
791            Some(si) => si,
792            None => return Ok(None),
793        };
794        let (skip_start, skip_count, global_max, block_data_offset) =
795            match si.get_skip_range_full(self.dim_id) {
796                Some(v) => v,
797                None => return Ok(None),
798            };
799        let cursor = crate::query::TermCursor::sparse(
800            si,
801            self.weight,
802            skip_start,
803            skip_count,
804            global_max,
805            block_data_offset,
806        );
807        Ok(Some(SparseTermScorer {
808            cursor,
809            field_id: self.field.0,
810        }))
811    }
812}
813
814impl Query for SparseTermQuery {
815    fn as_doc_bitset_with_options(
816        &self,
817        reader: &SegmentReader,
818        options: &crate::query::ScorerOptions,
819    ) -> Option<crate::query::DocBitset> {
820        self.validate(reader).ok()?;
821        let index = reader.seismic_index(self.field)?;
822        crate::query::seismic::membership(
823            index,
824            reader.num_docs(),
825            &[(self.dim_id, self.weight)],
826            options,
827        )
828    }
829    fn scorer<'a>(&self, reader: &'a SegmentReader, limit: usize) -> ScorerFuture<'a> {
830        self.scorer_with_options(reader, limit, crate::query::ScorerOptions::with_positions())
831    }
832
833    fn scorer_with_options<'a>(
834        &self,
835        reader: &'a SegmentReader,
836        limit: usize,
837        options: crate::query::ScorerOptions,
838    ) -> ScorerFuture<'a> {
839        let query = self.clone();
840        Box::pin(async move {
841            query.validate(reader)?;
842            if let Some(mut scorer) = query.make_maxscore_scorer(reader)? {
843                scorer.cursor.ensure_block_loaded().await?;
844                return Ok(Box::new(scorer) as Box<dyn Scorer + 'a>);
845            }
846            query.make_scorer(reader, limit, &options)
847        })
848    }
849
850    #[cfg(feature = "sync")]
851    fn scorer_sync<'a>(
852        &self,
853        reader: &'a SegmentReader,
854        limit: usize,
855    ) -> crate::Result<Box<dyn Scorer + 'a>> {
856        self.scorer_sync_with_options(reader, limit, crate::query::ScorerOptions::with_positions())
857    }
858
859    #[cfg(feature = "sync")]
860    fn scorer_sync_with_options<'a>(
861        &self,
862        reader: &'a SegmentReader,
863        limit: usize,
864        options: crate::query::ScorerOptions,
865    ) -> crate::Result<Box<dyn Scorer + 'a>> {
866        self.validate(reader)?;
867        if let Some(mut scorer) = self.make_maxscore_scorer(reader)? {
868            scorer.cursor.ensure_block_loaded_sync()?;
869            return Ok(Box::new(scorer) as Box<dyn Scorer + 'a>);
870        }
871        self.make_scorer(reader, limit, &options)
872    }
873
874    fn count_estimate<'a>(&self, reader: &'a SegmentReader) -> CountFuture<'a> {
875        let count = reader.seismic_index(self.field).map_or_else(
876            || {
877                reader.sparse_index(self.field).map_or_else(
878                    || {
879                        reader
880                            .bmp_index(self.field)
881                            .map_or(0, |_| reader.num_docs())
882                    },
883                    |index| index.doc_count(self.dim_id).min(reader.num_docs()),
884                )
885            },
886            |index| index.len().min(reader.num_docs()),
887        );
888        Box::pin(async move { Ok(count) })
889    }
890
891    fn decompose(&self) -> crate::query::QueryDecomposition {
892        crate::query::QueryDecomposition::SparseTerms(vec![self.sparse_info()])
893    }
894}
895
896/// Lazy scorer for a single sparse dimension, backed by `TermCursor::Sparse`.
897///
898/// Iterates through the posting list block-by-block using sync I/O.
899/// Score for each doc = `query_weight * quantized_stored_weight`.
900struct SparseTermScorer<'a> {
901    cursor: crate::query::TermCursor<'a>,
902    field_id: u32,
903}
904
905impl crate::query::docset::DocSet for SparseTermScorer<'_> {
906    fn doc(&self) -> DocId {
907        let d = self.cursor.doc();
908        if d == u32::MAX { TERMINATED } else { d }
909    }
910
911    fn advance(&mut self) -> DocId {
912        match self.cursor.advance_sync() {
913            Ok(d) if d == u32::MAX => TERMINATED,
914            Ok(d) => d,
915            Err(_) => TERMINATED,
916        }
917    }
918
919    fn seek(&mut self, target: DocId) -> DocId {
920        match self.cursor.seek_sync(target) {
921            Ok(d) if d == u32::MAX => TERMINATED,
922            Ok(d) => d,
923            Err(_) => TERMINATED,
924        }
925    }
926
927    fn size_hint(&self) -> u32 {
928        0
929    }
930}
931
932impl Scorer for SparseTermScorer<'_> {
933    fn score(&self) -> Score {
934        self.cursor.score()
935    }
936
937    fn matched_positions(&self) -> Option<MatchedPositions> {
938        let ordinal = self.cursor.ordinal();
939        let score = self.cursor.score();
940        if score == 0.0 {
941            return None;
942        }
943        Some(vec![(
944            self.field_id,
945            vec![ScoredPosition::new(ordinal as u32, score)],
946        )])
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953    use crate::dsl::Field;
954
955    #[test]
956    fn programmatic_sparse_queries_share_schema_seismic_defaults() {
957        let defaults = crate::structures::SparseQueryConfig::default();
958        let omitted: crate::structures::SparseQueryConfig = serde_json::from_str("{}").unwrap();
959        assert_eq!(omitted, defaults);
960        let vector = SparseVectorQuery::new(Field(0), vec![(7, 0.5)]);
961        let term = SparseTermQuery::new(Field(0), 7, 0.5);
962        let expected = (
963            defaults.seismic_cut,
964            defaults.seismic_factor,
965            defaults.exhaustive,
966        );
967        assert_eq!(
968            (vector.seismic_cut, vector.seismic_factor, vector.exhaustive),
969            expected
970        );
971        assert_eq!(
972            (term.seismic_cut, term.seismic_factor, term.exhaustive),
973            expected
974        );
975        // The programmatic work cap intentionally differs from the optional
976        // schema cap; sharing Seismic defaults must not remove that bound.
977        assert_eq!(vector.max_query_dims, Some(crate::query::MAX_QUERY_TERMS));
978        assert_eq!(defaults.max_query_dims, None);
979        for decomposition in [vector.decompose(), term.decompose()] {
980            let crate::query::QueryDecomposition::SparseTerms(infos) = decomposition else {
981                panic!("sparse queries must expose sparse scoring terms");
982            };
983            assert_eq!(infos.len(), 1);
984            assert_eq!(
985                (
986                    infos[0].seismic_cut,
987                    infos[0].seismic_factor,
988                    infos[0].exhaustive
989                ),
990                expected
991            );
992        }
993    }
994
995    #[test]
996    fn test_sparse_vector_query_new() {
997        let sparse = vec![(1, 0.5), (5, 0.3), (10, 0.2)];
998        let query = SparseVectorQuery::new(Field(0), sparse.clone());
999
1000        assert_eq!(query.field, Field(0));
1001        assert_eq!(query.vector, sparse);
1002        assert!(
1003            query.pruned.is_none(),
1004            "the default path must alias the source vector instead of cloning it"
1005        );
1006    }
1007
1008    #[test]
1009    fn test_sparse_vector_query_from_indices_weights() {
1010        let query =
1011            SparseVectorQuery::from_indices_weights(Field(0), vec![1, 5, 10], vec![0.5, 0.3, 0.2]);
1012
1013        assert_eq!(query.vector, vec![(1, 0.5), (5, 0.3), (10, 0.2)]);
1014    }
1015
1016    #[test]
1017    fn max_query_dims_cannot_exceed_query_work_budget() {
1018        let vector: Vec<(u32, f32)> = (0..100).map(|dim| (dim, dim as f32 + 1.0)).collect();
1019        let query = SparseVectorQuery::new(Field(0), vector).with_max_query_dims(usize::MAX);
1020
1021        assert_eq!(query.pruned_dims().len(), crate::query::MAX_QUERY_TERMS);
1022        // Pruning retains the dimensions with the largest absolute weights.
1023        assert!(query.pruned_dims().iter().all(|(dim, _)| *dim >= 36));
1024    }
1025
1026    #[test]
1027    fn decomposition_keeps_full_scores_and_marks_pruned_candidates() {
1028        let query = SparseVectorQuery::new(Field(0), vec![(3, 1.0), (7, 0.8), (11, 0.2)])
1029            .with_min_query_dims(0)
1030            .with_pruning(0.34);
1031        let infos = query.sparse_infos();
1032
1033        assert_eq!(infos.len(), 3);
1034        assert_eq!(
1035            infos
1036                .iter()
1037                .filter(|info| info.candidate)
1038                .map(|info| info.dim_id)
1039                .collect::<Vec<_>>(),
1040            vec![3, 7]
1041        );
1042        assert_eq!(
1043            infos
1044                .iter()
1045                .map(|info| (info.dim_id, info.weight))
1046                .collect::<Vec<_>>(),
1047            vec![(3, 1.0), (7, 0.8), (11, 0.2)]
1048        );
1049    }
1050}