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;