//! Embedding providers for semantic search. //! //! Embeddings convert text into dense vectors that capture semantic meaning. //! Similar concepts have similar vectors, enabling semantic search. use async_trait::async_trait; use serde::{Deserialize, Serialize}; /// Error type for embedding operations. #[derive(Debug, thiserror::Error)] pub enum EmbeddingError { #[error("HTTP request failed: {0}")] HttpError(String), #[error("Invalid response: {0}")] InvalidResponse(String), #[error("Rate limited, retry after {retry_after:?}")] RateLimited { retry_after: Option, }, #[error("Authentication failed")] AuthFailed, #[error("Text too long: {length} > {max}")] TextTooLong { length: usize, max: usize }, } impl From for EmbeddingError { fn from(e: reqwest::Error) -> Self { EmbeddingError::HttpError(e.to_string()) } } /// Trait for embedding providers. #[async_trait] pub trait EmbeddingProvider: Send + Sync { /// Get the embedding dimension. fn dimension(&self) -> usize; /// Get the model name. fn model_name(&self) -> &str; /// Maximum input length in characters. fn max_input_length(&self) -> usize; /// Generate an embedding for a single text. async fn embed(&self, text: &str) -> Result, EmbeddingError>; /// Generate embeddings for multiple texts (batched). /// /// Default implementation calls embed() for each text. async fn embed_batch(&self, texts: &[String]) -> Result>, EmbeddingError> { let mut embeddings = Vec::with_capacity(texts.len()); for text in texts { embeddings.push(self.embed(text).await?); } Ok(embeddings) } } /// Default base URL for the OpenAI API. const OPENAI_API_BASE_URL: &str = "https://api.openai.com"; /// OpenAI embedding provider using text-embedding-ada-002 or text-embedding-3-small. /// /// Supports any OpenAI-compatible embedding endpoint via [`with_base_url`](Self::with_base_url). pub struct OpenAiEmbeddings { client: reqwest::Client, api_key: String, model: String, dimension: usize, base_url: String, } impl OpenAiEmbeddings { /// Create a new OpenAI embedding provider with the default model. /// /// Uses text-embedding-3-small which has 1536 dimensions. pub fn new(api_key: impl Into) -> Self { Self { client: reqwest::Client::new(), api_key: api_key.into(), model: "text-embedding-3-small".to_string(), dimension: 1536, base_url: OPENAI_API_BASE_URL.to_string(), } } /// Use text-embedding-ada-002 model. pub fn ada_002(api_key: impl Into) -> Self { Self { client: reqwest::Client::new(), api_key: api_key.into(), model: "text-embedding-ada-002".to_string(), dimension: 1536, base_url: OPENAI_API_BASE_URL.to_string(), } } /// Use text-embedding-3-large model. pub fn large(api_key: impl Into) -> Self { Self { client: reqwest::Client::new(), api_key: api_key.into(), model: "text-embedding-3-large".to_string(), dimension: 3072, base_url: OPENAI_API_BASE_URL.to_string(), } } /// Use a custom model with specified dimension. pub fn with_model( api_key: impl Into, model: impl Into, dimension: usize, ) -> Self { Self { client: reqwest::Client::new(), api_key: api_key.into(), model: model.into(), dimension, base_url: OPENAI_API_BASE_URL.to_string(), } } /// Set a custom base URL for OpenAI-compatible embedding providers. /// /// The URL must use `http://` or `https://` scheme. If no scheme is present, /// `https://` is prepended automatically. Trailing slashes are stripped. pub fn with_base_url(mut self, base_url: &str) -> Self { let url = base_url.trim(); // Auto-prepend https:// if no scheme is present. let mut url = if !url.starts_with("http://") && !url.starts_with("https://") { tracing::debug!( "No scheme in embedding base URL '{}', prepending https://", url ); format!("https://{url}") } else { url.to_string() }; while url.ends_with('/') { url.pop(); } self.base_url = url; self } } #[derive(Debug, Serialize)] struct OpenAiEmbeddingRequest<'a> { model: &'a str, input: &'a [String], } #[derive(Debug, Deserialize)] struct OpenAiEmbeddingResponse { data: Vec, } #[derive(Debug, Deserialize)] struct OpenAiEmbeddingData { embedding: Vec, } #[async_trait] impl EmbeddingProvider for OpenAiEmbeddings { fn dimension(&self) -> usize { self.dimension } fn model_name(&self) -> &str { &self.model } fn max_input_length(&self) -> usize { // text-embedding-3-small/large: 8191 tokens (~32k chars) // text-embedding-ada-002: 8191 tokens 32_000 } async fn embed(&self, text: &str) -> Result, EmbeddingError> { if text.len() > self.max_input_length() { return Err(EmbeddingError::TextTooLong { length: text.len(), max: self.max_input_length(), }); } let embeddings = self.embed_batch(&[text.to_string()]).await?; embeddings .into_iter() .next() .ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string())) } async fn embed_batch(&self, texts: &[String]) -> Result>, EmbeddingError> { if texts.is_empty() { return Ok(Vec::new()); } let request = OpenAiEmbeddingRequest { model: &self.model, input: texts, }; let url = format!("{}/v1/embeddings", self.base_url); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", self.api_key)) .json(&request) .send() .await?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { return Err(EmbeddingError::AuthFailed); } if status == reqwest::StatusCode::TOO_MANY_REQUESTS { let retry_after = response .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .map(std::time::Duration::from_secs) .or(Some(std::time::Duration::from_secs(60))); return Err(EmbeddingError::RateLimited { retry_after }); } if !status.is_success() { let error_text = response.text().await.unwrap_or_default(); return Err(EmbeddingError::HttpError(format!( "Status {}: {}", status, error_text ))); } let result: OpenAiEmbeddingResponse = response.json().await.map_err(|e| { EmbeddingError::InvalidResponse(format!("Failed to parse response: {}", e)) })?; Ok(result.data.into_iter().map(|d| d.embedding).collect()) } } /// NEAR AI embedding provider using the NEAR AI API. /// /// Uses the same session-based auth as the LLM provider. pub struct NearAiEmbeddings { client: reqwest::Client, base_url: String, session: std::sync::Arc, model: String, dimension: usize, } impl NearAiEmbeddings { /// Create a new NEAR AI embedding provider. /// /// Uses the same session manager as the LLM provider for auth. pub fn new( base_url: impl Into, session: std::sync::Arc, ) -> Self { Self { client: reqwest::Client::new(), base_url: base_url.into(), session, model: "text-embedding-3-small".to_string(), dimension: 1536, } } /// Use a specific model. pub fn with_model(mut self, model: impl Into, dimension: usize) -> Self { self.model = model.into(); self.dimension = dimension; self } } #[derive(Debug, Serialize)] struct NearAiEmbeddingRequest<'a> { model: &'a str, input: &'a [String], } #[derive(Debug, Deserialize)] struct NearAiEmbeddingResponse { data: Vec, } #[derive(Debug, Deserialize)] struct NearAiEmbeddingData { embedding: Vec, } #[async_trait] impl EmbeddingProvider for NearAiEmbeddings { fn dimension(&self) -> usize { self.dimension } fn model_name(&self) -> &str { &self.model } fn max_input_length(&self) -> usize { 32_000 } async fn embed(&self, text: &str) -> Result, EmbeddingError> { if text.len() > self.max_input_length() { return Err(EmbeddingError::TextTooLong { length: text.len(), max: self.max_input_length(), }); } let embeddings = self.embed_batch(&[text.to_string()]).await?; embeddings .into_iter() .next() .ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string())) } async fn embed_batch(&self, texts: &[String]) -> Result>, EmbeddingError> { use secrecy::ExposeSecret; if texts.is_empty() { return Ok(Vec::new()); } let request = NearAiEmbeddingRequest { model: &self.model, input: texts, }; let token = self .session .get_token() .await .map_err(|_| EmbeddingError::AuthFailed)?; let url = format!("{}/v1/embeddings", self.base_url); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", token.expose_secret())) .json(&request) .send() .await?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { return Err(EmbeddingError::AuthFailed); } if status == reqwest::StatusCode::TOO_MANY_REQUESTS { let retry_after = response .headers() .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()) .map(std::time::Duration::from_secs) .or(Some(std::time::Duration::from_secs(60))); return Err(EmbeddingError::RateLimited { retry_after }); } if !status.is_success() { let error_text = response.text().await.unwrap_or_default(); return Err(EmbeddingError::HttpError(format!( "Status {}: {}", status, error_text ))); } let result: NearAiEmbeddingResponse = response.json().await.map_err(|e| { EmbeddingError::InvalidResponse(format!("Failed to parse response: {}", e)) })?; Ok(result.data.into_iter().map(|d| d.embedding).collect()) } } /// Ollama embedding provider using a local Ollama instance. /// /// Ollama serves embedding models (e.g. `nomic-embed-text`, `mxbai-embed-large`) /// via a REST API, typically at `http://localhost:11434`. pub struct OllamaEmbeddings { client: reqwest::Client, base_url: String, model: String, dimension: usize, } impl OllamaEmbeddings { /// Create a new Ollama embedding provider. /// /// Defaults to `nomic-embed-text` (768 dimensions). pub fn new(base_url: impl Into) -> Self { Self { client: reqwest::Client::new(), base_url: base_url.into(), model: "nomic-embed-text".to_string(), dimension: 768, } } /// Use a specific model with a given dimension. pub fn with_model(mut self, model: impl Into, dimension: usize) -> Self { self.model = model.into(); self.dimension = dimension; self } } #[derive(Debug, Serialize)] struct OllamaEmbedRequest<'a> { model: &'a str, input: &'a [String], } #[derive(Debug, Deserialize)] struct OllamaEmbedResponse { embeddings: Vec>, } #[async_trait] impl EmbeddingProvider for OllamaEmbeddings { fn dimension(&self) -> usize { self.dimension } fn model_name(&self) -> &str { &self.model } fn max_input_length(&self) -> usize { // Most Ollama embedding models support 8192 tokens (~32k chars) 32_000 } async fn embed(&self, text: &str) -> Result, EmbeddingError> { if text.len() > self.max_input_length() { return Err(EmbeddingError::TextTooLong { length: text.len(), max: self.max_input_length(), }); } let embeddings = self.embed_batch(&[text.to_string()]).await?; embeddings .into_iter() .next() .ok_or_else(|| EmbeddingError::InvalidResponse("No embedding returned".to_string())) } async fn embed_batch(&self, texts: &[String]) -> Result>, EmbeddingError> { if texts.is_empty() { return Ok(Vec::new()); } let request = OllamaEmbedRequest { model: &self.model, input: texts, }; let url = format!("{}/api/embed", self.base_url); let response = self.client.post(&url).json(&request).send().await?; let status = response.status(); if !status.is_success() { let error_text = response.text().await.unwrap_or_default(); return Err(EmbeddingError::HttpError(format!( "Ollama returned HTTP {}: {}", status, error_text ))); } let result: OllamaEmbedResponse = response.json().await.map_err(|e| { EmbeddingError::InvalidResponse(format!("Failed to parse Ollama response: {}", e)) })?; // Validate that returned embeddings match the configured dimension. for (i, emb) in result.embeddings.iter().enumerate() { if emb.len() != self.dimension { return Err(EmbeddingError::InvalidResponse(format!( "Ollama returned embedding of dimension {}, expected {} at index {}", emb.len(), self.dimension, i ))); } } Ok(result.embeddings) } } /// A mock embedding provider for testing. /// /// Generates deterministic embeddings based on text hash. /// Useful for unit and integration tests. pub struct MockEmbeddings { dimension: usize, } impl MockEmbeddings { /// Create a new mock embeddings provider with the given dimension. pub fn new(dimension: usize) -> Self { Self { dimension } } } #[async_trait] impl EmbeddingProvider for MockEmbeddings { fn dimension(&self) -> usize { self.dimension } fn model_name(&self) -> &str { "mock-embedding" } fn max_input_length(&self) -> usize { 10_000 } async fn embed(&self, text: &str) -> Result, EmbeddingError> { // Generate a deterministic embedding based on text hash use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); text.hash(&mut hasher); let hash = hasher.finish(); let mut embedding = Vec::with_capacity(self.dimension); let mut seed = hash; for _ in 0..self.dimension { // Simple LCG for deterministic random values seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let value = (seed as f32 / u64::MAX as f32) * 2.0 - 1.0; embedding.push(value); } // Normalize to unit length let magnitude: f32 = embedding.iter().map(|x| x * x).sum::().sqrt(); if magnitude > 0.0 { for x in &mut embedding { *x /= magnitude; } } Ok(embedding) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_mock_embeddings() { let provider = MockEmbeddings::new(128); let embedding = provider.embed("hello world").await.unwrap(); assert_eq!(embedding.len(), 128); // Check normalization (should be unit vector) let magnitude: f32 = embedding.iter().map(|x| x * x).sum::().sqrt(); assert!((magnitude - 1.0).abs() < 0.001); } #[tokio::test] async fn test_mock_embeddings_deterministic() { let provider = MockEmbeddings::new(64); let emb1 = provider.embed("test").await.unwrap(); let emb2 = provider.embed("test").await.unwrap(); // Same input should produce same embedding assert_eq!(emb1, emb2); } #[tokio::test] async fn test_mock_embeddings_batch() { let provider = MockEmbeddings::new(64); let texts = vec!["hello".to_string(), "world".to_string()]; let embeddings = provider.embed_batch(&texts).await.unwrap(); assert_eq!(embeddings.len(), 2); assert_eq!(embeddings[0].len(), 64); assert_eq!(embeddings[1].len(), 64); // Different texts should produce different embeddings assert_ne!(embeddings[0], embeddings[1]); } #[test] fn test_openai_embeddings_config() { let provider = OpenAiEmbeddings::new("test-key"); assert_eq!(provider.dimension(), 1536); assert_eq!(provider.model_name(), "text-embedding-3-small"); assert_eq!(provider.base_url, OPENAI_API_BASE_URL); let provider = OpenAiEmbeddings::large("test-key"); assert_eq!(provider.dimension(), 3072); assert_eq!(provider.model_name(), "text-embedding-3-large"); assert_eq!(provider.base_url, OPENAI_API_BASE_URL); } #[test] fn test_openai_with_base_url_valid() { let provider = OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com"); assert_eq!(provider.base_url, "https://custom.example.com"); } #[test] fn test_openai_with_base_url_strips_trailing_slashes() { let provider = OpenAiEmbeddings::new("test-key").with_base_url("https://custom.example.com///"); assert_eq!(provider.base_url, "https://custom.example.com"); } #[test] fn test_openai_with_base_url_http_scheme() { let provider = OpenAiEmbeddings::new("test-key").with_base_url("http://localhost:8080"); assert_eq!(provider.base_url, "http://localhost:8080"); } #[test] fn test_openai_with_base_url_schemeless_prepends_https() { let provider = OpenAiEmbeddings::new("test-key").with_base_url("custom.example.com/v1"); assert_eq!(provider.base_url, "https://custom.example.com/v1"); } // -- Retry-After header parsing tests (regression for rate limit "None" bug) -- #[test] fn test_retry_after_parsing_delay_seconds() { // Verify delay-seconds format is parsed correctly let header_value = "120"; let duration = parse_retry_after_embeddings_for_test(header_value); assert_eq!( duration, Some(std::time::Duration::from_secs(120)), "Should parse delay-seconds format" ); } #[test] fn test_retry_after_fallback_missing_header() { // Regression test: When Retry-After header is missing, // should fall back to 60s instead of None let duration = parse_retry_after_embeddings_for_test(""); assert_eq!( duration, Some(std::time::Duration::from_secs(60)), "Missing header should fallback to 60s" ); } #[test] fn test_retry_after_zero_seconds_accepted() { // Verify zero seconds is a valid retry delay let duration = parse_retry_after_embeddings_for_test("0"); assert_eq!(duration, Some(std::time::Duration::ZERO)); } /// Helper function to test Retry-After header parsing logic for embeddings /// (simulates the parsing done in embed without actual HTTP, including fallback) fn parse_retry_after_embeddings_for_test(header_value: &str) -> Option { header_value .trim() .parse::() .ok() .map(std::time::Duration::from_secs) .or(Some(std::time::Duration::from_secs(60))) } }