Skip to main content

rlm_rs/embedding/
mod.rs

1//! Embedding generation for semantic search.
2//!
3//! Provides embedding generation using fastembed (when available) or a
4//! hash-based fallback for deterministic pseudo-embeddings.
5//!
6//! # Feature Flags
7//!
8//! - `fastembed-embeddings`: Enables `FastEmbed` with BGE-M3 (1024 dimensions, 8192 token max)
9//! - Without the feature: Uses hash-based fallback (deterministic but not semantic)
10
11mod fallback;
12
13#[cfg(feature = "fastembed-embeddings")]
14mod fastembed_impl;
15
16pub use fallback::FallbackEmbedder;
17
18#[cfg(feature = "fastembed-embeddings")]
19pub use fastembed_impl::FastEmbedEmbedder;
20
21use crate::Result;
22
23/// Default embedding dimensions for the BGE-M3 model.
24///
25/// This is the authoritative source for embedding dimensions across the codebase.
26/// All vector backends should use this constant for consistency.
27pub const DEFAULT_DIMENSIONS: usize = 1024;
28
29/// Trait for embedding generators.
30///
31/// Implementations must be thread-safe (`Send + Sync`) to support parallel
32/// embedding generation during chunk loading.
33///
34/// # Examples
35///
36/// ```
37/// use rlm_rs::embedding::{Embedder, FallbackEmbedder, DEFAULT_DIMENSIONS};
38///
39/// let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
40/// let embedding = embedder.embed("Hello, world!").unwrap();
41/// assert_eq!(embedding.len(), DEFAULT_DIMENSIONS);
42/// ```
43pub trait Embedder: Send + Sync {
44    /// Returns the embedding dimensions.
45    fn dimensions(&self) -> usize;
46
47    /// Returns the model name/version identifier.
48    ///
49    /// This is stored with embeddings to detect model changes.
50    fn model_name(&self) -> &'static str;
51
52    /// Generates an embedding for the given text.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error if embedding generation fails.
57    fn embed(&self, text: &str) -> Result<Vec<f32>>;
58
59    /// Generates embeddings for multiple texts.
60    ///
61    /// The default implementation calls `embed` for each text sequentially.
62    /// Implementations may override this for batch optimization.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if embedding generation fails for any text.
67    fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
68        texts.iter().map(|t| self.embed(t)).collect()
69    }
70}
71
72/// Creates the default embedder based on available features.
73///
74/// - With `fastembed-embeddings`: Returns `FastEmbedEmbedder`
75/// - Without: Returns `FallbackEmbedder`
76///
77/// # Errors
78///
79/// Returns an error if embedder initialization fails.
80#[cfg(feature = "fastembed-embeddings")]
81pub fn create_embedder() -> Result<Box<dyn Embedder>> {
82    Ok(Box::new(FastEmbedEmbedder::new()?))
83}
84
85/// Creates the default embedder based on available features.
86///
87/// - With `fastembed-embeddings`: Returns `FastEmbedEmbedder`
88/// - Without: Returns `FallbackEmbedder`
89///
90/// # Errors
91///
92/// Returns an error if embedder initialization fails (never fails for fallback).
93#[cfg(not(feature = "fastembed-embeddings"))]
94pub fn create_embedder() -> Result<Box<dyn Embedder>> {
95    Ok(Box::new(FallbackEmbedder::new(DEFAULT_DIMENSIONS)))
96}
97
98/// Computes cosine similarity between two embedding vectors.
99///
100/// Returns a value between -1.0 (opposite) and 1.0 (identical).
101/// For normalized vectors (L2 norm = 1), this is equivalent to the dot product.
102///
103/// # Panics
104///
105/// Does not panic but returns 0.0 if vectors have different lengths or zero magnitude.
106#[must_use]
107pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
108    if a.len() != b.len() {
109        return 0.0;
110    }
111
112    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
113    let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
114    let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
115
116    if mag_a == 0.0 || mag_b == 0.0 {
117        return 0.0;
118    }
119
120    dot / (mag_a * mag_b)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    /// Returns the embedder, or `None` when the embedding model cannot be
128    /// loaded. `create_embedder()` is lazy — the network-dependent Hugging
129    /// Face model download happens on the first `embed()` call — so the
130    /// helper forces one tiny embedding and treats any failure as a skip.
131    fn embedder_or_skip(test: &str) -> Option<Box<dyn Embedder>> {
132        match create_embedder().and_then(|e| e.embed("warmup probe").map(|_| e)) {
133            Ok(embedder) => Some(embedder),
134            Err(err) => {
135                eprintln!("skipping {test}: embedding model unavailable: {err}");
136                None
137            }
138        }
139    }
140
141    /// Minimal embedder that does NOT override `embed_batch`, so tests can
142    /// exercise the default trait implementation deterministically.
143    struct StubEmbedder;
144
145    impl Embedder for StubEmbedder {
146        fn dimensions(&self) -> usize {
147            3
148        }
149
150        fn model_name(&self) -> &'static str {
151            "stub"
152        }
153
154        fn embed(&self, _text: &str) -> Result<Vec<f32>> {
155            Ok(vec![1.0, 2.0, 3.0])
156        }
157    }
158
159    #[test]
160    fn test_cosine_similarity_identical() {
161        let a = vec![1.0, 0.0, 0.0];
162        let b = vec![1.0, 0.0, 0.0];
163        let sim = cosine_similarity(&a, &b);
164        assert!((sim - 1.0).abs() < 1e-6);
165    }
166
167    #[test]
168    fn test_cosine_similarity_orthogonal() {
169        let a = vec![1.0, 0.0, 0.0];
170        let b = vec![0.0, 1.0, 0.0];
171        let sim = cosine_similarity(&a, &b);
172        assert!(sim.abs() < 1e-6);
173    }
174
175    #[test]
176    fn test_cosine_similarity_opposite() {
177        let a = vec![1.0, 0.0, 0.0];
178        let b = vec![-1.0, 0.0, 0.0];
179        let sim = cosine_similarity(&a, &b);
180        assert!((sim + 1.0).abs() < 1e-6);
181    }
182
183    #[test]
184    fn test_cosine_similarity_different_lengths() {
185        let a = vec![1.0, 0.0];
186        let b = vec![1.0, 0.0, 0.0];
187        let sim = cosine_similarity(&a, &b);
188        assert!(sim.abs() < 1e-6);
189    }
190
191    #[test]
192    fn test_cosine_similarity_zero_vector() {
193        let a = vec![0.0, 0.0, 0.0];
194        let b = vec![1.0, 0.0, 0.0];
195        let sim = cosine_similarity(&a, &b);
196        assert!(sim.abs() < 1e-6);
197    }
198
199    #[test]
200    fn test_create_embedder() {
201        let Some(embedder) = embedder_or_skip("test_create_embedder") else {
202            return;
203        };
204        assert_eq!(embedder.dimensions(), DEFAULT_DIMENSIONS);
205    }
206
207    #[test]
208    fn test_embed_batch_default_impl() {
209        // StubEmbedder does not override embed_batch, so this exercises the
210        // default trait implementation — deterministically, no model needed.
211        let embedder = StubEmbedder;
212        let texts = vec!["hello", "world", "test"];
213        let embeddings = embedder.embed_batch(&texts).unwrap();
214
215        assert_eq!(embeddings.len(), 3);
216        for embedding in &embeddings {
217            assert_eq!(embedding.len(), embedder.dimensions());
218        }
219    }
220
221    #[test]
222    fn test_embed_batch_empty() {
223        // Default embed_batch with an empty slice — deterministic via stub.
224        let embedder = StubEmbedder;
225        let texts: Vec<&str> = vec![];
226        let embeddings = embedder.embed_batch(&texts).unwrap();
227        assert!(embeddings.is_empty());
228    }
229}