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