Skip to main content

llm_kernel/embedding/
sparse.rs

1//! Sparse (lexical) vectors for hybrid retrieval.
2//!
3//! A [`SparseVector`] is the learned-lexical counterpart to a dense embedding —
4//! for example BGE-M3's sparse head or SPLADE. It pairs vocabulary indices with
5//! weights, and is what pgvector stores in a `sparsevec` column.
6//!
7//! Combining a dense and a sparse branch is what makes hybrid retrieval work:
8//! the dense side matches paraphrases, the sparse side matches exact terms
9//! (identifiers, statute numbers, rare jargon) that dense vectors blur away.
10
11/// A sparse vector — `indices[i]` carries weight `values[i]`.
12///
13/// Indices are 0-based vocabulary positions, as emitted by the model. Backends
14/// convert to their own convention on write (pgvector's text form is 1-based).
15/// Elements are kept sorted by index with zero weights removed, which is what
16/// `sparsevec` expects and what makes intersection cheap.
17#[derive(Debug, Clone, PartialEq, Default)]
18pub struct SparseVector {
19    indices: Vec<u32>,
20    values: Vec<f32>,
21}
22
23impl SparseVector {
24    /// Build from parallel `indices` / `values`.
25    ///
26    /// Zero weights are dropped and the remaining elements are sorted by index.
27    /// Returns `None` when the two slices disagree in length — a mismatch means
28    /// the caller lost the pairing and silently truncating would corrupt scores.
29    ///
30    /// Both `+0.0` and `-0.0` are dropped: `v != 0.0` is `false` for both
31    /// (`-0.0 == 0.0` per IEEE-754), so a signed zero cannot sneak through and
32    /// later tie-break against a genuinely nonzero weight in
33    /// [`prune_top_k`](Self::prune_top_k).
34    pub fn new(indices: Vec<u32>, values: Vec<f32>) -> Option<Self> {
35        if indices.len() != values.len() {
36            return None;
37        }
38        let mut pairs: Vec<(u32, f32)> = indices
39            .into_iter()
40            .zip(values)
41            // `!= 0.0` admits neither +0.0 nor -0.0 (IEEE-754: -0.0 == 0.0).
42            .filter(|(_, v)| *v != 0.0)
43            .collect();
44        pairs.sort_unstable_by_key(|(i, _)| *i);
45        let (indices, values) = pairs.into_iter().unzip();
46        Some(Self { indices, values })
47    }
48
49    /// Vocabulary indices, ascending.
50    pub fn indices(&self) -> &[u32] {
51        &self.indices
52    }
53
54    /// Weights, aligned with [`indices`](Self::indices).
55    pub fn values(&self) -> &[f32] {
56        &self.values
57    }
58
59    /// Number of non-zero elements.
60    pub fn nnz(&self) -> usize {
61        self.indices.len()
62    }
63
64    /// Whether the vector carries no weight at all.
65    pub fn is_empty(&self) -> bool {
66        self.indices.is_empty()
67    }
68
69    /// Keep only the `k` highest-magnitude weights, still sorted by index.
70    ///
71    /// Lexical vectors from models like BGE-M3 carry a long tail of near-zero
72    /// weights that costs storage and search time while contributing almost
73    /// nothing to the score. pgvector also refuses to build an HNSW index over
74    /// a `sparsevec` beyond a bounded number of non-zero elements (1,000 as of
75    /// pgvector 0.8), so long vectors *must* be pruned before indexing.
76    pub fn prune_top_k(&self, k: usize) -> SparseVector {
77        if k >= self.nnz() {
78            return self.clone();
79        }
80        if k == 0 {
81            return SparseVector::default();
82        }
83        let mut order: Vec<usize> = (0..self.nnz()).collect();
84        // Highest magnitude first; ties broken by lower index for determinism.
85        order.sort_unstable_by(|&a, &b| {
86            self.values[b]
87                .abs()
88                .total_cmp(&self.values[a].abs())
89                .then(self.indices[a].cmp(&self.indices[b]))
90        });
91        order.truncate(k);
92        order.sort_unstable_by_key(|&i| self.indices[i]);
93        SparseVector {
94            indices: order.iter().map(|&i| self.indices[i]).collect(),
95            values: order.iter().map(|&i| self.values[i]).collect(),
96        }
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn new_rejects_length_mismatch() {
106        assert!(SparseVector::new(vec![1, 2], vec![0.5]).is_none());
107    }
108
109    #[test]
110    fn new_drops_zeros_and_sorts_by_index() {
111        let sv = SparseVector::new(vec![5, 1, 3], vec![0.2, 0.9, 0.0]).unwrap();
112        assert_eq!(sv.indices(), &[1, 5]);
113        assert_eq!(sv.values(), &[0.9, 0.2]);
114        assert_eq!(sv.nnz(), 2);
115        assert!(!sv.is_empty());
116    }
117
118    #[test]
119    fn new_drops_signed_zero() {
120        // IEEE-754 makes `-0.0 == 0.0`, so `!= 0.0` filters the signed zero
121        // just like the unsigned one. A signed zero carries no weight and would
122        // otherwise tie-break against a real near-zero value in `prune_top_k`.
123        let sv = SparseVector::new(vec![1, 2], vec![-0.0, 0.5]).unwrap();
124        assert_eq!(sv.indices(), &[2]);
125        assert_eq!(sv.values(), &[0.5]);
126    }
127
128    #[test]
129    fn empty_vector_is_empty() {
130        let sv = SparseVector::new(vec![], vec![]).unwrap();
131        assert!(sv.is_empty());
132        assert_eq!(sv.nnz(), 0);
133    }
134
135    #[test]
136    fn prune_keeps_highest_magnitude_and_index_order() {
137        let sv = SparseVector::new(vec![1, 2, 3, 4], vec![0.1, 0.9, -0.7, 0.3]).unwrap();
138        let p = sv.prune_top_k(2);
139        // 0.9 (idx 2) and -0.7 (idx 3) survive; magnitude, not sign, decides.
140        assert_eq!(p.indices(), &[2, 3]);
141        assert_eq!(p.values(), &[0.9, -0.7]);
142    }
143
144    #[test]
145    fn prune_is_noop_when_k_covers_everything() {
146        let sv = SparseVector::new(vec![1, 2], vec![0.5, 0.4]).unwrap();
147        assert_eq!(sv.prune_top_k(2), sv);
148        assert_eq!(sv.prune_top_k(99), sv);
149    }
150
151    #[test]
152    fn prune_to_zero_yields_empty() {
153        let sv = SparseVector::new(vec![1, 2], vec![0.5, 0.4]).unwrap();
154        assert!(sv.prune_top_k(0).is_empty());
155    }
156}