Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
627 changes: 31 additions & 596 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ anyhow = "1.0"
async-trait = "0.1"
chacha20poly1305 = "0.10"
hex = "0.4"
fastembed = { version = "5.13", default-features = false, features = ["hf-hub-native-tls", "ort-load-dynamic"] }
ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", "ndarray", "load-dynamic"] }
tokio-util = { version = "0.7", features = ["rt"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
futures = "0.3"
Expand Down
6 changes: 3 additions & 3 deletions src/openhuman/config/schema/storage_memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ fn default_true() -> bool {
}

fn default_embedding_provider() -> String {
"fastembed".into()
"ollama".into()
}
fn default_hygiene_enabled() -> bool {
true
Expand All @@ -124,10 +124,10 @@ fn default_conversation_retention_days() -> u32 {
30
}
fn default_embedding_model() -> String {
"BGESmallENV15".into()
"nomic-embed-text:latest".into()
}
fn default_embedding_dims() -> usize {
384
768
}
fn default_vector_weight() -> f64 {
0.7
Expand Down
214 changes: 214 additions & 0 deletions src/openhuman/embeddings/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Embedding providers for the OpenHuman memory system.
//!
//! Converts text into numerical vectors for semantic search. Providers:
//!
//! - **Ollama** (default): Delegates to a local Ollama server — handles model
//! management, quantization, and GPU acceleration out of the box.
//! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints.
//! - **Noop**: A fallback provider for keyword-only search.

pub mod noop;
pub mod ollama;
pub mod openai;
pub mod store;

use std::sync::Arc;

use async_trait::async_trait;

pub use noop::NoopEmbedding;
pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL};
pub use openai::OpenAiEmbedding;
pub use store::{bytes_to_vec, cosine_similarity, vec_to_bytes, SearchResult, VectorStore};

/// Interface for embedding providers that convert text into numerical vectors.
#[async_trait]
pub trait EmbeddingProvider: Send + Sync {
/// Returns the name of the provider (e.g., "ollama", "openai").
fn name(&self) -> &str;

/// Returns the number of dimensions in the generated embeddings.
fn dimensions(&self) -> usize;

/// Generates embeddings for a batch of strings.
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>>;

/// Generates an embedding for a single string.
async fn embed_one(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let mut results = self.embed(&[text]).await?;
results
.pop()
.ok_or_else(|| anyhow::anyhow!("Empty embedding result"))
}
}

// ── Factory ──────────────────────────────────────────────────

/// Creates an embedding provider based on the specified name and configuration.
///
/// Supported provider names:
/// - `"ollama"` → local Ollama server (default, preferred)
/// - `"openai"` → OpenAI API
/// - `"custom:<url>"` → OpenAI-compatible endpoint
/// - `"none"` → no-op (keyword-only search, no embeddings)
///
/// Returns an error for unrecognised provider names so configuration
/// mistakes surface immediately rather than silently degrading to
/// keyword-only search.
pub fn create_embedding_provider(
provider: &str,
api_key: Option<&str>,
model: &str,
dims: usize,
) -> anyhow::Result<Box<dyn EmbeddingProvider>> {
match provider {
"ollama" => Ok(Box::new(OllamaEmbedding::new("", model, dims))),
"openai" => {
let key = api_key.unwrap_or("");
Ok(Box::new(OpenAiEmbedding::new(
"https://api.openai.com",
key,
model,
dims,
)))
}
name if name.starts_with("custom:") => {
let base_url = name.strip_prefix("custom:").unwrap_or("");
let key = api_key.unwrap_or("");
Ok(Box::new(OpenAiEmbedding::new(base_url, key, model, dims)))
}
"none" => Ok(Box::new(NoopEmbedding)),
unknown => Err(anyhow::anyhow!(
"unknown embedding provider: \"{unknown}\". \
Supported: \"ollama\", \"openai\", \"custom:<url>\", \"none\""
)),
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Returns the default local embedding provider (Ollama-backed).
pub fn default_local_embedding_provider() -> Arc<dyn EmbeddingProvider> {
Arc::new(OllamaEmbedding::default())
}

#[cfg(test)]
mod tests {
use super::*;

// ── Trait default method ─────────────────────────────────

#[test]
fn noop_name_and_dims() {
let p = NoopEmbedding;
assert_eq!(p.name(), "none");
assert_eq!(p.dimensions(), 0);
}

#[tokio::test]
async fn noop_embed_returns_empty() {
let p = NoopEmbedding;
let result = p.embed(&["hello"]).await.unwrap();
assert!(result.is_empty());
}

#[tokio::test]
async fn noop_embed_one_returns_error() {
// embed returns empty vec → pop() returns None → error from default impl
let p = NoopEmbedding;
let err = p.embed_one("hello").await.unwrap_err();
assert!(err.to_string().contains("Empty embedding result"));
}

#[tokio::test]
async fn noop_embed_empty_batch() {
let p = NoopEmbedding;
let result = p.embed(&[]).await.unwrap();
assert!(result.is_empty());
}

// ── Factory — success ────────────────────────────────────

#[test]
fn factory_ollama() {
let p = create_embedding_provider("ollama", None, DEFAULT_OLLAMA_MODEL, 768).unwrap();
assert_eq!(p.name(), "ollama");
assert_eq!(p.dimensions(), 768);
}

#[test]
fn factory_openai() {
let p = create_embedding_provider("openai", Some("key"), "text-embedding-3-small", 1536)
.unwrap();
assert_eq!(p.name(), "openai");
assert_eq!(p.dimensions(), 1536);
}

#[test]
fn factory_openai_no_api_key() {
let p = create_embedding_provider("openai", None, "text-embedding-3-small", 1536).unwrap();
assert_eq!(p.name(), "openai");
assert_eq!(p.dimensions(), 1536);
}

#[test]
fn factory_custom_url() {
let p =
create_embedding_provider("custom:http://localhost:1234", None, "model", 768).unwrap();
assert_eq!(p.name(), "openai"); // OpenAI-compatible under the hood
assert_eq!(p.dimensions(), 768);
}

#[test]
fn factory_custom_empty_url() {
let p = create_embedding_provider("custom:", None, "model", 768).unwrap();
assert_eq!(p.name(), "openai");
}

#[test]
fn factory_none() {
let p = create_embedding_provider("none", None, "", 0).unwrap();
assert_eq!(p.name(), "none");
assert_eq!(p.dimensions(), 0);
}

// ── Factory — errors ─────────────────────────────────────

#[test]
fn factory_unknown_provider_errors() {
let result = create_embedding_provider("cohere", None, "model", 1536);
let msg = result.err().expect("should be an error").to_string();
assert!(
msg.contains("cohere"),
"should include provider name: {msg}"
);
assert!(msg.contains("unknown"), "should say unknown: {msg}");
}

#[test]
fn factory_empty_string_errors() {
let result = create_embedding_provider("", None, "model", 1536);
assert!(result
.err()
.expect("should error")
.to_string()
.contains("unknown"));
}

#[test]
fn factory_fastembed_errors() {
let result = create_embedding_provider("fastembed", None, "BGESmallENV15", 384);
assert!(result
.err()
.expect("should error")
.to_string()
.contains("fastembed"));
}

// ── Default provider ─────────────────────────────────────

#[test]
fn default_local_provider_uses_ollama() {
let p = default_local_embedding_provider();
assert_eq!(p.name(), "ollama");
assert_eq!(p.dimensions(), DEFAULT_OLLAMA_DIMENSIONS);
}
}
24 changes: 24 additions & 0 deletions src/openhuman/embeddings/noop.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! No-op embedding provider for keyword-only search fallback.

use async_trait::async_trait;

use super::EmbeddingProvider;

/// A "no-op" embedding provider used when semantic search is disabled.
/// Returns empty vectors.
pub struct NoopEmbedding;

#[async_trait]
impl EmbeddingProvider for NoopEmbedding {
fn name(&self) -> &str {
"none"
}

fn dimensions(&self) -> usize {
0
}

async fn embed(&self, _texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
Ok(Vec::new())
}
}
Loading
Loading