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 32 favors pruning granularity; increase
253 /// it explicitly only when the block-grid memory tradeoff requires it
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 SparseVectorConfig::DEFAULT_BMP_BLOCK_SIZE
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 pub const DEFAULT_BMP_BLOCK_SIZE: u32 = 32;
364
365 /// SPLADE-optimized config with research-validated defaults
366 ///
367 /// Optimized for SPLADE, uniCOIL, and similar learned sparse retrieval models.
368 /// Based on research findings from:
369 /// - Pati (2025): UInt8 quantization = 4x compression, 1-2% nDCG loss
370 /// - Formal et al. (2021): SPLADE v2 posting list pruning
371 /// - Qiao et al. (2023): Query dimension pruning and approximate search
372 /// - Guo et al. (2022): Weight thresholding for efficiency
373 ///
374 /// Expected performance vs. full precision baseline:
375 /// - Index size: ~15-25% of original (combined effect of all optimizations)
376 /// - Query latency: 40-60% faster
377 /// - Effectiveness: 2-4% nDCG@10 loss (typically acceptable for production)
378 ///
379 /// Vocabulary: ~30K dimensions (fits in u16)
380 pub fn splade() -> Self {
381 Self {
382 format: SparseFormat::MaxScore,
383 index_size: IndexSize::U16,
384 weight_quantization: WeightQuantization::UInt8,
385 weight_threshold: 0.01, // Remove ~30-50% of low-weight postings
386 doc_mass: None,
387 block_size: 128,
388 bmp_block_size: default_bmp_block_size(),
389 bmp_grid_bits: 4,
390 bmp_superblock_size: 64,
391 pruning: Some(0.1), // Keep top 10% per dimension
392 query_config: Some(SparseQueryConfig {
393 tokenizer: None,
394 weighting: QueryWeighting::One,
395 heap_factor: 0.8, // 20% faster approximate search
396 weight_threshold: 0.01, // Drop low-IDF query tokens
397 max_query_dims: Some(20), // Process top 20 query dimensions
398 pruning: Some(0.1), // Keep top 10% of query dims
399 min_query_dims: 4,
400 max_superblocks: 0,
401 }),
402
403 dims: None,
404 max_weight: None,
405 min_terms: 4,
406 }
407 }
408
409 /// SPLADE-optimized config with BMP (Block-Max Pruning) format
410 ///
411 /// Same optimization settings as `splade()` but uses the BMP block-at-a-time
412 /// format (Mallia, Suel & Tonellotto, SIGIR 2024) instead of MaxScore.
413 /// BMP divides the document space into fixed-size blocks and processes them
414 /// in decreasing upper-bound order, enabling aggressive early termination.
415 pub fn splade_bmp() -> Self {
416 Self {
417 format: SparseFormat::Bmp,
418 index_size: IndexSize::U16,
419 weight_quantization: WeightQuantization::UInt8,
420 weight_threshold: 0.01,
421 doc_mass: None,
422 block_size: 128,
423 bmp_block_size: default_bmp_block_size(),
424 bmp_grid_bits: 4,
425 bmp_superblock_size: 64,
426 pruning: Some(0.1),
427 query_config: Some(SparseQueryConfig {
428 tokenizer: None,
429 weighting: QueryWeighting::One,
430 heap_factor: 0.8,
431 weight_threshold: 0.01,
432 max_query_dims: Some(20),
433 pruning: Some(0.1),
434 min_query_dims: 4,
435 max_superblocks: 0,
436 }),
437
438 dims: Some(105879),
439 max_weight: Some(5.0),
440 min_terms: 4,
441 }
442 }
443
444 /// Compact config: Maximum compression (experimental)
445 ///
446 /// Uses aggressive UInt4 quantization for smallest possible index size.
447 /// Expected trade-offs:
448 /// - Index size: ~10-15% of Float32 baseline
449 /// - Effectiveness: ~3-5% nDCG@10 loss
450 ///
451 /// Recommended for: Memory-constrained environments, cache-heavy workloads
452 pub fn compact() -> Self {
453 Self {
454 format: SparseFormat::MaxScore,
455 index_size: IndexSize::U16,
456 weight_quantization: WeightQuantization::UInt4,
457 weight_threshold: 0.02, // Slightly higher threshold for UInt4
458 doc_mass: None,
459 block_size: 128,
460 bmp_block_size: default_bmp_block_size(),
461 bmp_grid_bits: 4,
462 bmp_superblock_size: 64,
463 pruning: Some(0.15), // Keep top 15% per dimension
464 query_config: Some(SparseQueryConfig {
465 tokenizer: None,
466 weighting: QueryWeighting::One,
467 heap_factor: 0.7, // More aggressive approximate search
468 weight_threshold: 0.02, // Drop low-IDF query tokens
469 max_query_dims: Some(15), // Fewer query dimensions
470 pruning: Some(0.15), // Keep top 15% of query dims
471 min_query_dims: 4,
472 max_superblocks: 0,
473 }),
474
475 dims: None,
476 max_weight: None,
477 min_terms: 4,
478 }
479 }
480
481 /// Full precision config: No compression, baseline effectiveness
482 ///
483 /// Use for: Research baselines, when effectiveness is critical
484 pub fn full_precision() -> Self {
485 Self {
486 format: SparseFormat::MaxScore,
487 index_size: IndexSize::U32,
488 weight_quantization: WeightQuantization::Float32,
489 weight_threshold: 0.0,
490 doc_mass: None,
491 block_size: 128,
492 bmp_block_size: default_bmp_block_size(),
493 bmp_grid_bits: 4,
494 bmp_superblock_size: 64,
495 pruning: None,
496 query_config: None,
497
498 dims: None,
499 max_weight: None,
500 min_terms: 4,
501 }
502 }
503
504 /// Conservative config: Mild optimizations, minimal effectiveness loss
505 ///
506 /// Balances compression and effectiveness with conservative defaults.
507 /// Expected trade-offs:
508 /// - Index size: ~40-50% of Float32 baseline
509 /// - Query latency: ~20-30% faster
510 /// - Effectiveness: <1% nDCG@10 loss
511 ///
512 /// Recommended for: Production deployments prioritizing effectiveness
513 pub fn conservative() -> Self {
514 Self {
515 format: SparseFormat::MaxScore,
516 index_size: IndexSize::U32,
517 weight_quantization: WeightQuantization::Float16,
518 weight_threshold: 0.005, // Minimal pruning
519 doc_mass: None,
520 block_size: 128,
521 bmp_block_size: default_bmp_block_size(),
522 bmp_grid_bits: 4,
523 bmp_superblock_size: 64,
524 pruning: None, // No posting list pruning
525 query_config: Some(SparseQueryConfig {
526 tokenizer: None,
527 weighting: QueryWeighting::One,
528 heap_factor: 0.9, // Nearly exact search
529 weight_threshold: 0.005, // Minimal query pruning
530 max_query_dims: Some(50), // Process more dimensions
531 pruning: None, // No fraction-based pruning
532 min_query_dims: 4,
533 max_superblocks: 0,
534 }),
535
536 dims: None,
537 max_weight: None,
538 min_terms: 4,
539 }
540 }
541
542 /// Set weight threshold (builder pattern)
543 pub fn with_weight_threshold(mut self, threshold: f32) -> Self {
544 self.weight_threshold = threshold;
545 self
546 }
547
548 /// Set document-side mass cropping fraction (builder pattern)
549 /// e.g., 0.9 = keep top-weight entries covering 90% of each vector's mass
550 pub fn with_doc_mass(mut self, fraction: f32) -> Self {
551 self.doc_mass = Some(fraction.clamp(0.0, 1.0));
552 self
553 }
554
555 /// Set posting list pruning fraction (builder pattern)
556 /// e.g., 0.1 = keep top 10% of postings per dimension
557 pub fn with_pruning(mut self, fraction: f32) -> Self {
558 self.pruning = Some(fraction.clamp(0.0, 1.0));
559 self
560 }
561
562 /// Bytes per entry (index + weight)
563 pub fn bytes_per_entry(&self) -> f32 {
564 self.index_size.bytes() as f32 + self.weight_quantization.bytes_per_weight()
565 }
566
567 /// Serialize config to a single byte.
568 ///
569 /// Layout: bits 7-4 = IndexSize, bit 3 = format (0=MaxScore, 1=BMP), bits 2-0 = WeightQuantization
570 pub fn to_byte(&self) -> u8 {
571 let format_bit = if self.format == SparseFormat::Bmp {
572 0x08
573 } else {
574 0
575 };
576 ((self.index_size as u8) << 4) | format_bit | (self.weight_quantization as u8)
577 }
578
579 /// Deserialize config from a single byte.
580 ///
581 /// Note: weight_threshold, block_size, bmp_block_size, and query_config are not
582 /// serialized in the byte — they come from the schema.
583 pub fn from_byte(b: u8) -> Option<Self> {
584 let index_size = IndexSize::from_u8((b >> 4) & 0x03)?;
585 let format = if b & 0x08 != 0 {
586 SparseFormat::Bmp
587 } else {
588 SparseFormat::MaxScore
589 };
590 let weight_quantization = WeightQuantization::from_u8(b & 0x07)?;
591 Some(Self {
592 format,
593 index_size,
594 weight_quantization,
595 weight_threshold: 0.0,
596 doc_mass: None,
597 block_size: 128,
598 bmp_block_size: default_bmp_block_size(),
599 bmp_grid_bits: 4,
600 bmp_superblock_size: 64,
601 pruning: None,
602 query_config: None,
603
604 dims: None,
605 max_weight: None,
606 min_terms: 4,
607 })
608 }
609
610 /// Set block size (builder pattern)
611 /// Must be power of 2, recommended: 64, 128, 256
612 pub fn with_block_size(mut self, size: usize) -> Self {
613 self.block_size = size.next_power_of_two();
614 self
615 }
616
617 /// Set query configuration (builder pattern)
618 pub fn with_query_config(mut self, config: SparseQueryConfig) -> Self {
619 self.query_config = Some(config);
620 self
621 }
622}
623
624/// A sparse vector entry: (dimension_id, weight)
625#[derive(Debug, Clone, Copy, PartialEq)]
626pub struct SparseEntry {
627 pub dim_id: u32,
628 pub weight: f32,
629}
630
631/// Sparse vector representation
632#[derive(Debug, Clone, Default)]
633pub struct SparseVector {
634 pub(super) entries: Vec<SparseEntry>,
635}
636
637impl SparseVector {
638 /// Create a new sparse vector
639 pub fn new() -> Self {
640 Self {
641 entries: Vec::new(),
642 }
643 }
644
645 /// Create with pre-allocated capacity
646 pub fn with_capacity(capacity: usize) -> Self {
647 Self {
648 entries: Vec::with_capacity(capacity),
649 }
650 }
651
652 /// Create from dimension IDs and weights
653 pub fn from_entries(dim_ids: &[u32], weights: &[f32]) -> Self {
654 assert_eq!(dim_ids.len(), weights.len());
655 let mut entries: Vec<SparseEntry> = dim_ids
656 .iter()
657 .zip(weights.iter())
658 .map(|(&dim_id, &weight)| SparseEntry { dim_id, weight })
659 .collect();
660 // Sort by dimension ID for efficient intersection
661 entries.sort_by_key(|e| e.dim_id);
662 Self { entries }
663 }
664
665 /// Add an entry (must maintain sorted order by dim_id)
666 pub fn push(&mut self, dim_id: u32, weight: f32) {
667 debug_assert!(
668 self.entries.is_empty() || self.entries.last().unwrap().dim_id < dim_id,
669 "Entries must be added in sorted order by dim_id"
670 );
671 self.entries.push(SparseEntry { dim_id, weight });
672 }
673
674 /// Number of non-zero entries
675 pub fn len(&self) -> usize {
676 self.entries.len()
677 }
678
679 /// Check if empty
680 pub fn is_empty(&self) -> bool {
681 self.entries.is_empty()
682 }
683
684 /// Iterate over entries
685 pub fn iter(&self) -> impl Iterator<Item = &SparseEntry> {
686 self.entries.iter()
687 }
688
689 /// Sort by dimension ID (required for posting list encoding)
690 pub fn sort_by_dim(&mut self) {
691 self.entries.sort_by_key(|e| e.dim_id);
692 }
693
694 /// Sort by weight descending
695 pub fn sort_by_weight_desc(&mut self) {
696 self.entries.sort_by(|a, b| {
697 b.weight
698 .partial_cmp(&a.weight)
699 .unwrap_or(std::cmp::Ordering::Equal)
700 });
701 }
702
703 /// Get top-k entries by weight
704 pub fn top_k(&self, k: usize) -> Vec<SparseEntry> {
705 let mut sorted = self.entries.clone();
706 sorted.sort_by(|a, b| {
707 b.weight
708 .partial_cmp(&a.weight)
709 .unwrap_or(std::cmp::Ordering::Equal)
710 });
711 sorted.truncate(k);
712 sorted
713 }
714
715 /// Compute dot product with another sparse vector
716 pub fn dot(&self, other: &SparseVector) -> f32 {
717 let mut result = 0.0f32;
718 let mut i = 0;
719 let mut j = 0;
720
721 while i < self.entries.len() && j < other.entries.len() {
722 let a = &self.entries[i];
723 let b = &other.entries[j];
724
725 match a.dim_id.cmp(&b.dim_id) {
726 std::cmp::Ordering::Less => i += 1,
727 std::cmp::Ordering::Greater => j += 1,
728 std::cmp::Ordering::Equal => {
729 result += a.weight * b.weight;
730 i += 1;
731 j += 1;
732 }
733 }
734 }
735
736 result
737 }
738
739 /// L2 norm squared
740 pub fn norm_squared(&self) -> f32 {
741 self.entries.iter().map(|e| e.weight * e.weight).sum()
742 }
743
744 /// L2 norm
745 pub fn norm(&self) -> f32 {
746 self.norm_squared().sqrt()
747 }
748
749 /// Prune dimensions below a weight threshold
750 pub fn filter_by_weight(&self, min_weight: f32) -> Self {
751 let entries: Vec<SparseEntry> = self
752 .entries
753 .iter()
754 .filter(|e| e.weight.abs() >= min_weight)
755 .cloned()
756 .collect();
757 Self { entries }
758 }
759}
760
761impl From<Vec<(u32, f32)>> for SparseVector {
762 fn from(pairs: Vec<(u32, f32)>) -> Self {
763 Self {
764 entries: pairs
765 .into_iter()
766 .map(|(dim_id, weight)| SparseEntry { dim_id, weight })
767 .collect(),
768 }
769 }
770}
771
772impl From<SparseVector> for Vec<(u32, f32)> {
773 fn from(vec: SparseVector) -> Self {
774 vec.entries
775 .into_iter()
776 .map(|e| (e.dim_id, e.weight))
777 .collect()
778 }
779}