Skip to main content

velesdb_memory/
embedder.rs

1//! Pluggable text → vector embedding.
2//!
3//! The Agent Memory SDK is *bring-your-own-vector*: it never generates
4//! embeddings. This crate mirrors the repo's established pattern (the Python
5//! SDK's `Embedder` protocol, the tauri-rag demo's `fastembed` backend): an
6//! [`Embedder`] trait with a default on-device model and a deterministic,
7//! network-free fallback for tests and air-gapped reproducibility.
8
9#[cfg(feature = "embedder-http")]
10use serde::Deserialize;
11
12/// Failure produced by an [`Embedder`] backend (e.g. a network-backed embedder
13/// that cannot reach its model). The in-memory [`HashEmbedder`] never fails.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive] // error enum, grows by nature; matching externally requires a wildcard arm
16pub enum EmbedError {
17    /// The embedding backend (network, subprocess, …) returned an error.
18    #[error("embedding backend error: {0}")]
19    Backend(String),
20    /// The backend returned an empty embedding vector.
21    #[error("embedding backend returned an empty vector")]
22    Empty,
23}
24
25/// Turns text into a fixed-dimension embedding vector.
26pub trait Embedder {
27    /// Embedding dimension produced by [`Embedder::embed`].
28    fn dimension(&self) -> usize;
29
30    /// Embed `text` into a vector of length [`Embedder::dimension`].
31    ///
32    /// # Errors
33    /// Returns [`EmbedError`] if the backend cannot produce an embedding.
34    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError>;
35}
36
37/// Transport-neutral part of the startup notice adapters emit for `hash`.
38/// The library itself never writes to stderr; each owning binary or language
39/// binding adds the configuration syntax its caller can actually use.
40pub const HASH_EMBEDDER_NOTICE: &str = "Using the offline 'hash' embedder: deterministic and \
41    fully offline, but NOT semantic — recall matches surface form, not meaning.";
42
43/// Deterministic, network-free embedder (token-hashing into L2-normalized
44/// buckets). Not semantically strong — its purpose is reproducible tests and
45/// offline behavior, exactly like the `fake_embed` used in the repo's
46/// `agent_memory` examples. Swap in a real model (e.g. `fastembed`,
47/// all-MiniLM-L6-v2, 384-dim) for production recall quality.
48#[derive(Debug, Clone)]
49pub struct HashEmbedder {
50    dimension: usize,
51}
52
53impl HashEmbedder {
54    /// Create a [`HashEmbedder`] producing vectors of `dimension` length.
55    /// Use `384` to match the SDK's `DEFAULT_DIMENSION`.
56    #[must_use]
57    pub fn new(dimension: usize) -> Self {
58        Self { dimension }
59    }
60}
61
62impl Embedder for HashEmbedder {
63    fn dimension(&self) -> usize {
64        self.dimension
65    }
66
67    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
68        let mut vector = vec![0.0_f32; self.dimension];
69        if self.dimension == 0 {
70            return Ok(vector);
71        }
72        let modulus = self.dimension as u64;
73        for token in text.split_whitespace() {
74            let bucket = usize::try_from(crate::id::stable_id(token) % modulus).unwrap_or(0);
75            vector[bucket] += 1.0;
76        }
77        velesdb_core::simd_native::normalize_inplace_native(&mut vector);
78        Ok(vector)
79    }
80}
81
82/// A boxed, object-safe embedder. Lets a non-generic `MemoryService<DynEmbedder>`
83/// be stored behind a concrete type — the MCP server and the language bindings
84/// both need this, since handler/pyclass types can't carry a generic `E`.
85pub type DynEmbedder = Box<dyn Embedder + Send + Sync>;
86
87/// Forward [`Embedder`] through a box, enabling a non-generic
88/// `MemoryService<DynEmbedder>` for the MCP server and bindings.
89impl<T: Embedder + ?Sized> Embedder for Box<T> {
90    fn dimension(&self) -> usize {
91        (**self).dimension()
92    }
93
94    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
95        (**self).embed(text)
96    }
97}
98
99// --- Backend selection -------------------------------------------------------
100
101/// What a caller must do about a named embedding backend.
102///
103/// Two variants, not three: unlike [`crate::ExtractorSelection`] there is no
104/// `Disabled`. "No extraction" is a real choice — the graph simply does not
105/// build — while a memory store cannot exist without an embedder. Every
106/// accepted name therefore resolves to something usable.
107/// **Deliberately exhaustive** (no `non_exhaustive`): every variant demands
108/// caller wiring — construct a backend, ask for configuration, run nothing —
109/// and a wildcard arm would silently ignore a new capability instead of
110/// failing to compile where it must be handled. Adding a variant is therefore
111/// a breaking change, made on purpose, in a minor bump while the crate is 0.x.
112pub enum EmbedderSelection {
113    /// Ready to use as-is: needs no configuration, no network, and no optional
114    /// dependency.
115    ///
116    /// Carries the backend's name, which [`ExtractorSelection::Ready`] does
117    /// not. The daemon prints a startup notice that belongs to one specific
118    /// backend (`hash` is deterministic but not semantic), and a library must
119    /// not write to stderr on a caller's behalf. Naming the backend here lets
120    /// the binary decide, instead of inferring "ready implies hash" — an
121    /// inference a second offline backend would silently break.
122    ///
123    /// [`ExtractorSelection::Ready`]: crate::ExtractorSelection::Ready
124    Ready(&'static str, DynEmbedder),
125    /// A network-backed backend the caller must build itself, because only the
126    /// caller knows its URL and model. Carries the backend's name so the caller
127    /// can dispatch without re-parsing the string.
128    NeedsRemoteConfig(&'static str),
129}
130
131/// Hand-written because [`DynEmbedder`] is a trait object and [`Embedder`] does
132/// not require `Debug` — a backend is identified by its shape here, never by
133/// dumping its innards (an HTTP-backed one holds a URL, and a panic message is
134/// not the place for it). Mirrors [`crate::ExtractorSelection`]'s own impl.
135impl std::fmt::Debug for EmbedderSelection {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        match self {
138            Self::Ready(name, _) => write!(f, "Ready({name}, <embedder>)"),
139            Self::NeedsRemoteConfig(name) => write!(f, "NeedsRemoteConfig({name})"),
140        }
141    }
142}
143
144/// Resolve an embedding backend name to what the caller must do about it.
145///
146/// `backend` is `None` when the selecting variable is **unset**, and
147/// `Some(value)` for whatever it was set to. The distinction is load-bearing
148/// and predates this seam: an unset variable means "no preference" and takes
149/// the offline default, while an empty or misspelled value is a caller who
150/// asked for something and got it wrong. Reading both as "unset" — the shape
151/// an `unwrap_or_default()` at the call site would produce — would turn that
152/// mistake into a silent default.
153///
154/// # Why this exists, and why it is in the library rather than the binary
155///
156/// The embedding selection used to be a bare `match` inside `main.rs`, so
157/// nothing could exercise it without starting the daemon and reading its
158/// stderr. Its counterpart [`crate::select_extractor`] already lives here for
159/// the reasons #1734 made expensive; keeping the two apart meant only one of
160/// the two roles was testable, and only one could gain a backend without
161/// touching the binary.
162///
163/// # Errors
164/// A human-readable message naming the accepted forms, for an unknown backend.
165pub fn select_embedder(backend: Option<&str>) -> Result<EmbedderSelection, String> {
166    match backend {
167        // No `#[cfg]` on this arm, deliberately: `hash` is linked into every
168        // build, including the published one that has no HTTP backend at all,
169        // and it is what an unconfigured daemon runs on.
170        None | Some("hash") => Ok(EmbedderSelection::Ready(
171            "hash",
172            Box::new(HashEmbedder::new(crate::DEFAULT_DIMENSION)),
173        )),
174        Some("ollama") => Ok(EmbedderSelection::NeedsRemoteConfig("ollama")),
175        // A protocol, not a vendor: oMLX, llama.cpp's server, LM Studio, vLLM
176        // and the hosted providers all speak it. Reaching a new one is a
177        // different URL — never a new name here. That is what stops this
178        // `match` from growing a vendor list (#1730).
179        Some("openai") => Ok(EmbedderSelection::NeedsRemoteConfig("openai")),
180        Some(other) => Err(format!(
181            "unknown embedding backend '{other}' (expected 'hash' for the \
182             offline deterministic embedder, 'ollama' for a local model, or \
183             'openai' for any OpenAI-compatible server — oMLX, llama.cpp, LM \
184             Studio, vLLM or a hosted provider, selected by URL rather than by \
185             name)"
186        )),
187    }
188}
189
190// --- Optional real-recall backend: a local Ollama embeddings endpoint --------
191//
192// Enabled with `--features embedder-http`. A minimal `--no-default-features`
193// build omits this backend and its HTTP dependency. This backend keeps the
194// binary small: it calls a model the user already runs locally, so the memory
195// still never leaves the machine.
196
197/// Default Ollama base URL.
198#[cfg(feature = "embedder-http")]
199pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
200
201/// Default Ollama embedding model (384-dim; `ollama pull all-minilm`).
202#[cfg(feature = "embedder-http")]
203pub const DEFAULT_OLLAMA_MODEL: &str = "all-minilm";
204
205/// Embeds text through a local Ollama `/api/embeddings` endpoint — real
206/// semantic recall while the model stays on the user's own machine.
207#[cfg(feature = "embedder-http")]
208#[derive(Debug, Clone)]
209pub struct OllamaEmbedder {
210    base_url: String,
211    model: String,
212    dimension: usize,
213    agent: ureq::Agent,
214}
215
216#[cfg(feature = "embedder-http")]
217impl OllamaEmbedder {
218    /// Connect to Ollama at `base_url` using `model`, probing the embedding
219    /// dimension once so it adapts to whatever model is configured.
220    ///
221    /// # Errors
222    /// Returns [`EmbedError`] if Ollama is unreachable or the model does not
223    /// produce embeddings.
224    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Result<Self, EmbedError> {
225        let base_url = base_url.into();
226        let model = model.into();
227        let agent = embed_agent(std::time::Duration::from_secs(EMBED_TIMEOUT_SECS));
228        let dimension = request_embedding(&agent, &base_url, &model, "dimension probe")?.len();
229        if dimension == 0 {
230            return Err(EmbedError::Empty);
231        }
232        Ok(Self {
233            base_url,
234            model,
235            dimension,
236            agent,
237        })
238    }
239}
240
241#[cfg(feature = "embedder-http")]
242impl Embedder for OllamaEmbedder {
243    fn dimension(&self) -> usize {
244        self.dimension
245    }
246
247    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
248        request_embedding(&self.agent, &self.base_url, &self.model, text)
249    }
250}
251
252/// Embeds through any **OpenAI-compatible** `/v1/embeddings` endpoint — oMLX,
253/// llama.cpp's server, LM Studio, vLLM, or a hosted provider.
254///
255/// A sibling of [`OllamaEmbedder`], not a layer over it: each sits directly on
256/// its own protocol, both over the same transport. Reaching a new server is a
257/// different base URL, never a new backend name.
258///
259/// Gated on `feature = "embedder-http"`, which carries this crate's HTTP
260/// dependency for the embedding role.
261#[cfg(feature = "embedder-http")]
262#[derive(Debug)]
263pub struct OpenAiEmbedder {
264    client: crate::http_client::HttpJsonClient,
265    model: String,
266    dimension: usize,
267}
268
269#[cfg(feature = "embedder-http")]
270impl OpenAiEmbedder {
271    /// Connect to the server at `base_url` using `model`, probing the
272    /// embedding dimension once so it adapts to whatever model is configured.
273    ///
274    /// `base_url` is the server's origin, port included and path excluded
275    /// (`http://localhost:8020`): the `/v1/embeddings` suffix belongs to the
276    /// protocol, not to the caller.
277    ///
278    /// # Errors
279    /// [`EmbedError`] if the server is unreachable, refuses the request, or
280    /// answers with no vector.
281    pub fn new(
282        base_url: impl Into<String>,
283        model: impl Into<String>,
284        auth: crate::http_client::Auth,
285    ) -> Result<Self, EmbedError> {
286        let client = crate::http_client::HttpJsonClient::new(
287            crate::openai::base_url(&base_url.into()),
288            auth,
289            embed_agent(std::time::Duration::from_secs(EMBED_TIMEOUT_SECS)),
290        );
291        let probing = Self {
292            client,
293            model: model.into(),
294            dimension: 0,
295        };
296        let dimension = probing.request("dimension probe")?.len();
297        if dimension == 0 {
298            return Err(EmbedError::Empty);
299        }
300        Ok(Self {
301            dimension,
302            ..probing
303        })
304    }
305
306    /// One embeddings call. The protocol layer builds the body and reads the
307    /// answer back; this method supplies only the model and renders the
308    /// failure — the two things the protocol has no business knowing.
309    fn request(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
310        let body = crate::openai::embeddings_body(&self.model, text);
311        let payload = self
312            .client
313            .post_json(crate::openai::EMBEDDINGS_PATH, &body)
314            .map_err(|failure| {
315                EmbedError::Backend(crate::http_retry::actionable_openai_failure(
316                    "embeddings",
317                    &failure.url,
318                    &self.model,
319                    failure.attempts,
320                    &failure.cause,
321                    Some(
322                        "fall back to the fully-offline embedder with \
323                         VELESDB_MEMORY_EMBEDDER=hash",
324                    ),
325                ))
326            })?;
327        crate::openai::parse_embeddings_response(&payload).map_err(EmbedError::Backend)
328    }
329}
330
331#[cfg(feature = "embedder-http")]
332impl Embedder for OpenAiEmbedder {
333    fn dimension(&self) -> usize {
334        self.dimension
335    }
336
337    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
338        self.request(text)
339    }
340}
341
342/// Build the JSON request body for the embeddings endpoint.
343/// How long Ollama keeps a model resident after a request. `-1` means "for as
344/// long as the server runs", which is what a daemon wants: the model loads once
345/// and every later call is warm.
346///
347/// Ollama's own default unloads after a few idle minutes, and the reload is not
348/// a rounding error — measured on this repo's extraction model, 14.19 s cold
349/// against 0.22 s warm. An agent that pauses between calls pays that cliff
350/// almost every time, which is precisely the usage pattern here.
351///
352/// Override with `VELESDB_MEMORY_OLLAMA_KEEP_ALIVE` (any value Ollama accepts,
353/// e.g. `30m`, or `0` to unload immediately) when pinning the weights costs
354/// more RAM than the latency is worth.
355#[cfg(any(feature = "embedder-http", feature = "extractor-http"))]
356pub(crate) const DEFAULT_KEEP_ALIVE: i64 = -1;
357
358/// The configured keep-alive as Ollama expects it on the wire.
359///
360/// The TYPE matters, and getting it wrong fails silently. Ollama reads `-1`
361/// (a JSON **number**) as "never unload", but a JSON **string** `"-1"` is not
362/// a duration it can parse, so it is dropped and the default 5-minute unload
363/// applies — the call looks accepted and the model still disappears. Measured:
364/// numeric `-1` yields `expires_at` in year 2318, the string `"-1"` yields
365/// five minutes. Duration forms like `30m` are strings and must stay strings.
366///
367/// So: parse as a number when it is one, pass through as a string otherwise.
368// Also reachable from `extract.rs`, whose Ollama client sends the same
369// field. Gating this on the embedding role alone broke the extraction role in
370// isolation; both role features pull `dep:ureq`.
371#[cfg(any(feature = "embedder-http", feature = "extractor-http"))]
372pub(crate) fn keep_alive() -> serde_json::Value {
373    let raw = std::env::var("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE")
374        .ok()
375        .map(|value| value.trim().to_owned())
376        .filter(|value| !value.is_empty());
377    match raw {
378        None => serde_json::Value::from(DEFAULT_KEEP_ALIVE),
379        Some(value) => value.parse::<i64>().map_or_else(
380            |_| serde_json::Value::String(value.clone()),
381            serde_json::Value::from,
382        ),
383    }
384}
385
386#[cfg(feature = "embedder-http")]
387fn build_request_body(model: &str, text: &str) -> String {
388    serde_json::json!({
389        "model": model,
390        "prompt": text,
391        "keep_alive": keep_alive(),
392    })
393    .to_string()
394}
395
396/// Ollama `/api/embeddings` response shape.
397#[cfg(feature = "embedder-http")]
398#[derive(Deserialize)]
399struct EmbeddingResponse {
400    embedding: Vec<f32>,
401}
402
403/// Parse an embeddings response body into a vector.
404#[cfg(feature = "embedder-http")]
405fn parse_embedding_response(body: &str) -> Result<Vec<f32>, EmbedError> {
406    let parsed: EmbeddingResponse = serde_json::from_str(body)
407        .map_err(|err| EmbedError::Backend(format!("invalid embeddings response: {err}")))?;
408    if parsed.embedding.is_empty() {
409        return Err(EmbedError::Empty);
410    }
411    Ok(parsed.embedding)
412}
413
414/// Wall-clock ceiling for one embeddings request.
415///
416/// Generous enough for a COLD Ollama that has to load the model into memory
417/// on the first call (seconds, occasionally tens of seconds), but bounded —
418/// which the bare `ureq::post` used before was not. An unbounded wait here is
419/// not a slow call, it is a **hung caller**: `remember`/`save_working_context`
420/// embed before writing, so an Ollama that accepts the connection and never
421/// answers blocks the MCP tool call until the *client* gives up, surfacing as
422/// an opaque transport timeout with nothing in the server's own error path.
423/// Deliberately far below `extract.rs`'s 300 s: that ceiling covers text
424/// GENERATION, while an embedding that has not returned in a minute is not
425/// going to.
426#[cfg(feature = "embedder-http")]
427const EMBED_TIMEOUT_SECS: u64 = 60;
428
429/// Agent for every embeddings request: the shared local-daemon transport
430/// budget with [`EMBED_TIMEOUT_SECS`] as the whole-request deadline. The
431/// timeout-precedence contract lives on
432/// [`crate::http_client::bounded_agent`], its single authoritative copy.
433#[cfg(feature = "embedder-http")]
434fn embed_agent(timeout: std::time::Duration) -> ureq::Agent {
435    crate::http_client::bounded_agent(crate::http_client::AgentBudget::local_daemon(timeout))
436}
437
438/// How one embeddings attempt failed, kept apart just long enough to classify
439/// it: transport and body failures may be replayed, a payload the server
440/// answered in full is the server's final word.
441#[cfg(feature = "embedder-http")]
442enum OllamaCall {
443    /// The request never completed (reset, refusal, timeout, HTTP error status).
444    /// Boxed: `ureq::Error::Status` carries a whole `Response`.
445    Transport(Box<ureq::Error>),
446    /// The response headers arrived but the body did not read back in full.
447    Body(std::io::Error),
448    /// A complete response that is not a usable embedding — deterministic.
449    Payload(EmbedError),
450}
451
452/// Replay policy for one embeddings attempt.
453#[cfg(feature = "embedder-http")]
454fn call_is_retryable(err: &OllamaCall) -> bool {
455    match err {
456        OllamaCall::Transport(inner) => crate::http_retry::is_retryable(inner),
457        OllamaCall::Body(inner) => crate::http_retry::io_is_retryable(inner),
458        OllamaCall::Payload(_) => false,
459    }
460}
461
462/// The knobs that actually configure this backend, named in its failures.
463#[cfg(feature = "embedder-http")]
464const EMBED_LEVERS: crate::http_retry::FailureLevers<'static> = crate::http_retry::FailureLevers {
465    url_var: "VELESDB_MEMORY_OLLAMA_URL",
466    model_var: "VELESDB_MEMORY_OLLAMA_MODEL",
467    fallback: Some("fall back to the fully-offline embedder with VELESDB_MEMORY_EMBEDDER=hash"),
468};
469
470/// Perform one embeddings request against a local Ollama, replaying it when the
471/// failure is transient.
472///
473/// The retry is not belt-and-braces. `OllamaEmbedder` holds a single
474/// `ureq::Agent`, hence a keep-alive connection pool; Ollama closes idle
475/// connections, `ureq` hands the dead one back out, and the POST dies with
476/// `Connection reset by peer` — instantly, so the generous `EMBED_TIMEOUT_SECS`
477/// never applies. `ureq` will not replay it either: its internal retry demands
478/// an idempotent method and an empty body, and this is a POST with a body. The
479/// second attempt here dials a fresh connection, which is exactly the repair.
480///
481/// The whole attempt — POST *and* body read — sits inside the closure, so a
482/// truncated response is classified and replayed like any other transport
483/// failure instead of surfacing later as an unexplained parse error.
484#[cfg(feature = "embedder-http")]
485fn request_embedding(
486    agent: &ureq::Agent,
487    base_url: &str,
488    model: &str,
489    text: &str,
490) -> Result<Vec<f32>, EmbedError> {
491    let url = format!("{base_url}/api/embeddings");
492    let body = build_request_body(model, text);
493    let attempt = || {
494        let response = agent
495            .post(&url)
496            .set("Content-Type", "application/json")
497            .send_string(&body)
498            .map_err(|err| OllamaCall::Transport(Box::new(err)))?;
499        let payload = response.into_string().map_err(OllamaCall::Body)?;
500        parse_embedding_response(&payload).map_err(OllamaCall::Payload)
501    };
502
503    match crate::http_retry::with_retry(
504        &crate::http_retry::HTTP_RETRIES,
505        call_is_retryable,
506        attempt,
507    ) {
508        Ok(vector) => Ok(vector),
509        Err((OllamaCall::Payload(err), _)) => Err(err),
510        Err((OllamaCall::Transport(err), attempts)) => Err(EmbedError::Backend(
511            crate::http_retry::actionable_ollama_failure(
512                "embeddings",
513                &url,
514                model,
515                attempts,
516                &err.to_string(),
517                &EMBED_LEVERS,
518            ),
519        )),
520        Err((OllamaCall::Body(err), attempts)) => Err(EmbedError::Backend(
521            crate::http_retry::actionable_ollama_failure(
522                "embeddings",
523                &url,
524                model,
525                attempts,
526                &format!("reading the response failed: {err}"),
527                &EMBED_LEVERS,
528            ),
529        )),
530    }
531}
532
533#[cfg(all(test, feature = "embedder-http"))]
534#[path = "embedder_tests.rs"]
535mod ollama_tests;
536
537#[cfg(test)]
538#[path = "embedder_selection_tests.rs"]
539mod selection_tests;