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    ///
77    /// For asymmetric models (E5, BGE) this applies the **query** prefix — use
78    /// it for search queries. Use [`embed_document`](Self::embed_document) for
79    /// corpus/passage text so the query/passage asymmetry the model was trained
80    /// with is respected.
81    fn embed(&self, text: &str) -> Result<EmbeddingResult>;
82
83    /// Embed a single **document/passage** text.
84    ///
85    /// Default delegates to [`embed`](Self::embed); providers whose model has a
86    /// distinct passage prefix override this so corpus text gets the right prefix.
87    fn embed_document(&self, text: &str) -> Result<EmbeddingResult> {
88        self.embed(text)
89    }
90
91    /// Embed multiple texts in batch.
92    ///
93    /// The default implementation calls [`embed`](Self::embed) for each text.
94    /// Returns an error on the **first** failure — successful results up to
95    /// that point are discarded. For fine-grained error handling, call
96    /// [`embed`](Self::embed) individually and collect results manually.
97    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
98        texts.iter().map(|t| self.embed(t)).collect()
99    }
100
101    /// Embed multiple **documents/passages** in batch.
102    ///
103    /// Default delegates to [`embed_batch`](Self::embed_batch); asymmetric
104    /// providers override to apply the passage prefix.
105    fn embed_documents(&self, texts: &[&str]) -> Result<Vec<EmbeddingResult>> {
106        self.embed_batch(texts)
107    }
108
109    /// Provider name for display.
110    fn name(&self) -> &str;
111}
112
113/// Split a batch of texts into chunks of at most `max_batch_size`.
114///
115/// Returns an empty vec for empty input. Useful for respecting provider-specific
116/// batch limits (e.g., OpenAI allows 2048 texts per request).
117///
118/// # Errors
119///
120/// Returns an error if `max_batch_size` is 0.
121///
122/// # Example
123///
124/// ```
125/// use llm_kernel::embedding::chunk_batch;
126///
127/// let chunks = chunk_batch(&["a", "b", "c", "d", "e"], 2).unwrap();
128/// assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"], vec!["e"]]);
129/// ```
130pub fn chunk_batch<'a>(texts: &[&'a str], max_batch_size: usize) -> Result<Vec<Vec<&'a str>>> {
131    if max_batch_size == 0 {
132        return Err(KernelError::Embedding("max_batch_size must be > 0".into()));
133    }
134    Ok(texts.chunks(max_batch_size).map(|c| c.to_vec()).collect())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn cosine_similarity_identical() {
143        let v = vec![1.0, 0.0, 0.0];
144        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
145    }
146
147    #[test]
148    fn cosine_similarity_orthogonal() {
149        let a = vec![1.0, 0.0];
150        let b = vec![0.0, 1.0];
151        assert!((cosine_similarity(&a, &b)).abs() < 1e-6);
152    }
153
154    #[test]
155    fn cosine_similarity_opposite() {
156        let a = vec![1.0, 0.0];
157        let b = vec![-1.0, 0.0];
158        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
159    }
160
161    #[test]
162    fn cosine_similarity_empty() {
163        assert_eq!(cosine_similarity(&[], &[]), 0.0f64);
164    }
165
166    #[test]
167    fn cosine_similarity_unequal_len() {
168        assert_eq!(cosine_similarity(&[1.0], &[1.0, 2.0]), 0.0f64);
169    }
170
171    #[test]
172    fn embedding_result_similarity() {
173        let a = EmbeddingResult {
174            vector: vec![1.0, 0.0],
175            text_preview: "a".into(),
176        };
177        let b = EmbeddingResult {
178            vector: vec![0.0, 1.0],
179            text_preview: "b".into(),
180        };
181        assert!((a.cosine_similarity(&b)).abs() < 1e-6);
182    }
183
184    #[test]
185    fn embedding_result_dim() {
186        let e = EmbeddingResult {
187            vector: vec![1.0, 0.0, 0.5],
188            text_preview: "test".into(),
189        };
190        assert_eq!(e.dim(), 3);
191    }
192
193    // Regression: f32 accumulation in 512-dim spaces loses enough precision
194    // that self-similarity deviates from 1.0 by > 1e-6. f64 accumulation
195    // keeps it within 1e-10.
196    #[test]
197    fn cosine_similarity_f64_precision_high_dim() {
198        let scale = (512f64).sqrt().recip() as f32;
199        let v: Vec<f32> = vec![scale; 512];
200        let sim = cosine_similarity(&v, &v);
201        assert!(
202            (sim - 1.0).abs() < 1e-10,
203            "self-similarity too far from 1.0: {sim}"
204        );
205    }
206
207    // Regression: with f32 accumulation, near-identical 384-dim vectors can
208    // produce equal similarity scores, flipping ranking order.
209    #[test]
210    fn cosine_similarity_ranking_preserved() {
211        let n = 384;
212        let base: Vec<f32> = vec![1.0f32; n];
213        let mut nudged = base.clone();
214        nudged[0] = 1.0 + 1e-4;
215        let sim_exact = cosine_similarity(&base, &base);
216        let sim_off = cosine_similarity(&base, &nudged);
217        assert!(
218            sim_exact > sim_off,
219            "ranking flip: self-sim {sim_exact} <= nudged {sim_off}"
220        );
221    }
222
223    #[test]
224    fn chunk_batch_splits_evenly() {
225        let chunks = chunk_batch(&["a", "b", "c", "d"], 2).unwrap();
226        assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"]]);
227    }
228
229    #[test]
230    fn chunk_batch_splits_with_remainder() {
231        let chunks = chunk_batch(&["a", "b", "c", "d", "e"], 2).unwrap();
232        assert_eq!(chunks, vec![vec!["a", "b"], vec!["c", "d"], vec!["e"]]);
233    }
234
235    #[test]
236    fn chunk_batch_empty_input() {
237        let chunks: Vec<Vec<&str>> = chunk_batch(&[], 5).unwrap();
238        assert!(chunks.is_empty());
239    }
240
241    #[test]
242    fn chunk_batch_size_larger_than_input() {
243        let chunks = chunk_batch(&["a", "b"], 10).unwrap();
244        assert_eq!(chunks, vec![vec!["a", "b"]]);
245    }
246
247    #[test]
248    fn chunk_batch_zero_size_errors() {
249        assert!(chunk_batch(&["a"], 0).is_err());
250    }
251}