Skip to main content

lc_embeddings/
cohere.rs

1// lc-embeddings/src/cohere.rs
2//! Cohere Embeddings — embed-english-v3.0 / embed-multilingual-v3.0.
3//!
4//! Uses Cohere's v2/embed endpoint for generating text embeddings.
5
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::json;
9
10use crate::{EmbeddingError, Embeddings};
11
12/// Default Cohere embedding model.
13pub const COHERE_EMBED_MODEL: &str = "embed-english-v3.0";
14
15/// Cohere API base URL.
16pub const COHERE_EMBED_BASE_URL: &str = "https://api.cohere.com/v2";
17
18/// Cohere embedding input type.
19#[derive(Debug, Clone, Copy)]
20pub enum CohereEmbedInputType {
21    /// Search query embedding.
22    SearchQuery,
23    /// Search document embedding.
24    SearchDocument,
25    /// Classification embedding.
26    Classification,
27    /// Clustering embedding.
28    Clustering,
29}
30
31impl CohereEmbedInputType {
32    fn as_str(&self) -> &'static str {
33        match self {
34            CohereEmbedInputType::SearchQuery => "search_query",
35            CohereEmbedInputType::SearchDocument => "search_document",
36            CohereEmbedInputType::Classification => "classification",
37            CohereEmbedInputType::Clustering => "clustering",
38        }
39    }
40}
41
42/// Cohere embedding configuration.
43#[derive(Debug, Clone)]
44pub struct CohereEmbeddingsConfig {
45    /// Cohere API key.
46    pub api_key: String,
47    /// Base URL for the Cohere embeddings API.
48    pub base_url: String,
49    /// Embedding model name.
50    pub model: String,
51    /// Input type for the embedding request.
52    pub input_type: CohereEmbedInputType,
53}
54
55impl Default for CohereEmbeddingsConfig {
56    fn default() -> Self {
57        Self {
58            api_key: String::new(),
59            base_url: COHERE_EMBED_BASE_URL.to_string(),
60            model: COHERE_EMBED_MODEL.to_string(),
61            input_type: CohereEmbedInputType::SearchQuery,
62        }
63    }
64}
65
66impl CohereEmbeddingsConfig {
67    /// Creates a new config with the given API key.
68    pub fn new(api_key: impl Into<String>) -> Self {
69        Self {
70            api_key: api_key.into(),
71            ..Default::default()
72        }
73    }
74
75    /// Creates config from environment variables.
76    pub fn from_env_result() -> Result<Self, EmbeddingError> {
77        let api_key = std::env::var("COHERE_API_KEY").map_err(|_| {
78            EmbeddingError::Config("COHERE_API_KEY environment variable not set".to_string())
79        })?;
80        let base_url =
81            std::env::var("COHERE_BASE_URL").unwrap_or_else(|_| COHERE_EMBED_BASE_URL.to_string());
82        let model =
83            std::env::var("COHERE_EMBED_MODEL").unwrap_or_else(|_| COHERE_EMBED_MODEL.to_string());
84        Ok(Self {
85            api_key,
86            base_url,
87            model,
88            ..Default::default()
89        })
90    }
91
92    /// Sets the model name.
93    pub fn with_model(mut self, model: impl Into<String>) -> Self {
94        self.model = model.into();
95        self
96    }
97
98    /// Sets the input type.
99    pub fn with_input_type(mut self, input_type: CohereEmbedInputType) -> Self {
100        self.input_type = input_type;
101        self
102    }
103}
104
105/// Cohere embedding response.
106#[derive(Debug, Deserialize)]
107struct CohereEmbedResponse {
108    data: Vec<CohereEmbedData>,
109}
110
111#[derive(Debug, Deserialize)]
112struct CohereEmbedData {
113    embedding: Vec<f32>,
114}
115
116/// Cohere embedding provider.
117pub struct CohereEmbeddings {
118    config: CohereEmbeddingsConfig,
119    client: reqwest::Client,
120    dimension: usize,
121}
122
123impl std::fmt::Debug for CohereEmbeddings {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("CohereEmbeddings")
126            .field("model", &self.config.model)
127            .finish()
128    }
129}
130
131impl CohereEmbeddings {
132    /// Creates a new CohereEmbeddings with the given configuration.
133    ///
134    /// 构造时 fail fast(P1-3):API key 为空立即报错。模型维度已知才构造
135    /// (P1-2):Cohere v3.0 系列(english/multilingual)均为 1024 维,
136    /// 未知模型报错而非恒 1024 撒谎。
137    pub fn new(config: CohereEmbeddingsConfig) -> Result<Self, EmbeddingError> {
138        if config.api_key.trim().is_empty() {
139            return Err(EmbeddingError::Config(
140                "COHERE_API_KEY is empty".to_string(),
141            ));
142        }
143        let dimension = Self::dimension_for(&config.model)?;
144        Ok(Self {
145            config,
146            client: reqwest::Client::new(),
147            dimension,
148        })
149    }
150
151    /// 已知模型的维度表;Cohere v3.0 系列均为 1024 维(P1-2)。
152    fn dimension_for(model: &str) -> Result<usize, EmbeddingError> {
153        match model {
154            "embed-english-v3.0" | "embed-multilingual-v3.0" => Ok(1024),
155            other => Err(EmbeddingError::Config(format!(
156                "unknown embedding dimension for Cohere model '{other}' \
157                 (supported: 'embed-english-v3.0', 'embed-multilingual-v3.0')"
158            ))),
159        }
160    }
161
162    /// Creates from environment variables.
163    pub fn from_env_result() -> Result<Self, EmbeddingError> {
164        let config = CohereEmbeddingsConfig::from_env_result()?;
165        Self::new(config)
166    }
167}
168
169#[async_trait]
170impl Embeddings for CohereEmbeddings {
171    async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
172        // P1-1: 补上 Cohere 缺失的空输入检查,与其他 provider 契约一致。
173        if text.trim().is_empty() {
174            return Err(EmbeddingError::EmptyInput);
175        }
176
177        let url = format!("{}/embed", self.config.base_url);
178        let body = json!({
179            "model": self.config.model,
180            "input_type": self.config.input_type.as_str(),
181            "texts": [text],
182            "embedding_types": ["float"],
183        });
184
185        // P2-5: 429/5xx 指数退避重试。
186        let response = crate::retry::post_json_with_retry(
187            &self.client,
188            &url,
189            &self.config.api_key,
190            &body,
191            &crate::retry::DEFAULT_RETRY,
192        )
193        .await
194        .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
195
196        let status = response.status();
197        if !status.is_success() {
198            // P1-4: 读失败的错误体也要报错,不能 unwrap_or_default() 吞掉。
199            let error_text = response.text().await.map_err(|e| {
200                EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
201            })?;
202            return Err(EmbeddingError::ApiError(format!(
203                "HTTP {}: {}",
204                status, error_text
205            )));
206        }
207
208        let embed_response: CohereEmbedResponse = response
209            .json()
210            .await
211            .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
212
213        let mut embedding = embed_response
214            .data
215            .first()
216            .map(|d| d.embedding.clone())
217            .ok_or_else(|| EmbeddingError::ApiError("No embedding in response".to_string()))?;
218        // P2-8: 统一 L2 归一化,保证单位长度。
219        crate::l2_normalize(&mut embedding);
220        Ok(embedding)
221    }
222
223    async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
224        // P1-1: 空切片不是错误(无事可做),含空/全空白文本才报错——与其他 provider 契约统一。
225        if texts.is_empty() {
226            return Ok(Vec::new());
227        }
228        if texts.iter().any(|t| t.trim().is_empty()) {
229            return Err(EmbeddingError::EmptyInput);
230        }
231
232        let url = format!("{}/embed", self.config.base_url);
233        let body = json!({
234            "model": self.config.model,
235            "input_type": CohereEmbedInputType::SearchDocument.as_str(),
236            "texts": texts,
237            "embedding_types": ["float"],
238        });
239
240        // P2-5: 429/5xx 指数退避重试。
241        let response = crate::retry::post_json_with_retry(
242            &self.client,
243            &url,
244            &self.config.api_key,
245            &body,
246            &crate::retry::DEFAULT_RETRY,
247        )
248        .await
249        .map_err(|e| EmbeddingError::HttpError(e.to_string()))?;
250
251        let status = response.status();
252        if !status.is_success() {
253            // P1-4: 读失败的错误体也要报错,不能 unwrap_or_default() 吞掉。
254            let error_text = response.text().await.map_err(|e| {
255                EmbeddingError::HttpError(format!("failed to read error response body: {e}"))
256            })?;
257            return Err(EmbeddingError::ApiError(format!(
258                "HTTP {}: {}",
259                status, error_text
260            )));
261        }
262
263        let embed_response: CohereEmbedResponse = response
264            .json()
265            .await
266            .map_err(|e| EmbeddingError::ParseError(e.to_string()))?;
267
268        let mut embeddings: Vec<Vec<f32>> = embed_response
269            .data
270            .into_iter()
271            .map(|d| d.embedding)
272            .collect();
273
274        // P0-1: Cohere 一次请求全部文本,必须校验返回量与请求量一致,
275        // 否则少返回的向量会让下游张冠李戴。
276        if embeddings.len() != texts.len() {
277            return Err(EmbeddingError::BatchMismatch {
278                expected: texts.len(),
279                actual: embeddings.len(),
280            });
281        }
282
283        // P2-8: 逐条统一 L2 归一化,保证单位长度。
284        for v in embeddings.iter_mut() {
285            crate::l2_normalize(v);
286        }
287
288        Ok(embeddings)
289    }
290
291    fn dimension(&self) -> usize {
292        self.dimension
293    }
294
295    fn model_name(&self) -> &str {
296        &self.config.model
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::test_support::{spawn_embeddings_stub, spawn_status_stub};
304    use std::sync::atomic::Ordering;
305    use std::sync::Arc;
306
307    /// P2-5: Cohere 同样接线 429 重试。
308    #[tokio::test]
309    async fn test_embed_query_retries_on_429() {
310        let success_body = r#"{"data":[{"embedding":[0.6,0.8]}]}"#;
311        let (base_url, requests) = spawn_status_stub(429, 2, 200, success_body).await;
312        let config = CohereEmbeddingsConfig {
313            api_key: "test-key".into(),
314            base_url,
315            model: COHERE_EMBED_MODEL.into(),
316            input_type: CohereEmbedInputType::SearchQuery,
317        };
318        let embeddings = CohereEmbeddings::new(config).unwrap();
319
320        let v = embeddings
321            .embed_query("hello")
322            .await
323            .expect("should retry successfully after two 429s");
324        assert_eq!(v.len(), 2);
325        // P2-8: 返回向量应已归一化。
326        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
327        assert!((norm - 1.0).abs() < 1e-5, "norm = {}", norm);
328        assert_eq!(requests.load(Ordering::SeqCst), 3, "1 initial + 2 retries");
329    }
330
331    /// P0-1: Cohere 一次性返回全部文本,少返回必须显式报 `BatchMismatch`,
332    /// 而非静默少向量让下游错位。
333    #[tokio::test]
334    async fn test_embed_documents_truncated_errors() {
335        let base_url = spawn_embeddings_stub(Arc::new(|n| n.saturating_sub(1))).await;
336        let config = CohereEmbeddingsConfig {
337            api_key: "test-key".into(),
338            base_url,
339            model: COHERE_EMBED_MODEL.into(),
340            input_type: CohereEmbedInputType::SearchDocument,
341        };
342        let embeddings = CohereEmbeddings::new(config).unwrap();
343
344        let result = embeddings.embed_documents(&["a", "b"]).await;
345        assert!(
346            matches!(
347                result,
348                Err(EmbeddingError::BatchMismatch {
349                    expected: 2,
350                    actual: 1
351                })
352            ),
353            "truncated response should report BatchMismatch, got: {:?}",
354            result
355        );
356    }
357
358    #[test]
359    fn test_config_new() {
360        let config = CohereEmbeddingsConfig::new("test-key");
361        assert_eq!(config.api_key, "test-key");
362        assert_eq!(config.model, COHERE_EMBED_MODEL);
363    }
364
365    #[test]
366    fn test_config_builder() {
367        let config = CohereEmbeddingsConfig::new("key")
368            .with_model("embed-multilingual-v3.0")
369            .with_input_type(CohereEmbedInputType::SearchDocument);
370        assert_eq!(config.model, "embed-multilingual-v3.0");
371        assert!(matches!(
372            config.input_type,
373            CohereEmbedInputType::SearchDocument
374        ));
375    }
376
377    #[test]
378    fn test_input_type_str() {
379        assert_eq!(CohereEmbedInputType::SearchQuery.as_str(), "search_query");
380        assert_eq!(
381            CohereEmbedInputType::SearchDocument.as_str(),
382            "search_document"
383        );
384        assert_eq!(
385            CohereEmbedInputType::Classification.as_str(),
386            "classification"
387        );
388        assert_eq!(CohereEmbedInputType::Clustering.as_str(), "clustering");
389    }
390
391    #[test]
392    fn test_embeddings_new() {
393        let config = CohereEmbeddingsConfig::new("key");
394        let embeddings = CohereEmbeddings::new(config).unwrap();
395        assert_eq!(embeddings.model_name(), COHERE_EMBED_MODEL);
396        assert_eq!(embeddings.dimension(), 1024);
397    }
398
399    /// P1-3: API key 为空 → 构造期 fail fast 报 `Config`,而非拖到发请求才 401。
400    #[test]
401    fn test_new_rejects_empty_api_key() {
402        let config = CohereEmbeddingsConfig {
403            api_key: String::new(),
404            base_url: COHERE_EMBED_BASE_URL.into(),
405            model: COHERE_EMBED_MODEL.into(),
406            input_type: CohereEmbedInputType::SearchDocument,
407        };
408        let err = CohereEmbeddings::new(config).unwrap_err();
409        assert!(matches!(err, EmbeddingError::Config(_)));
410    }
411
412    /// P1-2: 未知模型 → 构造期报错,不得恒 1024 撒谎。
413    #[test]
414    fn test_new_rejects_unknown_model() {
415        let config = CohereEmbeddingsConfig::new("key").with_model("some-unknown-model");
416        let err = CohereEmbeddings::new(config).unwrap_err();
417        assert!(matches!(err, EmbeddingError::Config(_)));
418    }
419
420    /// P1-1: 空文本 / 全空白文本 → `Err(EmptyInput)`;空切片 → `Ok(vec![])`。
421    #[tokio::test]
422    async fn test_empty_input_contract() {
423        let embeddings = CohereEmbeddings::new(CohereEmbeddingsConfig::new("key")).unwrap();
424        assert!(matches!(
425            embeddings.embed_query("").await,
426            Err(EmbeddingError::EmptyInput)
427        ));
428        assert!(matches!(
429            embeddings.embed_query("   ").await,
430            Err(EmbeddingError::EmptyInput)
431        ));
432        assert_eq!(
433            embeddings.embed_documents(&[]).await.unwrap(),
434            Vec::<Vec<f32>>::new()
435        );
436        assert!(matches!(
437            embeddings.embed_documents(&["ok", " "]).await,
438            Err(EmbeddingError::EmptyInput)
439        ));
440    }
441}