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;