Skip to main content

llm_kernel/embedding/
types.rs

1//! Embedding types and trait definitions.
2
3use crate::error::{KernelError, Result};
4
5/// A single embedding result.
6#[derive(Debug, Clone)]
7pub struct EmbeddingResult {
8    /// The embedding vector.
9    pub vector: Vec<f32>,
10    /// Source text (for debugging).
11    pub text_preview: String,
12}
13
14impl EmbeddingResult {
15    /// Dimensionality of the embedding vector.
16    pub fn dim(&self) -> usize {
17        self.vector.len()
18    }
19
20    /// Compute cosine similarity between two embedding results.
21    pub fn cosine_similarity(&self, other: &EmbeddingResult) -> f64 {
22        cosine_similarity(&self.vector, &other.vector)
23    }
24}
25
26/// Returns up to 64 chars of `text`, appending `…` if truncated.
27///
28/// Uses character boundaries, so multibyte UTF-8 input never panics.
29#[allow(dead_code)]
30pub(crate) fn text_preview(text: &str) -> String {
31    match text.char_indices().nth(64) {
32        Some((i, _)) => format!("{}…", &text[..i]),
33        None => text.to_string(),
34    }
35}
36
37/// Normalize a vector in-place to unit length (L2 norm).
38///
39/// No-op on zero vectors to avoid division by zero.
40pub fn normalize(v: &mut [f32]) {
41    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
42    if norm > 0.0 {
43        v.iter_mut().for_each(|x| *x /= norm);
44    }
45}
46
47/// Compute cosine similarity between two f32 vectors.
48///
49/// Accumulates dot product and squared norms in f64 to avoid precision
50/// loss in high-dimensional spaces (384–1024 dims) where f32 rounding
51/// can flip ranking order between near-identical candidates.
52pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
53    if a.len() != b.len() || a.is_empty() {
54        return 0.0;
55    }
56    let (dot, sum_a, sum_b) =
57        a.iter()
58            .zip(b.iter())
59            .fold((0.0f64, 0.0f64, 0.0f64), |(dot, sa, sb), (&x, &y)| {
60                let x = x as f64;
61                let y = y as f64;
62                (dot + x * y, sa + x * x, sb + y * y)
63            });
64    let denom = sum_a.sqrt() * sum_b.sqrt();
65    if denom == 0.0 { 0.0 } else { dot / denom }
66}
67
68/// Trait for embedding providers.
69///
70/// Implementations may use local models (candle, ONNX) or remote APIs (OpenAI).
71pub trait EmbeddingProvider: Send + Sync {
72    /// The dimensionality of the embedding vectors.
73    fn dim(&self) -> usize;
74
75    /// Embed a single text string.
76    fn embed(&self, text: &str) -> Result<EmbeddingResult>;
77
78    /// Embed multiple texts in batch.
79    ///
80    /// The default implementation calls [`embed`](Self::embed) for each text.
81    /// Returns an error on the **first** failure — successful results up to
82    /// that point are discarded. For fine-grained error handling, call
83    /// [`embed`](Self::embed) individually and collect results manually.
84    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
85        texts.iter().map(|t| self.embed(t)).collect()
86    }
87
88    /// Provider name for display.
89    fn name(&self) -> &str;
90}
91
92/// Split a batch of texts into chunks of at most `max_batch_size`.
93///
94/// Returns an empty vec for empty input. Useful for respecting provider-specific
95/// batch limits (e.g., OpenAI allows 2048 texts per request).
96///
97/// # Errors
98///
99/// Returns an error if `max_batch_size` is 0.
100///
101/// # Example
102///
103/// ```
104/// use llm_kernel::embedding::chunk_batch;
105///
106/// let chunks = chunk_batch(&["a", "b", "c", "d", "e"], 2).unwrap();
107/// assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"], vec!["e"]]);
108/// ```
109pub fn chunk_batch<'a>(texts: &[&'a str], max_batch_size: usize) -> Result<Vec<Vec<&'a str>>> {
110    if max_batch_size == 0 {
111        return Err(KernelError::Embedding("max_batch_size must be > 0".into()));
112    }
113    Ok(texts.chunks(max_batch_size).map(|c| c.to_vec()).collect())
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn cosine_similarity_identical() {
122        let v = vec![1.0, 0.0, 0.0];
123        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
124    }
125
126    #[test]
127    fn cosine_similarity_orthogonal() {
128        let a = vec![1.0, 0.0];
129        let b = vec![0.0, 1.0];
130        assert!((cosine_similarity(&a, &b)).abs() < 1e-6);
131    }
132
133    #[test]
134    fn cosine_similarity_opposite() {
135        let a = vec![1.0, 0.0];
136        let b = vec![-1.0, 0.0];
137        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
138    }
139
140    #[test]
141    fn cosine_similarity_empty() {
142        assert_eq!(cosine_similarity(&[], &[]), 0.0f64);
143    }
144
145    #[test]
146    fn cosine_similarity_unequal_len() {
147        assert_eq!(cosine_similarity(&[1.0], &[1.0, 2.0]), 0.0f64);
148    }
149
150    #[test]
151    fn embedding_result_similarity() {
152        let a = EmbeddingResult {
153            vector: vec![1.0, 0.0],
154            text_preview: "a".into(),
155        };
156        let b = EmbeddingResult {
157            vector: vec![0.0, 1.0],
158            text_preview: "b".into(),
159        };
160        assert!((a.cosine_similarity(&b)).abs() < 1e-6);
161    }
162
163    #[test]
164    fn embedding_result_dim() {
165        let e = EmbeddingResult {
166            vector: vec![1.0, 0.0, 0.5],
167            text_preview: "test".into(),
168        };
169        assert_eq!(e.dim(), 3);
170    }
171
172    // Regression: f32 accumulation in 512-dim spaces loses enough precision
173    // that self-similarity deviates from 1.0 by > 1e-6. f64 accumulation
174    // keeps it within 1e-10.
175    #[test]
176    fn cosine_similarity_f64_precision_high_dim() {
177        let scale = (512f64).sqrt().recip() as f32;
178        let v: Vec<f32> = vec![scale; 512];
179        let sim = cosine_similarity(&v, &v);
180        assert!(
181            (sim - 1.0).abs() < 1e-10,
182            "self-similarity too far from 1.0: {sim}"
183        );
184    }
185
186    // Regression: with f32 accumulation, near-identical 384-dim vectors can
187    // produce equal similarity scores, flipping ranking order.
188    #[test]
189    fn cosine_similarity_ranking_preserved() {
190        let n = 384;
191        let base: Vec<f32> = vec![1.0f32; n];
192        let mut nudged = base.clone();
193        nudged[0] = 1.0 + 1e-4;
194        let sim_exact = cosine_similarity(&base, &base);
195        let sim_off = cosine_similarity(&base, &nudged);
196        assert!(
197            sim_exact > sim_off,
198            "ranking flip: self-sim {sim_exact} <= nudged {sim_off}"
199        );
200    }
201
202    #[test]
203    fn chunk_batch_splits_evenly() {
204        let chunks = chunk_batch(&["a", "b", "c", "d"], 2).unwrap();
205        assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"]]);
206    }
207
208    #[test]
209    fn chunk_batch_splits_with_remainder() {
210        let chunks = chunk_batch(&["a", "b", "c", "d", "e"], 2).unwrap();
211        assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"], vec!["e"]]);
212    }
213
214    #[test]
215    fn chunk_batch_empty_input() {
216        let chunks: Vec<Vec<&str>> = chunk_batch(&[], 5).unwrap();
217        assert!(chunks.is_empty());
218    }
219
220    #[test]
221    fn chunk_batch_size_larger_than_input() {
222        let chunks = chunk_batch(&["a", "b"], 10).unwrap();
223        assert_eq!(chunks, vec![vec!["a", "b"]]);
224    }
225
226    #[test]
227    fn chunk_batch_zero_size_errors() {
228        assert!(chunk_batch(&["a"], 0).is_err());
229    }
230}