Skip to main content

hermes_core/structures/postings/sparse/
config.rs

1//! Configuration types for sparse vector posting lists
2
3use serde::{Deserialize, Serialize};
4
5/// Sparse vector index format
6///
7/// Determines the on-disk layout and query execution strategy:
8/// - **MaxScore**: Per-dimension variable-size blocks (DAAT — document-at-a-time).
9///   Default, optimal for general sparse retrieval with block-max pruning.
10/// - **Bmp**: Fixed doc_id range blocks (BAAT — block-at-a-time).
11///   Based on Mallia, Suel & Tonellotto (SIGIR 2024). Divides the document
12///   space into fixed-size blocks and processes them in decreasing upper-bound
13///   order, enabling aggressive early termination.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
15pub enum SparseFormat {
16    /// Per-dimension variable-size blocks (existing format, DAAT MaxScore)
17    #[default]
18    MaxScore,
19    /// Fixed doc_id range blocks (BMP, BAAT block-at-a-time)
20    Bmp,
21}
22
23impl SparseFormat {
24    fn is_default(&self) -> bool {
25        *self == Self::MaxScore
26    }
27}
28
29/// Size of the index (term/dimension ID) in sparse vectors
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
31#[repr(u8)]
32pub enum IndexSize {
33    /// 16-bit index (0-65535), ideal for SPLADE vocabularies
34    U16 = 0,
35    /// 32-bit index (0-4B), for large vocabularies
36    #[default]
37    U32 = 1,
38}
39
40impl IndexSize {
41    /// Bytes per index
42    pub fn bytes(&self) -> usize {
43        match self {
44            IndexSize::U16 => 2,
45            IndexSize::U32 => 4,
46        }
47    }
48
49    /// Maximum value representable
50    pub fn max_value(&self) -> u32 {
51        match self {
52            IndexSize::U16 => u16::MAX as u32,
53            IndexSize::U32 => u32::MAX,
54        }
55    }
56
57    pub(crate) fn from_u8(v: u8) -> Option<Self> {
58        match v {
59            0 => Some(IndexSize::U16),
60            1 => Some(IndexSize::U32),
61            _ => None,
62        }
63    }
64}
65
66/// Quantization format for sparse vector weights
67///
68/// Research-validated compression/effectiveness trade-offs (Pati, 2025):
69/// - **UInt8**: 4x compression, ~1-2% nDCG@10 loss (RECOMMENDED for production)
70/// - **Float16**: 2x compression, <1% nDCG@10 loss
71/// - **Float32**: No compression, baseline effectiveness
72/// - **UInt4**: 8x compression, ~3-5% nDCG@10 loss (experimental)
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
74#[repr(u8)]
75pub enum WeightQuantization {
76    /// Full 32-bit float precision
77    #[default]
78    Float32 = 0,
79    /// 16-bit float (half precision) - 2x compression, <1% effectiveness loss
80    Float16 = 1,
81    /// 8-bit unsigned integer with scale factor - 4x compression, ~1-2% effectiveness loss (RECOMMENDED)
82    UInt8 = 2,
83    /// 4-bit unsigned integer with scale factor (packed, 2 per byte) - 8x compression, ~3-5% effectiveness loss
84    UInt4 = 3,
85}
86
87impl WeightQuantization {
88    /// Bytes per weight (approximate for UInt4)
89    pub fn bytes_per_weight(&self) -> f32 {
90        match self {
91            WeightQuantization::Float32 => 4.0,
92            WeightQuantization::Float16 => 2.0,
93            WeightQuantization::UInt8 => 1.0,
94            WeightQuantization::UInt4 => 0.5,
95        }
96    }
97
98    pub(crate) fn from_u8(v: u8) -> Option<Self> {
99        match v {
100            0 => Some(WeightQuantization::Float32),
101            1 => Some(WeightQuantization::Float16),
102            2 => Some(WeightQuantization::UInt8),
103            3 => Some(WeightQuantization::UInt4),
104            _ => None,
105        }
106    }
107}
108
109/// Query-time weighting strategy for sparse vector queries
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum QueryWeighting {
113    /// All terms get weight 1.0
114    #[default]
115    One,
116    /// Terms weighted by IDF (inverse document frequency) from global index statistics
117    /// Uses ln(N/df) where N = total docs, df = docs containing dimension
118    Idf,
119    /// Terms weighted by pre-computed IDF from model's idf.json file
120    /// Loaded from HuggingFace model repo. No fallback to global stats.
121    IdfFile,
122}
123
124/// Query-time configuration for sparse vectors
125///
126/// Research-validated query optimization strategies:
127/// - **weight_threshold (0.01-0.05)**: Drop query dimensions with weight below threshold
128///   - Filters low-IDF tokens that add latency without improving relevance
129/// - **max_query_dims (10-20)**: Process only top-k dimensions by weight
130///   - 30-50% latency reduction with <2% nDCG loss (Qiao et al., 2023)
131/// - **heap_factor (0.8)**: Skip blocks with low max score contribution
132///   - ~20% speedup with minor recall loss (SEISMIC-style)
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct SparseQueryConfig {
135    /// HuggingFace tokenizer path/name for query-time tokenization
136    /// Example: "Alibaba-NLP/gte-Qwen2-1.5B-instruct"
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub tokenizer: Option<String>,
139    /// Weighting strategy for tokenized query terms
140    #[serde(default)]
141    pub weighting: QueryWeighting,
142    /// Heap factor for approximate search (SEISMIC-style optimization)
143    /// A block is skipped if its max possible score < heap_factor * threshold
144    ///
145    /// Research recommendation:
146    /// - 1.0 = exact search (default)
147    /// - 0.8 = approximate, ~20% faster with minor recall loss (RECOMMENDED for production)
148    /// - 0.5 = very approximate, much faster but higher recall loss
149    #[serde(default = "default_heap_factor")]
150    pub heap_factor: f32,
151    /// Minimum weight for query dimensions (query-time pruning)
152    /// Dimensions with abs(weight) below this threshold are dropped before search.
153    /// Useful for filtering low-IDF tokens that add latency without improving relevance.
154    ///
155    /// - 0.0 = no filtering (default)
156    /// - 0.01-0.05 = recommended for SPLADE/learned sparse models
157    #[serde(default)]
158    pub weight_threshold: f32,
159    /// Maximum number of query dimensions to process (query pruning)
160    /// Processes only the top-k dimensions by weight
161    ///
162    /// Research recommendation (Multiple papers 2022-2024):
163    /// - None = process all dimensions (default, exact)
164    /// - Some(10-20) = process top 10-20 dimensions only (RECOMMENDED for SPLADE)
165    ///   - 30-50% latency reduction
166    ///   - <2% nDCG@10 loss
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub max_query_dims: Option<usize>,
169    /// Fraction of query dimensions to keep (0.0-1.0), same semantics as
170    /// indexing-time `pruning`: sort by abs(weight) descending,
171    /// keep top fraction. None or 1.0 = no pruning.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub pruning: Option<f32>,
174    /// Minimum number of query dimensions before pruning and weight_threshold
175    /// filtering are applied. Protects short queries from losing most signal.
176    ///
177    /// Default: 4. Set to 0 to always apply pruning/filtering.
178    #[serde(default = "default_min_terms")]
179    pub min_query_dims: usize,
180    /// Maximum number of superblocks to visit (LSP/0 gamma cap).
181    /// 0 = unlimited (default). Only applies to BMP format.
182    #[serde(default)]
183    pub max_superblocks: usize,
184}
185
186fn default_heap_factor() -> f32 {
187    1.0
188}
189
190impl Default for SparseQueryConfig {
191    fn default() -> Self {
192        Self {
193            tokenizer: None,
194            weighting: QueryWeighting::One,
195            heap_factor: 1.0,
196            weight_threshold: 0.0,
197            max_query_dims: None,
198            pruning: None,
199            min_query_dims: 4,
200            max_superblocks: 0,
201        }
202    }
203}
204
205/// Configuration for sparse vector storage
206///
207/// Research-validated optimizations for learned sparse retrieval (SPLADE, uniCOIL, etc.):
208/// - **Weight threshold (0.01-0.05)**: Removes ~30-50% of postings with minimal nDCG impact
209/// - **Posting list pruning (0.1)**: Keeps top 10% per dimension, 50-70% index reduction, <1% nDCG loss
210/// - **Query pruning (top 10-20 dims)**: 30-50% latency reduction, <2% nDCG loss
211/// - **UInt8 quantization**: 4x compression, 1-2% nDCG loss (optimal trade-off)
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213pub struct SparseVectorConfig {
214    /// Index format: MaxScore (DAAT) or BMP (BAAT)
215    #[serde(default, skip_serializing_if = "SparseFormat::is_default")]
216    pub format: SparseFormat,
217    /// Size of dimension/term indices
218    pub index_size: IndexSize,
219    /// Quantization for weights (see WeightQuantization docs for trade-offs)
220    pub weight_quantization: WeightQuantization,
221    /// Minimum weight threshold - weights below this value are not indexed
222    ///
223    /// Research recommendation (Guo et al., 2022; SPLADE v2):
224    /// - 0.01-0.05 for SPLADE models removes ~30-50% of postings
225    /// - Minimal impact on nDCG@10 (<1% loss)
226    /// - Major reduction in index size and query latency
227    #[serde(default)]
228    pub weight_threshold: f32,
229    /// Document-side mass cropping: keep the top-|weight| entries covering
230    /// this fraction of a sparse vector's total |weight| mass; the excessive
231    /// tail is dropped at indexing time.
232    ///
233    /// SPLADE-style vectors concentrate importance in a few head terms; the
234    /// long tail inflates the index and query cost with little relevance
235    /// signal. 0.9-0.95 typically drops 20-40% of postings with <1% nDCG loss.
236    ///
237    /// - None or >= 1.0 = keep all entries (default)
238    /// - Applied after `weight_threshold`; vectors with <= `min_terms`
239    ///   entries are never cropped.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub doc_mass: Option<f32>,
242    /// Block size for posting lists (must be power of 2, default 128 for SIMD)
243    /// Larger blocks = better compression, smaller blocks = faster seeks.
244    /// Used by MaxScore format only.
245    #[serde(default = "default_block_size")]
246    pub block_size: usize,
247    /// BMP block size: number of consecutive doc_ids per block (must be power
248    /// of 2, max 256). Only used when format = Bmp. Uniform across every
249    /// segment of the field — set per field in SDL (`bmp_block_size: N`).
250    /// Smaller = better pruning granularity; larger = smaller grid — the
251    /// dense 4-bit grid is `dims × num_blocks / 2` bytes, so grid memory
252    /// scales as 1/block_size. Default 64; large corpora (10M+ docs at
253    /// 100k-dim vocabularies) should set 256 to bound grid memory
254    /// (docs/bmp-grid-compression.md).
255    #[serde(default = "default_bmp_block_size")]
256    pub bmp_block_size: u32,
257    /// Bits per BMP block-grid cell: 4 (default) or 2. The grid is
258    /// `dims × num_blocks × bits/8` bytes, so 2 halves grid memory; measured
259    /// pruning cost is ~free (+0.4-2.2% blocks scored — the exact u8 sb_grid
260    /// prunes first and shields the block grid; docs/bmp-grid-compression.md).
261    /// Grid bounds are ceil-quantized, so exact top-k results are unchanged
262    /// at any width. Uniform per field across all segments — set in SDL
263    /// (`bmp_grid_bits: 2`) at index creation.
264    #[serde(default = "default_bmp_grid_bits")]
265    pub bmp_grid_bits: u8,
266    /// BMP superblock size: number of consecutive blocks grouped for hierarchical
267    /// pruning (Carlson et al., SIGIR 2025). Must be power of 2.
268    /// Default 64. Set to 0 to disable superblock pruning (flat BMP scoring).
269    /// Only used when format = Bmp.
270    #[serde(default = "default_bmp_superblock_size")]
271    pub bmp_superblock_size: u32,
272    /// Static pruning: fraction of postings to keep per inverted list (SEISMIC-style)
273    /// Lists are sorted by weight descending and truncated to top fraction.
274    ///
275    /// Research recommendation (SPLADE v2, Formal et al., 2021):
276    /// - None = keep all postings (default, exact)
277    /// - Some(0.1) = keep top 10% of postings per dimension
278    ///   - 50-70% index size reduction
279    ///   - <1% nDCG@10 loss
280    ///   - Exploits "concentration of importance" in learned representations
281    ///
282    /// Applied only during initial segment build, not during merge.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub pruning: Option<f32>,
285    /// Query-time configuration (tokenizer, weighting)
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub query_config: Option<SparseQueryConfig>,
288    /// Fixed vocabulary size (number of dimensions) for BMP format.
289    ///
290    /// When set, all BMP segments use the same grid dimensions (rows = dims),
291    /// enabling zero-copy block-copy merge. The grid is indexed by dim_id directly
292    /// (no dim_ids Section C needed).
293    ///
294    /// Required for BMP format. Typical values:
295    /// - SPLADE/BERT: 30522 or 105879 (WordPiece / Unigram vocabulary)
296    /// - uniCOIL: 30522
297    /// - Custom models: set to vocabulary size
298    ///
299    /// If None, BMP builder derives dims from observed data (V10 behavior).
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub dims: Option<u32>,
302    /// Fixed max weight scale for BMP format.
303    ///
304    /// When set, all BMP segments use the same quantization scale
305    /// (`max_weight_scale = max_weight`), eliminating rescaling during merge.
306    ///
307    /// For SPLADE models: 5.0 (covers typical weight range 0-5).
308    /// If None, BMP builder derives scale from data (V10 behavior).
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub max_weight: Option<f32>,
311    /// Minimum number of postings in a dimension before pruning and
312    /// weight_threshold filtering are applied. Protects dimensions with
313    /// very few postings from losing most of their signal.
314    ///
315    /// Default: 4. Set to 0 to always apply pruning/filtering.
316    #[serde(default = "default_min_terms")]
317    pub min_terms: usize,
318}
319
320fn default_block_size() -> usize {
321    128
322}
323
324fn default_bmp_block_size() -> u32 {
325    64
326}
327
328fn default_bmp_grid_bits() -> u8 {
329    4
330}
331
332fn default_bmp_superblock_size() -> u32 {
333    64
334}
335
336fn default_min_terms() -> usize {
337    4
338}
339
340impl Default for SparseVectorConfig {
341    fn default() -> Self {
342        Self {
343            format: SparseFormat::MaxScore,
344            index_size: IndexSize::U32,
345            weight_quantization: WeightQuantization::Float32,
346            weight_threshold: 0.0,
347            doc_mass: None,
348            block_size: 128,
349            bmp_block_size: default_bmp_block_size(),
350            bmp_grid_bits: 4,
351            bmp_superblock_size: 64,
352            pruning: None,
353            query_config: None,
354
355            dims: None,
356            max_weight: None,
357            min_terms: 4,
358        }
359    }
360}
361
362impl SparseVectorConfig {
363    /// SPLADE-optimized config with research-validated defaults
364    ///
365    /// Optimized for SPLADE, uniCOIL, and similar learned sparse retrieval models.
366    /// Based on research findings from:
367    /// - Pati (2025): UInt8 quantization = 4x compression, 1-2% nDCG loss
368    /// - Formal et al. (2021): SPLADE v2 posting list pruning
369    /// - Qiao et al. (2023): Query dimension pruning and approximate search
370    /// - Guo et al. (2022): Weight thresholding for efficiency
371    ///
372    /// Expected performance vs. full precision baseline:
373    /// - Index size: ~15-25% of original (combined effect of all optimizations)
374    /// - Query latency: 40-60% faster
375    /// - Effectiveness: 2-4% nDCG@10 loss (typically acceptable for production)
376    ///
377    /// Vocabulary: ~30K dimensions (fits in u16)
378    pub fn splade() -> Self {
379        Self {
380            format: SparseFormat::MaxScore,
381            index_size: IndexSize::U16,
382            weight_quantization: WeightQuantization::UInt8,
383            weight_threshold: 0.01, // Remove ~30-50% of low-weight postings
384            doc_mass: None,
385            block_size: 128,
386            bmp_block_size: default_bmp_block_size(),
387            bmp_grid_bits: 4,
388            bmp_superblock_size: 64,
389            pruning: Some(0.1), // Keep top 10% per dimension
390            query_config: Some(SparseQueryConfig {
391                tokenizer: None,
392                weighting: QueryWeighting::One,
393                heap_factor: 0.8,         // 20% faster approximate search
394                weight_threshold: 0.01,   // Drop low-IDF query tokens
395                max_query_dims: Some(20), // Process top 20 query dimensions
396                pruning: Some(0.1),       // Keep top 10% of query dims
397                min_query_dims: 4,
398                max_superblocks: 0,
399            }),
400
401            dims: None,
402            max_weight: None,
403            min_terms: 4,
404        }
405    }
406
407    /// SPLADE-optimized config with BMP (Block-Max Pruning) format
408    ///
409    /// Same optimization settings as `splade()` but uses the BMP block-at-a-time
410    /// format (Mallia, Suel & Tonellotto, SIGIR 2024) instead of MaxScore.
411    /// BMP divides the document space into fixed-size blocks and processes them
412    /// in decreasing upper-bound order, enabling aggressive early termination.
413    pub fn splade_bmp() -> Self {
414        Self {
415            format: SparseFormat::Bmp,
416            index_size: IndexSize::U16,
417            weight_quantization: WeightQuantization::UInt8,
418            weight_threshold: 0.01,
419            doc_mass: None,
420            block_size: 128,
421            bmp_block_size: default_bmp_block_size(),
422            bmp_grid_bits: 4,
423            bmp_superblock_size: 64,
424            pruning: Some(0.1),
425            query_config: Some(SparseQueryConfig {
426                tokenizer: None,
427                weighting: QueryWeighting::One,
428                heap_factor: 0.8,
429                weight_threshold: 0.01,
430                max_query_dims: Some(20),
431                pruning: Some(0.1),
432                min_query_dims: 4,
433                max_superblocks: 0,
434            }),
435
436            dims: Some(105879),
437            max_weight: Some(5.0),
438            min_terms: 4,
439        }
440    }
441
442    /// Compact config: Maximum compression (experimental)
443    ///
444    /// Uses aggressive UInt4 quantization for smallest possible index size.
445    /// Expected trade-offs:
446    /// - Index size: ~10-15% of Float32 baseline
447    /// - Effectiveness: ~3-5% nDCG@10 loss
448    ///
449    /// Recommended for: Memory-constrained environments, cache-heavy workloads
450    pub fn compact() -> Self {
451        Self {
452            format: SparseFormat::MaxScore,
453            index_size: IndexSize::U16,
454            weight_quantization: WeightQuantization::UInt4,
455            weight_threshold: 0.02, // Slightly higher threshold for UInt4
456            doc_mass: None,
457            block_size: 128,
458            bmp_block_size: default_bmp_block_size(),
459            bmp_grid_bits: 4,
460            bmp_superblock_size: 64,
461            pruning: Some(0.15), // Keep top 15% per dimension
462            query_config: Some(SparseQueryConfig {
463                tokenizer: None,
464                weighting: QueryWeighting::One,
465                heap_factor: 0.7,         // More aggressive approximate search
466                weight_threshold: 0.02,   // Drop low-IDF query tokens
467                max_query_dims: Some(15), // Fewer query dimensions
468                pruning: Some(0.15),      // Keep top 15% of query dims
469                min_query_dims: 4,
470                max_superblocks: 0,
471            }),
472
473            dims: None,
474            max_weight: None,
475            min_terms: 4,
476        }
477    }
478
479    /// Full precision config: No compression, baseline effectiveness
480    ///
481    /// Use for: Research baselines, when effectiveness is critical
482    pub fn full_precision() -> Self {
483        Self {
484            format: SparseFormat::MaxScore,
485            index_size: IndexSize::U32,
486            weight_quantization: WeightQuantization::Float32,
487            weight_threshold: 0.0,
488            doc_mass: None,
489            block_size: 128,
490            bmp_block_size: default_bmp_block_size(),
491            bmp_grid_bits: 4,
492            bmp_superblock_size: 64,
493            pruning: None,
494            query_config: None,
495
496            dims: None,
497            max_weight: None,
498            min_terms: 4,
499        }
500    }
501
502    /// Conservative config: Mild optimizations, minimal effectiveness loss
503    ///
504    /// Balances compression and effectiveness with conservative defaults.
505    /// Expected trade-offs:
506    /// - Index size: ~40-50% of Float32 baseline
507    /// - Query latency: ~20-30% faster
508    /// - Effectiveness: <1% nDCG@10 loss
509    ///
510    /// Recommended for: Production deployments prioritizing effectiveness
511    pub fn conservative() -> Self {
512        Self {
513            format: SparseFormat::MaxScore,
514            index_size: IndexSize::U32,
515            weight_quantization: WeightQuantization::Float16,
516            weight_threshold: 0.005, // Minimal pruning
517            doc_mass: None,
518            block_size: 128,
519            bmp_block_size: default_bmp_block_size(),
520            bmp_grid_bits: 4,
521            bmp_superblock_size: 64,
522            pruning: None, // No posting list pruning
523            query_config: Some(SparseQueryConfig {
524                tokenizer: None,
525                weighting: QueryWeighting::One,
526                heap_factor: 0.9,         // Nearly exact search
527                weight_threshold: 0.005,  // Minimal query pruning
528                max_query_dims: Some(50), // Process more dimensions
529                pruning: None,            // No fraction-based pruning
530                min_query_dims: 4,
531                max_superblocks: 0,
532            }),
533
534            dims: None,
535            max_weight: None,
536            min_terms: 4,
537        }
538    }
539
540    /// Set weight threshold (builder pattern)
541    pub fn with_weight_threshold(mut self, threshold: f32) -> Self {
542        self.weight_threshold = threshold;
543        self
544    }
545
546    /// Set document-side mass cropping fraction (builder pattern)
547    /// e.g., 0.9 = keep top-weight entries covering 90% of each vector's mass
548    pub fn with_doc_mass(mut self, fraction: f32) -> Self {
549        self.doc_mass = Some(fraction.clamp(0.0, 1.0));
550        self
551    }
552
553    /// Set posting list pruning fraction (builder pattern)
554    /// e.g., 0.1 = keep top 10% of postings per dimension
555    pub fn with_pruning(mut self, fraction: f32) -> Self {
556        self.pruning = Some(fraction.clamp(0.0, 1.0));
557        self
558    }
559
560    /// Bytes per entry (index + weight)
561    pub fn bytes_per_entry(&self) -> f32 {
562        self.index_size.bytes() as f32 + self.weight_quantization.bytes_per_weight()
563    }
564
565    /// Serialize config to a single byte.
566    ///
567    /// Layout: bits 7-4 = IndexSize, bit 3 = format (0=MaxScore, 1=BMP), bits 2-0 = WeightQuantization
568    pub fn to_byte(&self) -> u8 {
569        let format_bit = if self.format == SparseFormat::Bmp {
570            0x08
571        } else {
572            0
573        };
574        ((self.index_size as u8) << 4) | format_bit | (self.weight_quantization as u8)
575    }
576
577    /// Deserialize config from a single byte.
578    ///
579    /// Note: weight_threshold, block_size, bmp_block_size, and query_config are not
580    /// serialized in the byte — they come from the schema.
581    pub fn from_byte(b: u8) -> Option<Self> {
582        let index_size = IndexSize::from_u8((b >> 4) & 0x03)?;
583        let format = if b & 0x08 != 0 {
584            SparseFormat::Bmp
585        } else {
586            SparseFormat::MaxScore
587        };
588        let weight_quantization = WeightQuantization::from_u8(b & 0x07)?;
589        Some(Self {
590            format,
591            index_size,
592            weight_quantization,
593            weight_threshold: 0.0,
594            doc_mass: None,
595            block_size: 128,
596            bmp_block_size: default_bmp_block_size(),
597            bmp_grid_bits: 4,
598            bmp_superblock_size: 64,
599            pruning: None,
600            query_config: None,
601
602            dims: None,
603            max_weight: None,
604            min_terms: 4,
605        })
606    }
607
608    /// Set block size (builder pattern)
609    /// Must be power of 2, recommended: 64, 128, 256
610    pub fn with_block_size(mut self, size: usize) -> Self {
611        self.block_size = size.next_power_of_two();
612        self
613    }
614
615    /// Set query configuration (builder pattern)
616    pub fn with_query_config(mut self, config: SparseQueryConfig) -> Self {
617        self.query_config = Some(config);
618        self
619    }
620}
621
622/// A sparse vector entry: (dimension_id, weight)
623#[derive(Debug, Clone, Copy, PartialEq)]
624pub struct SparseEntry {
625    pub dim_id: u32,
626    pub weight: f32,
627}
628
629/// Sparse vector representation
630#[derive(Debug, Clone, Default)]
631pub struct SparseVector {
632    pub(super) entries: Vec<SparseEntry>,
633}
634
635impl SparseVector {
636    /// Create a new sparse vector
637    pub fn new() -> Self {
638        Self {
639            entries: Vec::new(),
640        }
641    }
642
643    /// Create with pre-allocated capacity
644    pub fn with_capacity(capacity: usize) -> Self {
645        Self {
646            entries: Vec::with_capacity(capacity),
647        }
648    }
649
650    /// Create from dimension IDs and weights
651    pub fn from_entries(dim_ids: &[u32], weights: &[f32]) -> Self {
652        assert_eq!(dim_ids.len(), weights.len());
653        let mut entries: Vec<SparseEntry> = dim_ids
654            .iter()
655            .zip(weights.iter())
656            .map(|(&dim_id, &weight)| SparseEntry { dim_id, weight })
657            .collect();
658        // Sort by dimension ID for efficient intersection
659        entries.sort_by_key(|e| e.dim_id);
660        Self { entries }
661    }
662
663    /// Add an entry (must maintain sorted order by dim_id)
664    pub fn push(&mut self, dim_id: u32, weight: f32) {
665        debug_assert!(
666            self.entries.is_empty() || self.entries.last().unwrap().dim_id < dim_id,
667            "Entries must be added in sorted order by dim_id"
668        );
669        self.entries.push(SparseEntry { dim_id, weight });
670    }
671
672    /// Number of non-zero entries
673    pub fn len(&self) -> usize {
674        self.entries.len()
675    }
676
677    /// Check if empty
678    pub fn is_empty(&self) -> bool {
679        self.entries.is_empty()
680    }
681
682    /// Iterate over entries
683    pub fn iter(&self) -> impl Iterator<Item = &SparseEntry> {
684        self.entries.iter()
685    }
686
687    /// Sort by dimension ID (required for posting list encoding)
688    pub fn sort_by_dim(&mut self) {
689        self.entries.sort_by_key(|e| e.dim_id);
690    }
691
692    /// Sort by weight descending
693    pub fn sort_by_weight_desc(&mut self) {
694        self.entries.sort_by(|a, b| {
695            b.weight
696                .partial_cmp(&a.weight)
697                .unwrap_or(std::cmp::Ordering::Equal)
698        });
699    }
700
701    /// Get top-k entries by weight
702    pub fn top_k(&self, k: usize) -> Vec<SparseEntry> {
703        let mut sorted = self.entries.clone();
704        sorted.sort_by(|a, b| {
705            b.weight
706                .partial_cmp(&a.weight)
707                .unwrap_or(std::cmp::Ordering::Equal)
708        });
709        sorted.truncate(k);
710        sorted
711    }
712
713    /// Compute dot product with another sparse vector
714    pub fn dot(&self, other: &SparseVector) -> f32 {
715        let mut result = 0.0f32;
716        let mut i = 0;
717        let mut j = 0;
718
719        while i < self.entries.len() && j < other.entries.len() {
720            let a = &self.entries[i];
721            let b = &other.entries[j];
722
723            match a.dim_id.cmp(&b.dim_id) {
724                std::cmp::Ordering::Less => i += 1,
725                std::cmp::Ordering::Greater => j += 1,
726                std::cmp::Ordering::Equal => {
727                    result += a.weight * b.weight;
728                    i += 1;
729                    j += 1;
730                }
731            }
732        }
733
734        result
735    }
736
737    /// L2 norm squared
738    pub fn norm_squared(&self) -> f32 {
739        self.entries.iter().map(|e| e.weight * e.weight).sum()
740    }
741
742    /// L2 norm
743    pub fn norm(&self) -> f32 {
744        self.norm_squared().sqrt()
745    }
746
747    /// Prune dimensions below a weight threshold
748    pub fn filter_by_weight(&self, min_weight: f32) -> Self {
749        let entries: Vec<SparseEntry> = self
750            .entries
751            .iter()
752            .filter(|e| e.weight.abs() >= min_weight)
753            .cloned()
754            .collect();
755        Self { entries }
756    }
757}
758
759impl From<Vec<(u32, f32)>> for SparseVector {
760    fn from(pairs: Vec<(u32, f32)>) -> Self {
761        Self {
762            entries: pairs
763                .into_iter()
764                .map(|(dim_id, weight)| SparseEntry { dim_id, weight })
765                .collect(),
766        }
767    }
768}
769
770impl From<SparseVector> for Vec<(u32, f32)> {
771    fn from(vec: SparseVector) -> Self {
772        vec.entries
773            .into_iter()
774            .map(|e| (e.dim_id, e.weight))
775            .collect()
776    }
777}