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