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;