#[cfg(feature = "embedder-http")]
use serde::Deserialize;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive] pub enum EmbedError {
#[error("embedding backend error: {0}")]
Backend(String),
#[error("embedding backend returned an empty vector")]
Empty,
}
pub trait Embedder {
fn dimension(&self) -> usize;
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError>;
}
pub const HASH_EMBEDDER_NOTICE: &str = "Using the offline 'hash' embedder: deterministic and \
fully offline, but NOT semantic — recall matches surface form, not meaning.";
#[derive(Debug, Clone)]
pub struct HashEmbedder {
dimension: usize,
}
impl HashEmbedder {
#[must_use]
pub fn new(dimension: usize) -> Self {
Self { dimension }
}
}
impl Embedder for HashEmbedder {
fn dimension(&self) -> usize {
self.dimension
}
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
let mut vector = vec![0.0_f32; self.dimension];
if self.dimension == 0 {
return Ok(vector);
}
let modulus = self.dimension as u64;
for token in text.split_whitespace() {
let bucket = usize::try_from(crate::id::stable_id(token) % modulus).unwrap_or(0);
vector[bucket] += 1.0;
}
velesdb_core::simd_native::normalize_inplace_native(&mut vector);
Ok(vector)
}
}
pub type DynEmbedder = Box<dyn Embedder + Send + Sync>;
impl<T: Embedder + ?Sized> Embedder for Box<T> {
fn dimension(&self) -> usize {
(**self).dimension()
}
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
(**self).embed(text)
}
}
pub enum EmbedderSelection {
Ready(&'static str, DynEmbedder),
NeedsRemoteConfig(&'static str),
}
impl std::fmt::Debug for EmbedderSelection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Ready(name, _) => write!(f, "Ready({name}, <embedder>)"),
Self::NeedsRemoteConfig(name) => write!(f, "NeedsRemoteConfig({name})"),
}
}
}
pub fn select_embedder(backend: Option<&str>) -> Result<EmbedderSelection, String> {
match backend {
None | Some("hash") => Ok(EmbedderSelection::Ready(
"hash",
Box::new(HashEmbedder::new(crate::DEFAULT_DIMENSION)),
)),
Some("ollama") => Ok(EmbedderSelection::NeedsRemoteConfig("ollama")),
Some("openai") => Ok(EmbedderSelection::NeedsRemoteConfig("openai")),
Some(other) => Err(format!(
"unknown embedding backend '{other}' (expected 'hash' for the \
offline deterministic embedder, 'ollama' for a local model, or \
'openai' for any OpenAI-compatible server — oMLX, llama.cpp, LM \
Studio, vLLM or a hosted provider, selected by URL rather than by \
name)"
)),
}
}
#[cfg(feature = "embedder-http")]
pub const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
#[cfg(feature = "embedder-http")]
pub const DEFAULT_OLLAMA_MODEL: &str = "all-minilm";
#[cfg(feature = "embedder-http")]
#[derive(Debug, Clone)]
pub struct OllamaEmbedder {
base_url: String,
model: String,
dimension: usize,
agent: ureq::Agent,
}
#[cfg(feature = "embedder-http")]
impl OllamaEmbedder {
pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Result<Self, EmbedError> {
let base_url = base_url.into();
let model = model.into();
let agent = embed_agent(std::time::Duration::from_secs(EMBED_TIMEOUT_SECS));
let dimension = request_embedding(&agent, &base_url, &model, "dimension probe")?.len();
if dimension == 0 {
return Err(EmbedError::Empty);
}
Ok(Self {
base_url,
model,
dimension,
agent,
})
}
}
#[cfg(feature = "embedder-http")]
impl Embedder for OllamaEmbedder {
fn dimension(&self) -> usize {
self.dimension
}
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
request_embedding(&self.agent, &self.base_url, &self.model, text)
}
}
#[cfg(feature = "embedder-http")]
#[derive(Debug)]
pub struct OpenAiEmbedder {
client: crate::http_client::HttpJsonClient,
model: String,
dimension: usize,
}
#[cfg(feature = "embedder-http")]
impl OpenAiEmbedder {
pub fn new(
base_url: impl Into<String>,
model: impl Into<String>,
auth: crate::http_client::Auth,
) -> Result<Self, EmbedError> {
let client = crate::http_client::HttpJsonClient::new(
crate::openai::base_url(&base_url.into()),
auth,
embed_agent(std::time::Duration::from_secs(EMBED_TIMEOUT_SECS)),
);
let probing = Self {
client,
model: model.into(),
dimension: 0,
};
let dimension = probing.request("dimension probe")?.len();
if dimension == 0 {
return Err(EmbedError::Empty);
}
Ok(Self {
dimension,
..probing
})
}
fn request(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
let body = crate::openai::embeddings_body(&self.model, text);
let payload = self
.client
.post_json(crate::openai::EMBEDDINGS_PATH, &body)
.map_err(|failure| {
EmbedError::Backend(crate::http_retry::actionable_openai_failure(
"embeddings",
&failure.url,
&self.model,
failure.attempts,
&failure.cause,
Some(
"fall back to the fully-offline embedder with \
VELESDB_MEMORY_EMBEDDER=hash",
),
))
})?;
crate::openai::parse_embeddings_response(&payload).map_err(EmbedError::Backend)
}
}
#[cfg(feature = "embedder-http")]
impl Embedder for OpenAiEmbedder {
fn dimension(&self) -> usize {
self.dimension
}
fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
self.request(text)
}
}
#[cfg(any(feature = "embedder-http", feature = "extractor-http"))]
pub(crate) const DEFAULT_KEEP_ALIVE: i64 = -1;
#[cfg(any(feature = "embedder-http", feature = "extractor-http"))]
pub(crate) fn keep_alive() -> serde_json::Value {
let raw = std::env::var("VELESDB_MEMORY_OLLAMA_KEEP_ALIVE")
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty());
match raw {
None => serde_json::Value::from(DEFAULT_KEEP_ALIVE),
Some(value) => value.parse::<i64>().map_or_else(
|_| serde_json::Value::String(value.clone()),
serde_json::Value::from,
),
}
}
#[cfg(feature = "embedder-http")]
fn build_request_body(model: &str, text: &str) -> String {
serde_json::json!({
"model": model,
"prompt": text,
"keep_alive": keep_alive(),
})
.to_string()
}
#[cfg(feature = "embedder-http")]
#[derive(Deserialize)]
struct EmbeddingResponse {
embedding: Vec<f32>,
}
#[cfg(feature = "embedder-http")]
fn parse_embedding_response(body: &str) -> Result<Vec<f32>, EmbedError> {
let parsed: EmbeddingResponse = serde_json::from_str(body)
.map_err(|err| EmbedError::Backend(format!("invalid embeddings response: {err}")))?;
if parsed.embedding.is_empty() {
return Err(EmbedError::Empty);
}
Ok(parsed.embedding)
}
#[cfg(feature = "embedder-http")]
const EMBED_TIMEOUT_SECS: u64 = 60;
#[cfg(feature = "embedder-http")]
fn embed_agent(timeout: std::time::Duration) -> ureq::Agent {
crate::http_client::bounded_agent(crate::http_client::AgentBudget::local_daemon(timeout))
}
#[cfg(feature = "embedder-http")]
enum OllamaCall {
Transport(Box<ureq::Error>),
Body(std::io::Error),
Payload(EmbedError),
}
#[cfg(feature = "embedder-http")]
fn call_is_retryable(err: &OllamaCall) -> bool {
match err {
OllamaCall::Transport(inner) => crate::http_retry::is_retryable(inner),
OllamaCall::Body(inner) => crate::http_retry::io_is_retryable(inner),
OllamaCall::Payload(_) => false,
}
}
#[cfg(feature = "embedder-http")]
const EMBED_LEVERS: crate::http_retry::FailureLevers<'static> = crate::http_retry::FailureLevers {
url_var: "VELESDB_MEMORY_OLLAMA_URL",
model_var: "VELESDB_MEMORY_OLLAMA_MODEL",
fallback: Some("fall back to the fully-offline embedder with VELESDB_MEMORY_EMBEDDER=hash"),
};
#[cfg(feature = "embedder-http")]
fn request_embedding(
agent: &ureq::Agent,
base_url: &str,
model: &str,
text: &str,
) -> Result<Vec<f32>, EmbedError> {
let url = format!("{base_url}/api/embeddings");
let body = build_request_body(model, text);
let attempt = || {
let response = agent
.post(&url)
.set("Content-Type", "application/json")
.send_string(&body)
.map_err(|err| OllamaCall::Transport(Box::new(err)))?;
let payload = response.into_string().map_err(OllamaCall::Body)?;
parse_embedding_response(&payload).map_err(OllamaCall::Payload)
};
match crate::http_retry::with_retry(
&crate::http_retry::HTTP_RETRIES,
call_is_retryable,
attempt,
) {
Ok(vector) => Ok(vector),
Err((OllamaCall::Payload(err), _)) => Err(err),
Err((OllamaCall::Transport(err), attempts)) => Err(EmbedError::Backend(
crate::http_retry::actionable_ollama_failure(
"embeddings",
&url,
model,
attempts,
&err.to_string(),
&EMBED_LEVERS,
),
)),
Err((OllamaCall::Body(err), attempts)) => Err(EmbedError::Backend(
crate::http_retry::actionable_ollama_failure(
"embeddings",
&url,
model,
attempts,
&format!("reading the response failed: {err}"),
&EMBED_LEVERS,
),
)),
}
}
#[cfg(all(test, feature = "embedder-http"))]
#[path = "embedder_tests.rs"]
mod ollama_tests;
#[cfg(test)]
#[path = "embedder_selection_tests.rs"]
mod selection_tests;