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