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// --- Optional real-recall backend: a local Ollama embeddings endpoint --------
93//
94// Enabled with `--features ollama`. The default build omits this backend (and
95// its HTTP dependency) so the shipped binary stays tiny, zero-dependency, and
96// fully offline. This backend keeps the binary small too: it calls a model the
97// user already runs locally, so the memory still never leaves the machine.
98
99/// Default Ollama base URL.
100#[cfg(feature = "ollama")]
101pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
102
103/// Default Ollama embedding model (384-dim; `ollama pull all-minilm`).
104#[cfg(feature = "ollama")]
105pub const DEFAULT_OLLAMA_MODEL: &str = "all-minilm";
106
107/// Embeds text through a local Ollama `/api/embeddings` endpoint — real
108/// semantic recall while the model stays on the user's own machine.
109#[cfg(feature = "ollama")]
110#[derive(Debug, Clone)]
111pub struct OllamaEmbedder {
112    base_url: String,
113    model: String,
114    dimension: usize,
115    agent: ureq::Agent,
116}
117
118#[cfg(feature = "ollama")]
119impl OllamaEmbedder {
120    /// Connect to Ollama at `base_url` using `model`, probing the embedding
121    /// dimension once so it adapts to whatever model is configured.
122    ///
123    /// # Errors
124    /// Returns [`EmbedError`] if Ollama is unreachable or the model does not
125    /// produce embeddings.
126    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Result<Self, EmbedError> {
127        let base_url = base_url.into();
128        let model = model.into();
129        let agent = embed_agent(std::time::Duration::from_secs(EMBED_TIMEOUT_SECS));
130        let dimension = request_embedding(&agent, &base_url, &model, "dimension probe")?.len();
131        if dimension == 0 {
132            return Err(EmbedError::Empty);
133        }
134        Ok(Self {
135            base_url,
136            model,
137            dimension,
138            agent,
139        })
140    }
141}
142
143#[cfg(feature = "ollama")]
144impl Embedder for OllamaEmbedder {
145    fn dimension(&self) -> usize {
146        self.dimension
147    }
148
149    fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
150        request_embedding(&self.agent, &self.base_url, &self.model, text)
151    }
152}
153
154/// Build the JSON request body for the embeddings endpoint.
155/// How long Ollama keeps a model resident after a request. `-1` means "for as
156/// long as the server runs", which is what a daemon wants: the model loads once
157/// and every later call is warm.
158///
159/// Ollama's own default unloads after a few idle minutes, and the reload is not
160/// a rounding error — measured on this repo's extraction model, 14.19 s cold
161/// against 0.22 s warm. An agent that pauses between calls pays that cliff
162/// almost every time, which is precisely the usage pattern here.
163///
164/// Override with `VELESDB_MEMORY_OLLAMA_KEEP_ALIVE` (any value Ollama accepts,
165/// e.g. `30m`, or `0` to unload immediately) when pinning the weights costs
166/// more RAM than the latency is worth.
167#[cfg(any(feature = "ollama", feature = "extract"))]
168pub(crate) const DEFAULT_KEEP_ALIVE: i64 = -1;
169
170/// The configured keep-alive as Ollama expects it on the wire.
171///
172/// The TYPE matters, and getting it wrong fails silently. Ollama reads `-1`
173/// (a JSON **number**) as "never unload", but a JSON **string** `"-1"` is not
174/// a duration it can parse, so it is dropped and the default 5-minute unload
175/// applies — the call looks accepted and the model still disappears. Measured:
176/// numeric `-1` yields `expires_at` in year 2318, the string `"-1"` yields
177/// five minutes. Duration forms like `30m` are strings and must stay strings.
178///
179/// So: parse as a number when it is one, pass through as a string otherwise.
180// Also reachable from `extract.rs`, whose Ollama client sends the same
181// field. Gating this on `ollama` alone broke `--features extract` on its
182// own: `extract` pulls `dep:ureq`, not `ollama`.
183#[cfg(any(feature = "ollama", feature = "extract"))]
184pub(crate) fn keep_alive() -> serde_json::Value {
185    let raw = std::env::var("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE")
186        .ok()
187        .map(|value| value.trim().to_owned())
188        .filter(|value| !value.is_empty());
189    match raw {
190        None => serde_json::Value::from(DEFAULT_KEEP_ALIVE),
191        Some(value) => value.parse::<i64>().map_or_else(
192            |_| serde_json::Value::String(value.clone()),
193            serde_json::Value::from,
194        ),
195    }
196}
197
198#[cfg(feature = "ollama")]
199fn build_request_body(model: &str, text: &str) -> String {
200    serde_json::json!({
201        "model": model,
202        "prompt": text,
203        "keep_alive": keep_alive(),
204    })
205    .to_string()
206}
207
208/// Ollama `/api/embeddings` response shape.
209#[cfg(feature = "ollama")]
210#[derive(Deserialize)]
211struct EmbeddingResponse {
212    embedding: Vec<f32>,
213}
214
215/// Parse an embeddings response body into a vector.
216#[cfg(feature = "ollama")]
217fn parse_embedding_response(body: &str) -> Result<Vec<f32>, EmbedError> {
218    let parsed: EmbeddingResponse = serde_json::from_str(body)
219        .map_err(|err| EmbedError::Backend(format!("invalid embeddings response: {err}")))?;
220    if parsed.embedding.is_empty() {
221        return Err(EmbedError::Empty);
222    }
223    Ok(parsed.embedding)
224}
225
226/// Wall-clock ceiling for one embeddings request.
227///
228/// Generous enough for a COLD Ollama that has to load the model into memory
229/// on the first call (seconds, occasionally tens of seconds), but bounded —
230/// which the bare `ureq::post` used before was not. An unbounded wait here is
231/// not a slow call, it is a **hung caller**: `remember`/`save_working_context`
232/// embed before writing, so an Ollama that accepts the connection and never
233/// answers blocks the MCP tool call until the *client* gives up, surfacing as
234/// an opaque transport timeout with nothing in the server's own error path.
235/// Deliberately far below `extract.rs`'s 300 s: that ceiling covers text
236/// GENERATION, while an embedding that has not returned in a minute is not
237/// going to.
238#[cfg(feature = "ollama")]
239const EMBED_TIMEOUT_SECS: u64 = 60;
240
241/// Ceiling on establishing the TCP connection.
242///
243/// The one setting here that genuinely changes behavior. `ureq` already applies
244/// a connect timeout, but its default is 30 s (`agent.rs`) — a sane figure for
245/// the open internet and an absurd one for a daemon on `localhost`, which either
246/// accepts immediately or is not running. Since retries multiply this wait, 30 s
247/// would turn a dead Ollama into a 90 s stall.
248#[cfg(feature = "ollama")]
249const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
250
251/// Ceiling on writing the request. Applied to the socket at connect time, so —
252/// unlike the read timeout below — it is in force independently of the global
253/// deadline.
254#[cfg(feature = "ollama")]
255const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
256
257/// Agent used for every embeddings request, with [`EMBED_TIMEOUT_SECS`]
258/// applied. Same pattern as [`crate::extract::OllamaExtractor`], which has
259/// bounded its own Ollama calls since it was written.
260///
261/// # Precedence, stated plainly
262///
263/// `ureq` documents that `.timeout()` "takes precedence over `.timeout_read()`
264/// and `.timeout_write()`, but not `.timeout_connect()`", and its
265/// `DeadlineStream` rewrites the socket read deadline to the remaining global
266/// budget before every read. So `.timeout_read()` below is **subordinate**: it
267/// is declared for the day the global bound is lifted, and must not be read as
268/// a per-read ceiling today. `.timeout_connect()` and `.timeout_write()` are the
269/// two that bite. Saying otherwise in a doc — or writing a test that claimed to
270/// prove a per-read bound — would be a reassurance with nothing behind it.
271#[cfg(feature = "ollama")]
272fn embed_agent(timeout: std::time::Duration) -> ureq::Agent {
273    ureq::AgentBuilder::new()
274        .timeout_connect(CONNECT_TIMEOUT)
275        .timeout_write(WRITE_TIMEOUT)
276        .timeout_read(timeout)
277        .timeout(timeout)
278        .build()
279}
280
281/// How one embeddings attempt failed, kept apart just long enough to classify
282/// it: transport and body failures may be replayed, a payload the server
283/// answered in full is the server's final word.
284#[cfg(feature = "ollama")]
285enum OllamaCall {
286    /// The request never completed (reset, refusal, timeout, HTTP error status).
287    /// Boxed: `ureq::Error::Status` carries a whole `Response`.
288    Transport(Box<ureq::Error>),
289    /// The response headers arrived but the body did not read back in full.
290    Body(std::io::Error),
291    /// A complete response that is not a usable embedding — deterministic.
292    Payload(EmbedError),
293}
294
295/// Replay policy for one embeddings attempt.
296#[cfg(feature = "ollama")]
297fn call_is_retryable(err: &OllamaCall) -> bool {
298    match err {
299        OllamaCall::Transport(inner) => crate::ollama_retry::is_retryable(inner),
300        OllamaCall::Body(inner) => crate::ollama_retry::io_is_retryable(inner),
301        OllamaCall::Payload(_) => false,
302    }
303}
304
305/// The knobs that actually configure this backend, named in its failures.
306#[cfg(feature = "ollama")]
307const EMBED_LEVERS: crate::ollama_retry::OllamaLevers<'static> =
308    crate::ollama_retry::OllamaLevers {
309        url_var: "VELESDB_MEMORY_OLLAMA_URL",
310        model_var: "VELESDB_MEMORY_OLLAMA_MODEL",
311        fallback: Some("fall back to the fully-offline embedder with VELESDB_MEMORY_EMBEDDER=hash"),
312    };
313
314/// Perform one embeddings request against a local Ollama, replaying it when the
315/// failure is transient.
316///
317/// The retry is not belt-and-braces. `OllamaEmbedder` holds a single
318/// `ureq::Agent`, hence a keep-alive connection pool; Ollama closes idle
319/// connections, `ureq` hands the dead one back out, and the POST dies with
320/// `Connection reset by peer` — instantly, so the generous `EMBED_TIMEOUT_SECS`
321/// never applies. `ureq` will not replay it either: its internal retry demands
322/// an idempotent method and an empty body, and this is a POST with a body. The
323/// second attempt here dials a fresh connection, which is exactly the repair.
324///
325/// The whole attempt — POST *and* body read — sits inside the closure, so a
326/// truncated response is classified and replayed like any other transport
327/// failure instead of surfacing later as an unexplained parse error.
328#[cfg(feature = "ollama")]
329fn request_embedding(
330    agent: &ureq::Agent,
331    base_url: &str,
332    model: &str,
333    text: &str,
334) -> Result<Vec<f32>, EmbedError> {
335    let url = format!("{base_url}/api/embeddings");
336    let body = build_request_body(model, text);
337    let attempt = || {
338        let response = agent
339            .post(&url)
340            .set("Content-Type", "application/json")
341            .send_string(&body)
342            .map_err(|err| OllamaCall::Transport(Box::new(err)))?;
343        let payload = response.into_string().map_err(OllamaCall::Body)?;
344        parse_embedding_response(&payload).map_err(OllamaCall::Payload)
345    };
346
347    match crate::ollama_retry::with_retry(
348        &crate::ollama_retry::OLLAMA_RETRIES,
349        call_is_retryable,
350        attempt,
351    ) {
352        Ok(vector) => Ok(vector),
353        Err((OllamaCall::Payload(err), _)) => Err(err),
354        Err((OllamaCall::Transport(err), attempts)) => Err(EmbedError::Backend(
355            crate::ollama_retry::actionable_failure(
356                "embeddings",
357                &url,
358                model,
359                attempts,
360                &err.to_string(),
361                &EMBED_LEVERS,
362            ),
363        )),
364        Err((OllamaCall::Body(err), attempts)) => Err(EmbedError::Backend(
365            crate::ollama_retry::actionable_failure(
366                "embeddings",
367                &url,
368                model,
369                attempts,
370                &format!("reading the response failed: {err}"),
371                &EMBED_LEVERS,
372            ),
373        )),
374    }
375}
376
377#[cfg(all(test, feature = "ollama"))]
378#[path = "embedder_tests.rs"]
379mod ollama_tests;