Skip to main content

vecdb_core/index/
distance.rs

1use crate::types::DistanceMetric;
2
3/// Cosine similarity between two vectors.
4/// Returns 1.0 for identical direction, -1.0 for opposite, 0.0 for orthogonal.
5/// Returns 0.0 when either vector is zero (avoids divide-by-zero).
6pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
7    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
8    let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
9    let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
10    if mag_a == 0.0 || mag_b == 0.0 {
11        return 0.0;
12    }
13    dot / (mag_a * mag_b)
14}
15
16/// Cosine distance = 1 - cosine_similarity. Range [0, 2]; 0 = identical direction.
17pub fn cosine_distance(a: &[f32], b: &[f32]) -> f32 {
18    1.0 - cosine_similarity(a, b)
19}
20
21/// Euclidean (L2) distance between two vectors.
22pub fn euclidean_distance(a: &[f32], b: &[f32]) -> f32 {
23    a.iter()
24        .zip(b.iter())
25        .map(|(x, y)| (x - y) * (x - y))
26        .sum::<f32>()
27        .sqrt()
28}
29
30/// Dot product of two vectors.
31pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
32    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
33}
34
35/// Dot-product distance = 1 - dot_product(a, b).
36/// NOTE: Only meaningful as a proper distance metric for unit-normalized vectors.
37pub fn dot_product_distance(a: &[f32], b: &[f32]) -> f32 {
38    1.0 - dot_product(a, b)
39}
40
41/// Return a unit-normalized copy of `v`.
42/// If magnitude < 1e-10 (near-zero vector), returns `v` unchanged to avoid instability.
43pub fn normalize(v: &[f32]) -> Vec<f32> {
44    let magnitude: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
45    if magnitude < 1e-10 {
46        return v.to_vec();
47    }
48    v.iter().map(|x| x / magnitude).collect()
49}
50
51/// Dot product using 8-element loop unrolling.
52///
53/// LLVM auto-vectorizes to AVX2/SSE on x86-64 with `-C target-cpu=native`.
54/// Falls back to the scalar implementation for `n < 8`.
55///
56/// Performance (dim=1536, AVX2): ~2–4× faster than the scalar loop.
57// Before: scalar iterator chain, ~1 instruction per float
58// After:  8-lane unrolled accumulate, LLVM fuses to VFMADD256 x192 + scalar tail
59pub fn dot_product_simd(a: &[f32], b: &[f32]) -> f32 {
60    let n = a.len().min(b.len());
61    if n < 8 {
62        return dot_product(&a[..n], &b[..n]);
63    }
64    let chunks = n / 8;
65    let rem = n % 8;
66    let (mut s0, mut s1, mut s2, mut s3) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
67    let (mut s4, mut s5, mut s6, mut s7) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
68    for i in 0..chunks {
69        let base = i * 8;
70        s0 += a[base] * b[base];
71        s1 += a[base + 1] * b[base + 1];
72        s2 += a[base + 2] * b[base + 2];
73        s3 += a[base + 3] * b[base + 3];
74        s4 += a[base + 4] * b[base + 4];
75        s5 += a[base + 5] * b[base + 5];
76        s6 += a[base + 6] * b[base + 6];
77        s7 += a[base + 7] * b[base + 7];
78    }
79    let mut acc = s0 + s1 + s2 + s3 + s4 + s5 + s6 + s7;
80    let start = chunks * 8;
81    for i in 0..rem {
82        acc += a[start + i] * b[start + i];
83    }
84    acc
85}
86
87/// Cosine similarity using 8-element loop unrolling.
88///
89/// Single-pass computation of dot product, ‖a‖², and ‖b‖² simultaneously.
90/// LLVM auto-vectorizes to AVX2/SSE on x86-64 with `-C target-cpu=native`.
91/// Falls back to the scalar implementation for `n < 8`.
92///
93/// Performance (dim=1536, AVX2): ~2–4× faster than the two-pass scalar version.
94// Before: three separate iterator passes (dot, mag_a, mag_b)
95// After:  single 8-lane pass accumulating all three quantities in parallel
96pub fn cosine_similarity_simd(a: &[f32], b: &[f32]) -> f32 {
97    let n = a.len().min(b.len());
98    if n < 8 {
99        return cosine_similarity(&a[..n], &b[..n]);
100    }
101    let chunks = n / 8;
102    let rem = n % 8;
103    let (mut d0, mut d1, mut d2, mut d3) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
104    let (mut d4, mut d5, mut d6, mut d7) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
105    let (mut aa0, mut aa1, mut aa2, mut aa3) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
106    let (mut aa4, mut aa5, mut aa6, mut aa7) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
107    let (mut bb0, mut bb1, mut bb2, mut bb3) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
108    let (mut bb4, mut bb5, mut bb6, mut bb7) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
109    for i in 0..chunks {
110        let base = i * 8;
111        let (a0, a1, a2, a3) = (a[base], a[base + 1], a[base + 2], a[base + 3]);
112        let (a4, a5, a6, a7) = (a[base + 4], a[base + 5], a[base + 6], a[base + 7]);
113        let (b0, b1, b2, b3) = (b[base], b[base + 1], b[base + 2], b[base + 3]);
114        let (b4, b5, b6, b7) = (b[base + 4], b[base + 5], b[base + 6], b[base + 7]);
115        d0 += a0 * b0;
116        d1 += a1 * b1;
117        d2 += a2 * b2;
118        d3 += a3 * b3;
119        d4 += a4 * b4;
120        d5 += a5 * b5;
121        d6 += a6 * b6;
122        d7 += a7 * b7;
123        aa0 += a0 * a0;
124        aa1 += a1 * a1;
125        aa2 += a2 * a2;
126        aa3 += a3 * a3;
127        aa4 += a4 * a4;
128        aa5 += a5 * a5;
129        aa6 += a6 * a6;
130        aa7 += a7 * a7;
131        bb0 += b0 * b0;
132        bb1 += b1 * b1;
133        bb2 += b2 * b2;
134        bb3 += b3 * b3;
135        bb4 += b4 * b4;
136        bb5 += b5 * b5;
137        bb6 += b6 * b6;
138        bb7 += b7 * b7;
139    }
140    let mut dot = d0 + d1 + d2 + d3 + d4 + d5 + d6 + d7;
141    let mut mag_a_sq = aa0 + aa1 + aa2 + aa3 + aa4 + aa5 + aa6 + aa7;
142    let mut mag_b_sq = bb0 + bb1 + bb2 + bb3 + bb4 + bb5 + bb6 + bb7;
143    let start = chunks * 8;
144    for i in 0..rem {
145        let ai = a[start + i];
146        let bi = b[start + i];
147        dot += ai * bi;
148        mag_a_sq += ai * ai;
149        mag_b_sq += bi * bi;
150    }
151    let mag_a = mag_a_sq.sqrt();
152    let mag_b = mag_b_sq.sqrt();
153    if mag_a == 0.0 || mag_b == 0.0 {
154        return 0.0;
155    }
156    dot / (mag_a * mag_b)
157}
158
159/// Euclidean (L2) distance using 8-element loop unrolling.
160///
161/// Eight independent squared-difference accumulators give LLVM the structure to
162/// vectorize (SSE at the baseline target, AVX with `-C target-cpu=native`).
163/// This is the hot inner loop of HNSW construction and search for the Euclidean
164/// metric, so speeding it up speeds both index build and query. Falls back to
165/// the scalar path for `n < 8`.
166pub fn euclidean_distance_simd(a: &[f32], b: &[f32]) -> f32 {
167    let n = a.len().min(b.len());
168    if n < 8 {
169        return euclidean_distance(&a[..n], &b[..n]);
170    }
171    let chunks = n / 8;
172    let rem = n % 8;
173    let (mut s0, mut s1, mut s2, mut s3) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
174    let (mut s4, mut s5, mut s6, mut s7) = (0.0f32, 0.0f32, 0.0f32, 0.0f32);
175    for i in 0..chunks {
176        let base = i * 8;
177        let d0 = a[base] - b[base];
178        let d1 = a[base + 1] - b[base + 1];
179        let d2 = a[base + 2] - b[base + 2];
180        let d3 = a[base + 3] - b[base + 3];
181        let d4 = a[base + 4] - b[base + 4];
182        let d5 = a[base + 5] - b[base + 5];
183        let d6 = a[base + 6] - b[base + 6];
184        let d7 = a[base + 7] - b[base + 7];
185        s0 += d0 * d0;
186        s1 += d1 * d1;
187        s2 += d2 * d2;
188        s3 += d3 * d3;
189        s4 += d4 * d4;
190        s5 += d5 * d5;
191        s6 += d6 * d6;
192        s7 += d7 * d7;
193    }
194    let mut acc = s0 + s1 + s2 + s3 + s4 + s5 + s6 + s7;
195    let start = chunks * 8;
196    for i in 0..rem {
197        let d = a[start + i] - b[start + i];
198        acc += d * d;
199    }
200    acc.sqrt()
201}
202
203/// Dispatch distance computation based on `metric`. All three metrics use
204/// SIMD-unrolled kernels.
205pub fn compute_distance(a: &[f32], b: &[f32], metric: &DistanceMetric) -> f32 {
206    match metric {
207        DistanceMetric::Cosine => 1.0 - cosine_similarity_simd(a, b),
208        DistanceMetric::Euclidean => euclidean_distance_simd(a, b),
209        DistanceMetric::DotProduct => 1.0 - dot_product_simd(a, b),
210    }
211}
212
213/// Convert a `compute_distance` value into a similarity score in which higher is
214/// better (the ranking/hydration convention used across all index backends).
215pub fn to_score(dist: f32, metric: &DistanceMetric) -> f32 {
216    match metric {
217        DistanceMetric::Cosine => 1.0 - dist,
218        DistanceMetric::Euclidean => 1.0 / (1.0 + dist),
219        DistanceMetric::DotProduct => 1.0 - dist,
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_cosine_identical() {
229        let v = vec![1.0f32, 2.0, 3.0];
230        let sim = cosine_similarity(&v, &v);
231        assert!((sim - 1.0).abs() < f32::EPSILON * 10.0);
232    }
233
234    #[test]
235    fn test_cosine_orthogonal() {
236        let a = vec![1.0f32, 0.0];
237        let b = vec![0.0f32, 1.0];
238        let sim = cosine_similarity(&a, &b);
239        assert!(sim.abs() < f32::EPSILON * 10.0);
240    }
241
242    #[test]
243    fn test_cosine_opposite() {
244        let a = vec![1.0f32, 0.0];
245        let b = vec![-1.0f32, 0.0];
246        let sim = cosine_similarity(&a, &b);
247        assert!((sim + 1.0).abs() < f32::EPSILON * 10.0);
248    }
249
250    #[test]
251    fn test_euclidean_zero() {
252        let v = vec![3.0f32, 1.0, -2.0];
253        assert_eq!(euclidean_distance(&v, &v), 0.0);
254    }
255
256    #[test]
257    fn test_euclidean_known() {
258        let a = vec![0.0f32, 0.0];
259        let b = vec![3.0f32, 4.0];
260        let d = euclidean_distance(&a, &b);
261        assert!((d - 5.0).abs() < 1e-6);
262    }
263
264    #[test]
265    fn test_euclidean_simd_matches_scalar() {
266        // Parity across the 8-lane boundary and a remainder tail (dim 130).
267        let a: Vec<f32> = (0..130).map(|i| (i as f32) * 0.3 - 7.0).collect();
268        let b: Vec<f32> = (0..130).map(|i| (i as f32).sin() * 5.0).collect();
269        let s = euclidean_distance(&a, &b);
270        let v = euclidean_distance_simd(&a, &b);
271        assert!((s - v).abs() < 1e-2, "scalar {s} vs simd {v}");
272        // Short path (< 8) must fall back and agree exactly.
273        assert_eq!(
274            euclidean_distance(&a[..5], &b[..5]),
275            euclidean_distance_simd(&a[..5], &b[..5])
276        );
277    }
278
279    #[test]
280    fn test_normalize() {
281        let v = vec![3.0f32, 4.0];
282        let n = normalize(&v);
283        let mag: f32 = n.iter().map(|x| x * x).sum::<f32>().sqrt();
284        assert!((mag - 1.0).abs() < 1e-6);
285    }
286
287    #[test]
288    fn test_zero_vector_cosine() {
289        let zero = vec![0.0f32, 0.0, 0.0];
290        let other = vec![1.0f32, 2.0, 3.0];
291        // Must not panic, must return 0.0
292        let sim = cosine_similarity(&zero, &other);
293        assert_eq!(sim, 0.0);
294    }
295}