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;