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}