Skip to content

Commit 4cf608c

Browse files
authored
feat: native embeddings module with Ollama and vector store (tinyhumansai#521)
* feat: replace fastembed with candle for local embeddings - Introduced a new `CandleEmbedding` provider using the `candle` ML framework, eliminating C++ dependencies and enhancing performance. - Updated configuration to default to `candle` for embedding provider settings, replacing the previous `fastembed` references. - Added new modules for embedding providers, including `candle_embed`, `noop`, and `openai`, to support various embedding strategies. - Enhanced the `EmbeddingProvider` interface to accommodate the new `CandleEmbedding` implementation. - Refactored related tests to ensure comprehensive coverage of the new embedding functionalities and maintain backward compatibility with existing configurations. * feat: update embedding provider to Ollama - Replaced the default embedding provider from Candle to Ollama, enhancing local embedding capabilities with improved model management and GPU acceleration. - Updated configuration defaults for embedding model and dimensions to align with Ollama specifications. - Introduced a new Ollama embedding module, including necessary constants and functionality for embedding requests. - Refactored related code and tests to ensure compatibility with the new provider, maintaining backward compatibility with existing configurations. * refactor: remove Candle embedding provider and related dependencies - Deleted the `CandleEmbedding` module and its associated files, streamlining the embedding provider architecture. - Updated `Cargo.toml` and `Cargo.lock` to remove references to Candle-related packages, ensuring a cleaner dependency tree. - Refactored the `EmbeddingProvider` interface to eliminate support for the Candle provider, maintaining compatibility with existing providers. - Adjusted tests and documentation to reflect the removal of the Candle embedding functionality, ensuring clarity and consistency across the codebase. * feat: add SQLite-backed vector store for embeddings - Introduced a new `store` module to implement a local vector store backed by SQLite, enabling efficient storage and retrieval of text embeddings. - Added functionality for inserting, updating, and searching embeddings using cosine similarity, enhancing the embedding management capabilities. - Updated the `mod.rs` file to include the new `store` module and expose relevant functions for cosine similarity and vector operations. - Enhanced documentation to provide usage examples and clarify the integration of the vector store with existing embedding providers. * Add fs2 dependency for improved file locking in ComposeIO trigger history * test: boost embeddings module coverage to 97%+ Add tests for batch insert mismatch error path, invalid metadata JSON handling, disk store parent directory creation, and fake embedding dimensions accessor. 95 tests total across the module, all files at 97-100% line coverage. * style: apply cargo fmt to embeddings module * feat: enhance embedding provider creation with error handling - Updated the `create_embedding_provider` function to return an `anyhow::Result`, allowing for immediate error reporting on unrecognized provider names. - Added support for a "none" provider that returns a no-op embedding. - Enhanced tests to cover new error handling paths and validate behavior for known and unknown providers, improving overall test coverage. * feat: enhance Ollama and OpenAI embedding providers with improved handling for blank inputs and response validation - Updated the `embed` method in both `OllamaEmbedding` and `OpenAiEmbedding` to skip blank inputs while preserving their positions in the output as zero-vectors. - Added validation to ensure the response count matches the input count, with appropriate error handling for dimension mismatches. - Enhanced tests to cover new behavior for blank inputs and response validation, ensuring robustness in embedding functionality. * fix: address PR review findings for embeddings module - Factory returns Result for unknown providers instead of silent noop - Ollama: preserve positional alignment when blank texts are filtered - Ollama: validate response count and dimensions before returning - OpenAI: strict parsing errors on non-numeric embedding values - OpenAI: skip Authorization header when api_key is empty - OpenAI: validate response count and dimensions - OpenAI/Ollama: add tracing at entry, success, and error paths - Store: add store_meta table to persist and validate embedding provider/dimensions on open — errors on dimension mismatch - Store: propagate row decode errors instead of filter_map(ok) - Store: add tracing to search, insert, delete, and count paths - Update factories.rs caller for Result-returning factory 101 tests pass, all files at 97%+ coverage.
1 parent 3a20599 commit 4cf608c

12 files changed

Lines changed: 2241 additions & 1197 deletions

File tree

‎Cargo.lock‎

Lines changed: 31 additions & 596 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ anyhow = "1.0"
3535
async-trait = "0.1"
3636
chacha20poly1305 = "0.10"
3737
hex = "0.4"
38-
fastembed = { version = "5.13", default-features = false, features = ["hf-hub-native-tls", "ort-load-dynamic"] }
39-
ort = { version = "=2.0.0-rc.11", default-features = false, features = ["std", "ndarray", "load-dynamic"] }
4038
tokio-util = { version = "0.7", features = ["rt"] }
4139
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
4240
futures = "0.3"

‎src/openhuman/config/schema/storage_memory.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ fn default_true() -> bool {
109109
}
110110

111111
fn default_embedding_provider() -> String {
112-
"fastembed".into()
112+
"ollama".into()
113113
}
114114
fn default_hygiene_enabled() -> bool {
115115
true
@@ -124,10 +124,10 @@ fn default_conversation_retention_days() -> u32 {
124124
30
125125
}
126126
fn default_embedding_model() -> String {
127-
"BGESmallENV15".into()
127+
"nomic-embed-text:latest".into()
128128
}
129129
fn default_embedding_dims() -> usize {
130-
384
130+
768
131131
}
132132
fn default_vector_weight() -> f64 {
133133
0.7

‎src/openhuman/embeddings/mod.rs‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
//! Embedding providers for the OpenHuman memory system.
2+
//!
3+
//! Converts text into numerical vectors for semantic search. Providers:
4+
//!
5+
//! - **Ollama** (default): Delegates to a local Ollama server — handles model
6+
//! management, quantization, and GPU acceleration out of the box.
7+
//! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints.
8+
//! - **Noop**: A fallback provider for keyword-only search.
9+
10+
pub mod noop;
11+
pub mod ollama;
12+
pub mod openai;
13+
pub mod store;
14+
15+
use std::sync::Arc;
16+
17+
use async_trait::async_trait;
18+
19+
pub use noop::NoopEmbedding;
20+
pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL};
21+
pub use openai::OpenAiEmbedding;
22+
pub use store::{bytes_to_vec, cosine_similarity, vec_to_bytes, SearchResult, VectorStore};
23+
24+
/// Interface for embedding providers that convert text into numerical vectors.
25+
#[async_trait]
26+
pub trait EmbeddingProvider: Send + Sync {
27+
/// Returns the name of the provider (e.g., "ollama", "openai").
28+
fn name(&self) -> &str;
29+
30+
/// Returns the number of dimensions in the generated embeddings.
31+
fn dimensions(&self) -> usize;
32+
33+
/// Generates embeddings for a batch of strings.
34+
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>>;
35+
36+
/// Generates an embedding for a single string.
37+
async fn embed_one(&self, text: &str) -> anyhow::Result<Vec<f32>> {
38+
let mut results = self.embed(&[text]).await?;
39+
results
40+
.pop()
41+
.ok_or_else(|| anyhow::anyhow!("Empty embedding result"))
42+
}
43+
}
44+
45+
// ── Factory ──────────────────────────────────────────────────
46+
47+
/// Creates an embedding provider based on the specified name and configuration.
48+
///
49+
/// Supported provider names:
50+
/// - `"ollama"` → local Ollama server (default, preferred)
51+
/// - `"openai"` → OpenAI API
52+
/// - `"custom:<url>"` → OpenAI-compatible endpoint
53+
/// - `"none"` → no-op (keyword-only search, no embeddings)
54+
///
55+
/// Returns an error for unrecognised provider names so configuration
56+
/// mistakes surface immediately rather than silently degrading to
57+
/// keyword-only search.
58+
pub fn create_embedding_provider(
59+
provider: &str,
60+
api_key: Option<&str>,
61+
model: &str,
62+
dims: usize,
63+
) -> anyhow::Result<Box<dyn EmbeddingProvider>> {
64+
match provider {
65+
"ollama" => Ok(Box::new(OllamaEmbedding::new("", model, dims))),
66+
"openai" => {
67+
let key = api_key.unwrap_or("");
68+
Ok(Box::new(OpenAiEmbedding::new(
69+
"https://api.openai.com",
70+
key,
71+
model,
72+
dims,
73+
)))
74+
}
75+
name if name.starts_with("custom:") => {
76+
let base_url = name.strip_prefix("custom:").unwrap_or("");
77+
let key = api_key.unwrap_or("");
78+
Ok(Box::new(OpenAiEmbedding::new(base_url, key, model, dims)))
79+
}
80+
"none" => Ok(Box::new(NoopEmbedding)),
81+
unknown => Err(anyhow::anyhow!(
82+
"unknown embedding provider: \"{unknown}\". \
83+
Supported: \"ollama\", \"openai\", \"custom:<url>\", \"none\""
84+
)),
85+
}
86+
}
87+
88+
/// Returns the default local embedding provider (Ollama-backed).
89+
pub fn default_local_embedding_provider() -> Arc<dyn EmbeddingProvider> {
90+
Arc::new(OllamaEmbedding::default())
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
97+
// ── Trait default method ─────────────────────────────────
98+
99+
#[test]
100+
fn noop_name_and_dims() {
101+
let p = NoopEmbedding;
102+
assert_eq!(p.name(), "none");
103+
assert_eq!(p.dimensions(), 0);
104+
}
105+
106+
#[tokio::test]
107+
async fn noop_embed_returns_empty() {
108+
let p = NoopEmbedding;
109+
let result = p.embed(&["hello"]).await.unwrap();
110+
assert!(result.is_empty());
111+
}
112+
113+
#[tokio::test]
114+
async fn noop_embed_one_returns_error() {
115+
// embed returns empty vec → pop() returns None → error from default impl
116+
let p = NoopEmbedding;
117+
let err = p.embed_one("hello").await.unwrap_err();
118+
assert!(err.to_string().contains("Empty embedding result"));
119+
}
120+
121+
#[tokio::test]
122+
async fn noop_embed_empty_batch() {
123+
let p = NoopEmbedding;
124+
let result = p.embed(&[]).await.unwrap();
125+
assert!(result.is_empty());
126+
}
127+
128+
// ── Factory — success ────────────────────────────────────
129+
130+
#[test]
131+
fn factory_ollama() {
132+
let p = create_embedding_provider("ollama", None, DEFAULT_OLLAMA_MODEL, 768).unwrap();
133+
assert_eq!(p.name(), "ollama");
134+
assert_eq!(p.dimensions(), 768);
135+
}
136+
137+
#[test]
138+
fn factory_openai() {
139+
let p = create_embedding_provider("openai", Some("key"), "text-embedding-3-small", 1536)
140+
.unwrap();
141+
assert_eq!(p.name(), "openai");
142+
assert_eq!(p.dimensions(), 1536);
143+
}
144+
145+
#[test]
146+
fn factory_openai_no_api_key() {
147+
let p = create_embedding_provider("openai", None, "text-embedding-3-small", 1536).unwrap();
148+
assert_eq!(p.name(), "openai");
149+
assert_eq!(p.dimensions(), 1536);
150+
}
151+
152+
#[test]
153+
fn factory_custom_url() {
154+
let p =
155+
create_embedding_provider("custom:http://localhost:1234", None, "model", 768).unwrap();
156+
assert_eq!(p.name(), "openai"); // OpenAI-compatible under the hood
157+
assert_eq!(p.dimensions(), 768);
158+
}
159+
160+
#[test]
161+
fn factory_custom_empty_url() {
162+
let p = create_embedding_provider("custom:", None, "model", 768).unwrap();
163+
assert_eq!(p.name(), "openai");
164+
}
165+
166+
#[test]
167+
fn factory_none() {
168+
let p = create_embedding_provider("none", None, "", 0).unwrap();
169+
assert_eq!(p.name(), "none");
170+
assert_eq!(p.dimensions(), 0);
171+
}
172+
173+
// ── Factory — errors ─────────────────────────────────────
174+
175+
#[test]
176+
fn factory_unknown_provider_errors() {
177+
let result = create_embedding_provider("cohere", None, "model", 1536);
178+
let msg = result.err().expect("should be an error").to_string();
179+
assert!(
180+
msg.contains("cohere"),
181+
"should include provider name: {msg}"
182+
);
183+
assert!(msg.contains("unknown"), "should say unknown: {msg}");
184+
}
185+
186+
#[test]
187+
fn factory_empty_string_errors() {
188+
let result = create_embedding_provider("", None, "model", 1536);
189+
assert!(result
190+
.err()
191+
.expect("should error")
192+
.to_string()
193+
.contains("unknown"));
194+
}
195+
196+
#[test]
197+
fn factory_fastembed_errors() {
198+
let result = create_embedding_provider("fastembed", None, "BGESmallENV15", 384);
199+
assert!(result
200+
.err()
201+
.expect("should error")
202+
.to_string()
203+
.contains("fastembed"));
204+
}
205+
206+
// ── Default provider ─────────────────────────────────────
207+
208+
#[test]
209+
fn default_local_provider_uses_ollama() {
210+
let p = default_local_embedding_provider();
211+
assert_eq!(p.name(), "ollama");
212+
assert_eq!(p.dimensions(), DEFAULT_OLLAMA_DIMENSIONS);
213+
}
214+
}

‎src/openhuman/embeddings/noop.rs‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//! No-op embedding provider for keyword-only search fallback.
2+
3+
use async_trait::async_trait;
4+
5+
use super::EmbeddingProvider;
6+
7+
/// A "no-op" embedding provider used when semantic search is disabled.
8+
/// Returns empty vectors.
9+
pub struct NoopEmbedding;
10+
11+
#[async_trait]
12+
impl EmbeddingProvider for NoopEmbedding {
13+
fn name(&self) -> &str {
14+
"none"
15+
}
16+
17+
fn dimensions(&self) -> usize {
18+
0
19+
}
20+
21+
async fn embed(&self, _texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
22+
Ok(Vec::new())
23+
}
24+
}

0 commit comments

Comments
 (0)