Skip to main content

lc_embeddings/
lib.rs

1#![warn(missing_docs)]
2// lc-embeddings/src/lib.rs
3//! Embedding model implementations for LangChainRust.
4//!
5//! Provides embedding generation via multiple backends:
6//! - OpenAI (`text-embedding-ada-002`, `text-embedding-3-small/large`)
7//! - DeepSeek
8//! - Qwen (Alibaba Cloud / DashScope)
9//! - Local: `BagOfWordsEmbeddings` (always available) and `LocalEmbeddings` (ONNX, feature-gated)
10//! - `MockEmbeddings` for testing
11
12mod cohere;
13mod deepseek;
14mod local;
15mod mock;
16mod openai;
17pub mod openai_compat;
18mod qwen;
19mod retry;
20pub mod token_level;
21
22#[cfg(test)]
23mod test_support;
24
25#[cfg(feature = "fastembed")]
26mod fastembed_emb;
27
28#[cfg(feature = "local-candle")]
29mod candle_emb;
30
31pub use cohere::{
32    CohereEmbedInputType, CohereEmbeddings, CohereEmbeddingsConfig, COHERE_EMBED_BASE_URL,
33    COHERE_EMBED_MODEL,
34};
35pub use deepseek::{DeepSeekEmbeddings, DeepSeekEmbeddingsConfig, DEEPSEEK_EMBED_MODEL};
36pub use local::BagOfWordsEmbeddings;
37// As of 1.0, `LocalEmbeddings` is not available without the `local-embeddings` feature (the
38// old fallback alias was removed); users must explicitly choose — `BagOfWordsEmbeddings` or
39// the ONNX version via the feature.
40#[cfg(feature = "local-embeddings")]
41pub use local::{LocalEmbeddings, LocalEmbeddingsBuilder};
42pub use mock::MockEmbeddings;
43pub use openai::{OpenAIEmbeddings, OpenAIEmbeddingsConfig};
44pub use qwen::{QwenEmbeddings, QwenEmbeddingsConfig, QWEN_EMBED_MODEL};
45pub use token_level::{
46    char_spans_to_byte_spans, LocalTokenLevelEmbeddings, TokenEmbedding, TokenLevelEmbeddings,
47    TokenSpan,
48};
49
50#[cfg(feature = "fastembed")]
51pub use fastembed_emb::FastEmbedEmbeddings;
52
53#[cfg(feature = "local-candle")]
54pub use candle_emb::CandleEmbeddings;
55
56use async_trait::async_trait;
57
58/// Embedding error type
59#[derive(Debug, thiserror::Error)]
60#[non_exhaustive]
61pub enum EmbeddingError {
62    /// HTTP request error
63    #[error("HTTP error: {0}")]
64    HttpError(String),
65
66    /// API error
67    #[error("API error: {0}")]
68    ApiError(String),
69
70    /// Parse error
71    #[error("Parse error: {0}")]
72    ParseError(String),
73
74    /// Configuration error (e.g. empty API key, unknown model dimension) — fails fast at construction.
75    #[error("Configuration error: {0}")]
76    Config(String),
77
78    /// Empty input
79    #[error("Input is empty")]
80    EmptyInput,
81
82    /// Batch embedding misalignment: for N requested texts, the provider returned a vector
83    /// count or index outside the expected range (a chunk missing entries / out of order).
84    ///
85    /// P0-1: reject silently misaligned data — never treat a missing vector as "dissimilar".
86    #[error("Embedding batch mismatch: expected {expected} vectors, got position {actual}")]
87    BatchMismatch {
88        /// The expected number of vectors
89        expected: usize,
90        /// The position index where the misalignment occurred
91        actual: usize,
92    },
93
94    /// Some text in a batch got no vector (the provider returned fewer embeddings than requested).
95    ///
96    /// P0-1: reject silently empty vectors — a missing vector is an explicit error, not a zero vector.
97    #[error("Embedding batch contains an empty vector (provider returned fewer embeddings than requested)")]
98    EmptyVectorInBatch,
99}
100
101/// Embedding model trait
102///
103/// Defines the interface for generating text embedding vectors.
104///
105/// # Normalization contract (P2-8)
106///
107/// All returned vectors are **L2-normalized** unit vectors (except zero vectors),
108/// regardless of whether the provider normalizes internally. This guarantees downstream
109/// cosine, dot-product, or L2-distance results do not drift across providers. HTTP
110/// providers call [`l2_normalize`] uniformly before returning.
111#[async_trait]
112pub trait Embeddings: Send + Sync {
113    /// Generate an embedding vector for a single text.
114    ///
115    /// # Arguments
116    /// * `text` - Input text
117    ///
118    /// # Returns
119    /// Embedding vector (typically 1536 dimensions or higher)
120    async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError>;
121
122    /// Generate embedding vectors for multiple documents.
123    ///
124    /// # Arguments
125    /// * `texts` - List of input texts
126    ///
127    /// # Returns
128    /// List of embedding vectors
129    ///
130    /// # Semantic contract (P1-1)
131    ///
132    /// - Any empty or all-whitespace text (`trim().is_empty()`) → `Err(EmbeddingError::EmptyInput)`;
133    /// - An empty slice `&[]` is treated as "no text to embed" → `Ok(vec![])` (nothing to do is not an error).
134    ///
135    /// The default implementation loops over [`Self::embed_query`] with a uniform emptiness
136    /// check up front; providers that override it must follow the same contract and not diverge.
137    async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
138        if texts.iter().any(|t| t.trim().is_empty()) {
139            return Err(EmbeddingError::EmptyInput);
140        }
141        let mut embeddings = Vec::new();
142        for text in texts {
143            embeddings.push(self.embed_query(text).await?);
144        }
145        Ok(embeddings)
146    }
147
148    /// Get the embedding vector dimension.
149    fn dimension(&self) -> usize;
150
151    /// Get the model name.
152    fn model_name(&self) -> &str;
153}
154
155/// Compute cosine similarity between two vectors.
156///
157/// Re-exported from [`lc_core::math::cosine_similarity`].
158pub use lc_core::math::cosine_similarity;
159
160/// In-place L2 normalization: scale `vec` to unit length.
161///
162/// P2-8: providers normalize returned vectors differently (OpenAI normalizes, BOW
163/// self-normalizes, remote providers such as Cohere may not); downstream results using
164/// dot-product/L2 distance instead of cosine would drift across providers. This function
165/// is the **single** normalization implementation; HTTP providers call it uniformly before
166/// returning, ensuring `Embeddings` always produces unit-length vectors.
167///
168/// A zero vector stays zero (no NaN is produced).
169pub fn l2_normalize(vec: &mut [f32]) {
170    let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
171    if norm > 0.0 {
172        for v in vec.iter_mut() {
173            *v /= norm;
174        }
175    }
176}
177
178/// Mutex for synchronizing environment-variable mutations in tests.
179///
180/// Tests that set/remove env vars must acquire this lock to avoid data races
181/// when running tests in parallel (the default for `cargo test`).
182#[cfg(test)]
183static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_cosine_similarity() {
191        // Identical vectors
192        let a = vec![1.0, 0.0, 0.0];
193        let b = vec![1.0, 0.0, 0.0];
194        assert!((cosine_similarity(&a, &b).unwrap() - 1.0).abs() < 0.0001);
195
196        // Orthogonal vectors
197        let a = vec![1.0, 0.0, 0.0];
198        let b = vec![0.0, 1.0, 0.0];
199        assert!((cosine_similarity(&a, &b).unwrap() - 0.0).abs() < 0.0001);
200
201        // Opposite vectors
202        let a = vec![1.0, 0.0, 0.0];
203        let b = vec![-1.0, 0.0, 0.0];
204        assert!((cosine_similarity(&a, &b).unwrap() - (-1.0)).abs() < 0.0001);
205    }
206
207    #[test]
208    fn test_cosine_similarity_different_lengths() {
209        let a = vec![1.0, 0.0];
210        let b = vec![1.0, 0.0, 0.0];
211        assert!(cosine_similarity(&a, &b).is_err());
212    }
213}