Skip to main content

nitrite_vector/
distance.rs

1//! Distance metrics for vector similarity search.
2//!
3//! All [`Metric::distance`] functions return a value where **smaller means
4//! closer**, so the HNSW graph can order candidates with a single min-heap
5//! convention regardless of metric. [`Metric::score`] converts a raw distance
6//! back into a human-facing similarity where **higher means more similar**
7//! (used by the RAG layer and `min_score` cutoffs).
8
9use serde::{Deserialize, Serialize};
10
11/// The distance metric used by a vector index.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum Metric {
14    /// Cosine distance (`1 - cosine_similarity`). Vectors are L2-normalized on
15    /// insert, so cosine distance reduces to `1 - dot(a, b)`.
16    Cosine,
17    /// Squared Euclidean (L2) distance. Squared to avoid the `sqrt` on the hot
18    /// path; ordering is identical to true Euclidean distance.
19    Euclidean,
20    /// Negated dot product (`-dot(a, b)`), so that larger dot products (more
21    /// similar) sort first under the smaller-is-closer convention.
22    Dot,
23}
24
25impl Metric {
26    /// Whether stored vectors must be L2-normalized for this metric.
27    #[inline]
28    pub fn normalizes(&self) -> bool {
29        matches!(self, Metric::Cosine)
30    }
31
32    /// Prepares a raw input vector for storage under this metric.
33    ///
34    /// For [`Metric::Cosine`] this L2-normalizes the vector so that later
35    /// distance computations only need a dot product. Other metrics are stored
36    /// as-is.
37    #[inline]
38    pub fn prepare(&self, mut v: Vec<f32>) -> Vec<f32> {
39        if self.normalizes() {
40            normalize(&mut v);
41        }
42        v
43    }
44
45    /// Computes the distance between two prepared vectors of equal length.
46    ///
47    /// Smaller is closer. Callers must pass vectors of the same dimension
48    /// (the index enforces this on write); mismatched lengths are a bug.
49    #[inline]
50    pub fn distance(&self, a: &[f32], b: &[f32]) -> f32 {
51        debug_assert_eq!(a.len(), b.len(), "vector dimension mismatch");
52        match self {
53            Metric::Cosine => 1.0 - dot(a, b),
54            Metric::Euclidean => squared_l2(a, b),
55            Metric::Dot => -dot(a, b),
56        }
57    }
58
59    /// Converts a raw distance into a similarity score where higher is better.
60    ///
61    /// - Cosine: cosine similarity in `[-1, 1]` (`1 - distance`).
62    /// - Dot: the raw dot product (`-distance`).
63    /// - Euclidean: `1 / (1 + euclidean_distance)` in `(0, 1]`.
64    #[inline]
65    pub fn score(&self, distance: f32) -> f32 {
66        match self {
67            Metric::Cosine => 1.0 - distance,
68            Metric::Dot => -distance,
69            Metric::Euclidean => 1.0 / (1.0 + distance.max(0.0).sqrt()),
70        }
71    }
72}
73
74/// L2-normalizes a vector in place. A zero vector is left unchanged.
75#[inline]
76pub fn normalize(v: &mut [f32]) {
77    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
78    if norm > 0.0 {
79        let inv = 1.0 / norm;
80        for x in v.iter_mut() {
81            *x *= inv;
82        }
83    }
84}
85
86/// Dot product of two equal-length slices.
87///
88/// Uses portable SIMD (`f32x8`) with a scalar tail — the distance kernels are
89/// the query hot path, so explicit vectorization here is worth ~2–4× over a
90/// scalar loop across dimensions typical of embeddings.
91#[inline]
92pub fn dot(a: &[f32], b: &[f32]) -> f32 {
93    use wide::f32x8;
94    let n = a.len();
95    let mut acc = f32x8::splat(0.0);
96    let mut i = 0;
97    while i + 8 <= n {
98        let va = f32x8::from(&a[i..i + 8]);
99        let vb = f32x8::from(&b[i..i + 8]);
100        acc = va.mul_add(vb, acc);
101        i += 8;
102    }
103    let mut sum = acc.reduce_add();
104    while i < n {
105        sum += a[i] * b[i];
106        i += 1;
107    }
108    sum
109}
110
111/// Squared Euclidean distance between two equal-length slices.
112#[inline]
113pub fn squared_l2(a: &[f32], b: &[f32]) -> f32 {
114    use wide::f32x8;
115    let n = a.len();
116    let mut acc = f32x8::splat(0.0);
117    let mut i = 0;
118    while i + 8 <= n {
119        let va = f32x8::from(&a[i..i + 8]);
120        let vb = f32x8::from(&b[i..i + 8]);
121        let d = va - vb;
122        acc = d.mul_add(d, acc);
123        i += 8;
124    }
125    let mut sum = acc.reduce_add();
126    while i < n {
127        let d = a[i] - b[i];
128        sum += d * d;
129        i += 1;
130    }
131    sum
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn approx(a: f32, b: f32) {
139        assert!((a - b).abs() < 1e-5, "expected {a} ≈ {b}");
140    }
141
142    #[test]
143    fn dot_and_l2_basics() {
144        let a = [1.0, 2.0, 3.0];
145        let b = [4.0, 5.0, 6.0];
146        approx(dot(&a, &b), 32.0);
147        approx(squared_l2(&a, &b), 27.0); // 9+9+9
148    }
149
150    #[test]
151    fn cosine_of_identical_normalized_vectors_is_zero_distance() {
152        let v = Metric::Cosine.prepare(vec![3.0, 4.0]); // -> [0.6, 0.8]
153        approx(v[0], 0.6);
154        approx(v[1], 0.8);
155        approx(Metric::Cosine.distance(&v, &v), 0.0);
156        approx(Metric::Cosine.score(Metric::Cosine.distance(&v, &v)), 1.0);
157    }
158
159    #[test]
160    fn cosine_of_orthogonal_vectors_is_one() {
161        let a = Metric::Cosine.prepare(vec![1.0, 0.0]);
162        let b = Metric::Cosine.prepare(vec![0.0, 1.0]);
163        approx(Metric::Cosine.distance(&a, &b), 1.0);
164        approx(Metric::Cosine.score(Metric::Cosine.distance(&a, &b)), 0.0);
165    }
166
167    #[test]
168    fn cosine_of_opposite_vectors_is_two() {
169        let a = Metric::Cosine.prepare(vec![1.0, 0.0]);
170        let b = Metric::Cosine.prepare(vec![-1.0, 0.0]);
171        approx(Metric::Cosine.distance(&a, &b), 2.0);
172        approx(Metric::Cosine.score(Metric::Cosine.distance(&a, &b)), -1.0);
173    }
174
175    #[test]
176    fn euclidean_distance_and_score() {
177        let a = vec![0.0, 0.0];
178        let b = vec![3.0, 4.0];
179        // squared distance = 25
180        approx(Metric::Euclidean.distance(&a, &b), 25.0);
181        // score = 1/(1+5) = 0.16666
182        approx(Metric::Euclidean.score(25.0), 1.0 / 6.0);
183        // identical vectors -> score 1.0
184        approx(Metric::Euclidean.score(Metric::Euclidean.distance(&a, &a)), 1.0);
185    }
186
187    #[test]
188    fn dot_metric_orders_larger_dot_first() {
189        let q = vec![1.0, 1.0];
190        let near = vec![2.0, 2.0]; // dot 4
191        let far = vec![0.5, 0.0]; // dot 0.5
192        assert!(Metric::Dot.distance(&q, &near) < Metric::Dot.distance(&q, &far));
193        approx(Metric::Dot.score(Metric::Dot.distance(&q, &near)), 4.0);
194    }
195
196    #[test]
197    fn zero_vector_normalize_is_noop() {
198        let mut v = vec![0.0, 0.0, 0.0];
199        normalize(&mut v);
200        assert_eq!(v, vec![0.0, 0.0, 0.0]);
201    }
202}