Skip to main content

lean_embed/
lib.rs

1//! A lean, provider-agnostic text-embeddings client.
2//!
3//! One [`Client`] turns batches of text into vectors against any of four
4//! [`Provider`]s - **[Voyage AI]**, **[OpenAI]** (or any OpenAI-compatible
5//! endpoint), **[Gemini]**, or **[Ollama]** (local, no key, offline) - behind a
6//! single [`embed`](Client::embed) call. [`EmbedKind`] selects query- vs
7//! document-side vectors where the provider supports it, and an optional
8//! [`output_dimension`](ClientBuilder::output_dimension) is requested *and*
9//! validated so a model drift can't silently desync a fixed-width column.
10//!
11//! The wire is [`reqwest`] on **rustls + ring** only - never OpenSSL or aws-lc -
12//! so the dependency tree stays small and cross-compiles cleanly (musl,
13//! aarch64). That lean stack is the reason this crate exists instead of a full
14//! agent/RAG framework: it is *only* the embeddings HTTP client, so a vector
15//! store, chunking, and retrieval stay in the caller where they belong.
16//!
17//! # Example
18//!
19//! ```no_run
20//! use lean_embed::{Client, EmbedKind, Provider};
21//!
22//! # async fn run() -> Result<(), lean_embed::Error> {
23//! // Local Ollama - no key, offline.
24//! let client = Client::builder(Provider::Ollama, "nomic-embed-text").build()?;
25//! let vectors = client
26//!     .embed(&["hello".into(), "world".into()], EmbedKind::Document)
27//!     .await?;
28//! assert_eq!(vectors.len(), 2);
29//!
30//! // Hosted Voyage, pinned to 1024 dimensions (key from VOYAGE_API_KEY).
31//! let voyage = Client::builder(Provider::Voyage, "voyage-3.5-lite")
32//!     .output_dimension(1024)
33//!     .max_batch(96)
34//!     .build()?;
35//! let q = voyage.embed(&["a question".into()], EmbedKind::Query).await?;
36//! # let _ = (vectors, q);
37//! # Ok(())
38//! # }
39//! ```
40//!
41//! [Voyage AI]: https://www.voyageai.com/
42//! [OpenAI]: https://platform.openai.com/docs/guides/embeddings
43//! [Gemini]: https://ai.google.dev/gemini-api/docs/embeddings
44//! [Ollama]: https://ollama.com/
45
46#![warn(missing_docs)]
47#![warn(rustdoc::broken_intra_doc_links)]
48
49use std::sync::Once;
50use std::time::Duration;
51
52/// Default request timeout: generous, because a cold Ollama model load can take
53/// several seconds before the first byte.
54const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
55
56const OLLAMA_DEFAULT_BASE_URL: &str = "http://localhost:11434";
57const VOYAGE_DEFAULT_BASE_URL: &str = "https://api.voyageai.com/v1";
58const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
59const GEMINI_DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
60
61/// The environment variable [`Provider::Voyage`] reads when no key is passed.
62pub const VOYAGE_API_KEY_ENV: &str = "VOYAGE_API_KEY";
63/// The environment variable [`Provider::OpenAi`] reads when no key is passed.
64pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
65/// The environment variable [`Provider::Gemini`] reads when no key is passed.
66pub const GEMINI_API_KEY_ENV: &str = "GEMINI_API_KEY";
67
68/// Which embeddings backend a [`Client`] talks to.
69///
70/// `#[non_exhaustive]`: more providers can be added in a minor release, so match
71/// with a `_ =>` arm.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[non_exhaustive]
74pub enum Provider {
75    /// A local Ollama server (`{base_url}/api/embed`, default
76    /// `http://localhost:11434`). No API key, works offline; ignores
77    /// [`EmbedKind`].
78    Ollama,
79    /// Voyage AI (`{base_url}/embeddings`, default `https://api.voyageai.com/v1`).
80    /// Needs [`VOYAGE_API_KEY_ENV`]; honours `input_type` ([`EmbedKind`]) and
81    /// `output_dimension`.
82    Voyage,
83    /// OpenAI, or any OpenAI-compatible `/v1/embeddings` endpoint (together.ai,
84    /// vLLM, LocalAI, ...) via a `base_url` override. Default
85    /// `https://api.openai.com/v1`. Needs [`OPENAI_API_KEY_ENV`]; honours
86    /// `dimensions` ([`ClientBuilder::output_dimension`]). Symmetric - ignores
87    /// [`EmbedKind`].
88    OpenAi,
89    /// Google Gemini (Generative Language API,
90    /// `{base_url}/models/{model}:batchEmbedContents`, default
91    /// `https://generativelanguage.googleapis.com/v1beta`). Needs
92    /// [`GEMINI_API_KEY_ENV`]; maps [`EmbedKind`] to `taskType` and honours
93    /// `outputDimensionality`.
94    Gemini,
95}
96
97impl Provider {
98    fn label(self) -> &'static str {
99        match self {
100            Provider::Ollama => "ollama",
101            Provider::Voyage => "voyage",
102            Provider::OpenAi => "openai",
103            Provider::Gemini => "gemini",
104        }
105    }
106
107    fn default_base_url(self) -> &'static str {
108        match self {
109            Provider::Ollama => OLLAMA_DEFAULT_BASE_URL,
110            Provider::Voyage => VOYAGE_DEFAULT_BASE_URL,
111            Provider::OpenAi => OPENAI_DEFAULT_BASE_URL,
112            Provider::Gemini => GEMINI_DEFAULT_BASE_URL,
113        }
114    }
115
116    /// The environment variable a missing key falls back to, or `None` for a
117    /// keyless provider (Ollama).
118    fn api_key_env(self) -> Option<&'static str> {
119        match self {
120            Provider::Ollama => None,
121            Provider::Voyage => Some(VOYAGE_API_KEY_ENV),
122            Provider::OpenAi => Some(OPENAI_API_KEY_ENV),
123            Provider::Gemini => Some(GEMINI_API_KEY_ENV),
124        }
125    }
126}
127
128/// Whether a batch is stored **documents** or a search **query**. Voyage uses
129/// this for asymmetric retrieval (query- and document-side vectors differ, which
130/// retrieves better); Ollama ignores it.
131///
132/// `#[non_exhaustive]` because the set of input types is a provider-defined
133/// vocabulary that may grow; match with a `_ =>` arm.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum EmbedKind {
137    /// A stored document (Voyage `input_type = "document"`).
138    Document,
139    /// A search query (Voyage `input_type = "query"`).
140    Query,
141}
142
143impl EmbedKind {
144    /// Voyage / OpenAI-style `input_type`.
145    fn as_str(self) -> &'static str {
146        match self {
147            EmbedKind::Document => "document",
148            EmbedKind::Query => "query",
149        }
150    }
151
152    /// Gemini `taskType`.
153    fn gemini_task_type(self) -> &'static str {
154        match self {
155            EmbedKind::Document => "RETRIEVAL_DOCUMENT",
156            EmbedKind::Query => "RETRIEVAL_QUERY",
157        }
158    }
159}
160
161/// A transport/decoding error, kept opaque on purpose. The concrete HTTP
162/// backend ([`reqwest`]) is an implementation detail, so it is boxed rather than
163/// exposed in the public API - a reqwest major bump must not force a breaking
164/// release of this crate. The message and [`std::error::Error::source`] chain
165/// are preserved.
166pub type TransportError = Box<dyn std::error::Error + Send + Sync + 'static>;
167
168/// Everything that can go wrong producing embeddings. Each variant carries the
169/// provider that raised it so a caller can log or match without string-scraping.
170///
171/// The struct variants are `#[non_exhaustive]` so fields can be added (e.g. a
172/// `retry_after` on [`Error::Api`]) without a breaking release; match them with
173/// a trailing `..`.
174#[derive(Debug, thiserror::Error)]
175#[non_exhaustive]
176pub enum Error {
177    /// Building the underlying HTTP client failed (bad TLS config, etc.).
178    #[error("failed to build HTTP client: {0}")]
179    ClientBuild(#[source] TransportError),
180
181    /// Voyage was selected but no API key was supplied and the environment
182    /// variable is unset.
183    #[error("{provider}: no API key (pass .api_key(..) or set {env})")]
184    #[non_exhaustive]
185    MissingApiKey {
186        /// The provider that needed a key (`"voyage"`).
187        provider: &'static str,
188        /// The environment variable that was consulted ([`VOYAGE_API_KEY_ENV`]).
189        env: &'static str,
190    },
191
192    /// The HTTP request never completed (connection refused, timeout, DNS, ...).
193    /// For Ollama this usually means the server is not running.
194    #[error("{provider} request failed: {source}")]
195    #[non_exhaustive]
196    Request {
197        /// The provider the request targeted.
198        provider: &'static str,
199        /// The underlying transport error.
200        #[source]
201        source: TransportError,
202    },
203
204    /// The provider answered with a non-success status; `body` is its message.
205    #[error("{provider} returned HTTP {status}: {body}")]
206    #[non_exhaustive]
207    Api {
208        /// The provider that returned the error.
209        provider: &'static str,
210        /// The HTTP status code.
211        status: u16,
212        /// The response body (the provider's error message).
213        body: String,
214    },
215
216    /// The success response could not be decoded into the expected shape.
217    #[error("{provider} failed to decode response: {source}")]
218    #[non_exhaustive]
219    Decode {
220        /// The provider whose response failed to decode.
221        provider: &'static str,
222        /// The underlying transport error.
223        #[source]
224        source: TransportError,
225    },
226
227    /// The provider returned a different number of vectors than inputs given.
228    #[error("{provider} returned {got} embeddings for {expected} inputs")]
229    #[non_exhaustive]
230    CountMismatch {
231        /// The provider that returned the wrong count.
232        provider: &'static str,
233        /// How many vectors came back.
234        got: usize,
235        /// How many were expected (one per input).
236        expected: usize,
237    },
238
239    /// An `output_dimension` was pinned but a returned vector had a different
240    /// width - a schema-desync guard for callers that store into a fixed-width
241    /// column (e.g. pgvector `vector(1024)`).
242    #[error("{provider} returned dimension {got} (expected {expected})")]
243    #[non_exhaustive]
244    DimMismatch {
245        /// The provider that returned the wrong width.
246        provider: &'static str,
247        /// The width actually returned.
248        got: usize,
249        /// The pinned [`ClientBuilder::output_dimension`].
250        expected: usize,
251    },
252}
253
254/// Install the ring crypto provider exactly once, process-wide. Ignoring the
255/// `Err` is intentional: it only means a provider is already installed.
256fn install_ring() {
257    static ONCE: Once = Once::new();
258    ONCE.call_once(|| {
259        let _ = rustls::crypto::ring::default_provider().install_default();
260    });
261}
262
263/// Build a [`Client`]. Start with [`Client::builder`]. `Debug` redacts the API
264/// key.
265#[derive(Clone)]
266pub struct ClientBuilder {
267    provider: Provider,
268    model: String,
269    base_url: Option<String>,
270    api_key: Option<String>,
271    output_dimension: Option<usize>,
272    timeout: Duration,
273    max_batch: Option<usize>,
274}
275
276/// Render `Option<String>` API keys as presence-only, never the secret itself.
277fn redacted(key: &Option<String>) -> Option<&'static str> {
278    key.as_ref().map(|_| "<redacted>")
279}
280
281impl std::fmt::Debug for ClientBuilder {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.debug_struct("ClientBuilder")
284            .field("provider", &self.provider)
285            .field("model", &self.model)
286            .field("base_url", &self.base_url)
287            .field("api_key", &redacted(&self.api_key))
288            .field("output_dimension", &self.output_dimension)
289            .field("timeout", &self.timeout)
290            .field("max_batch", &self.max_batch)
291            .finish()
292    }
293}
294
295impl ClientBuilder {
296    /// Override the provider base URL. Blank/whitespace falls back to the
297    /// provider default; a trailing `/` is trimmed.
298    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
299        self.base_url = Some(base_url.into());
300        self
301    }
302
303    /// Set the API key explicitly instead of reading it from the environment.
304    /// Consulted by every keyed provider (Voyage, OpenAI, Gemini); ignored by
305    /// keyless Ollama.
306    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
307        self.api_key = Some(api_key.into());
308        self
309    }
310
311    /// Request a specific embedding width: Voyage `output_dimension`, OpenAI
312    /// `dimensions`, Gemini `outputDimensionality` (Ollama is model-fixed and
313    /// ignores it). Whatever the provider, every returned vector is validated
314    /// against it, so a drift becomes an [`Error::DimMismatch`] rather than a
315    /// silent schema desync.
316    pub fn output_dimension(mut self, dim: usize) -> Self {
317        self.output_dimension = Some(dim);
318        self
319    }
320
321    /// Override the request timeout (default 120s).
322    pub fn timeout(mut self, timeout: Duration) -> Self {
323        self.timeout = timeout;
324        self
325    }
326
327    /// Cap inputs per HTTP request; larger batches are split into sequential
328    /// requests and their results concatenated in order. Unset sends every input
329    /// in a single request. `0` is treated as `1`.
330    pub fn max_batch(mut self, max_batch: usize) -> Self {
331        self.max_batch = Some(max_batch.max(1));
332        self
333    }
334
335    /// Finish building. Installs the ring TLS provider, constructs the HTTP
336    /// client, and - for a keyed provider - resolves the API key (erroring with
337    /// [`Error::MissingApiKey`] if it is neither passed nor in the provider's
338    /// environment variable).
339    pub fn build(self) -> Result<Client, Error> {
340        install_ring();
341
342        let api_key = match self.provider.api_key_env() {
343            // Keyless (Ollama).
344            None => None,
345            Some(env) => {
346                let key = self
347                    .api_key
348                    .or_else(|| std::env::var(env).ok())
349                    .filter(|k| !k.trim().is_empty());
350                match key {
351                    Some(k) => Some(k),
352                    None => {
353                        return Err(Error::MissingApiKey {
354                            provider: self.provider.label(),
355                            env,
356                        });
357                    }
358                }
359            }
360        };
361
362        let http = reqwest::Client::builder()
363            .timeout(self.timeout)
364            .build()
365            .map_err(|e| Error::ClientBuild(Box::new(e)))?;
366
367        let base_url = self
368            .base_url
369            .map(|b| b.trim().trim_end_matches('/').to_string())
370            .filter(|b| !b.is_empty())
371            .unwrap_or_else(|| self.provider.default_base_url().to_string());
372
373        Ok(Client {
374            http,
375            provider: self.provider,
376            model: self.model,
377            base_url,
378            api_key,
379            output_dimension: self.output_dimension,
380            max_batch: self.max_batch,
381        })
382    }
383}
384
385/// A configured embeddings client. Cheap to clone (`reqwest::Client` is an
386/// `Arc` internally); build it once and reuse it. `Debug` redacts the API key.
387#[derive(Clone)]
388pub struct Client {
389    http: reqwest::Client,
390    provider: Provider,
391    model: String,
392    base_url: String,
393    api_key: Option<String>,
394    output_dimension: Option<usize>,
395    max_batch: Option<usize>,
396}
397
398impl std::fmt::Debug for Client {
399    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400        f.debug_struct("Client")
401            .field("provider", &self.provider)
402            .field("model", &self.model)
403            .field("base_url", &self.base_url)
404            .field("api_key", &redacted(&self.api_key))
405            .field("output_dimension", &self.output_dimension)
406            .field("max_batch", &self.max_batch)
407            .finish_non_exhaustive()
408    }
409}
410
411impl Client {
412    /// Start configuring a client for `provider` using `model`.
413    pub fn builder(provider: Provider, model: impl Into<String>) -> ClientBuilder {
414        ClientBuilder {
415            provider,
416            model: model.into(),
417            base_url: None,
418            api_key: None,
419            output_dimension: None,
420            timeout: DEFAULT_TIMEOUT,
421            max_batch: None,
422        }
423    }
424
425    /// The provider this client talks to.
426    pub fn provider(&self) -> Provider {
427        self.provider
428    }
429
430    /// Embed a batch of texts, preserving input order. `kind` distinguishes
431    /// stored documents from search queries (see [`EmbedKind`]; symmetric
432    /// providers ignore it). Batches larger than `max_batch` are split into
433    /// sequential requests and their results concatenated.
434    ///
435    /// **No retry or backoff.** A transient failure (network blip, a `429`
436    /// rate-limit) returns `Err` immediately; if it happens partway through a
437    /// split batch, the vectors already fetched in this call are discarded.
438    /// Resilience is deliberately the caller's job - inspect [`Error::Api`]'s
439    /// `status` and re-invoke `embed` if you need it.
440    pub async fn embed(&self, texts: &[String], kind: EmbedKind) -> Result<Vec<Vec<f32>>, Error> {
441        if texts.is_empty() {
442            return Ok(Vec::new());
443        }
444        let batch = self.max_batch.unwrap_or(texts.len()).max(1);
445        let mut out = Vec::with_capacity(texts.len());
446        for chunk in texts.chunks(batch) {
447            let vectors = match self.provider {
448                Provider::Ollama => providers::ollama::embed(self, chunk).await?,
449                Provider::Voyage => providers::voyage::embed(self, chunk, kind).await?,
450                Provider::OpenAi => providers::openai::embed(self, chunk).await?,
451                Provider::Gemini => providers::gemini::embed(self, chunk, kind).await?,
452            };
453            out.extend(vectors);
454        }
455        self.validate(out, texts.len())
456    }
457
458    /// The resolved API key for a keyed provider. `build()` guarantees it is
459    /// present for every provider except keyless Ollama, which never calls this.
460    fn require_key(&self) -> &str {
461        self.api_key
462            .as_deref()
463            .expect("invariant: build() resolves an api_key for keyed providers")
464    }
465
466    /// Enforce one vector per input and, if a dimension was pinned, that every
467    /// vector matches it.
468    fn validate(&self, vectors: Vec<Vec<f32>>, expected: usize) -> Result<Vec<Vec<f32>>, Error> {
469        let provider = self.provider.label();
470        if vectors.len() != expected {
471            return Err(Error::CountMismatch {
472                provider,
473                got: vectors.len(),
474                expected,
475            });
476        }
477        if let Some(dim) = self.output_dimension {
478            for v in &vectors {
479                if v.len() != dim {
480                    return Err(Error::DimMismatch {
481                        provider,
482                        got: v.len(),
483                        expected: dim,
484                    });
485                }
486            }
487        }
488        Ok(vectors)
489    }
490}
491
492mod providers;
493
494#[cfg(test)]
495mod tests;