fix(memory): default embeddings to cloud (Voyage), opt in for local Ollama (#1508)

This commit is contained in:
Steven Enamakel
2026-05-11 19:10:29 -07:00
committed by GitHub
parent fd0ac9cb0e
commit 5bef91ea4d
10 changed files with 362 additions and 34 deletions
@@ -592,8 +592,9 @@ impl Agent {
&config.workspace_dir,
));
let memory: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage_and_routes(
let memory: Arc<dyn Memory> = Arc::from(memory::create_memory_with_local_ai(
&config.memory,
&config.local_ai,
&config.embedding_routes,
Some(&config.storage.provider.config),
&config.workspace_dir,
+3 -1
View File
@@ -184,8 +184,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
.clone()
.unwrap_or_else(|| crate::openhuman::config::DEFAULT_MODEL.into());
let temperature = config.default_temperature;
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_storage(
let mem: Arc<dyn Memory> = Arc::from(memory::create_memory_with_local_ai(
&config.memory,
&config.local_ai,
&[],
Some(&config.storage.provider.config),
&config.workspace_dir,
)?);
+10 -3
View File
@@ -48,13 +48,20 @@ pub struct MemoryConfig {
}
fn default_embedding_provider() -> String {
"ollama".into()
// Default to the OpenHuman backend (Voyage-backed `embedding-v1`) so a
// fresh install works without requiring a local Ollama daemon. Users
// who want fully-local embeddings can flip this to "ollama" in
// `config.toml` or enable `local_ai.usage.embeddings = true`, which is
// wired into the memory factory via [`LocalAiConfig::use_local_for_embeddings`].
"cloud".into()
}
fn default_embedding_model() -> String {
"nomic-embed-text:latest".into()
// Keep this in sync with `embeddings::cloud::DEFAULT_CLOUD_EMBEDDING_MODEL`.
"embedding-v1".into()
}
fn default_embedding_dims() -> usize {
768
// Keep this in sync with `embeddings::cloud::DEFAULT_CLOUD_EMBEDDING_DIMENSIONS`.
1024
}
fn default_min_relevance_score() -> f64 {
0.4
+152
View File
@@ -0,0 +1,152 @@
//! Cloud embedding provider — routes through the OpenHuman backend's
//! `POST /openai/v1/embeddings` surface (Voyage-backed) using the same
//! session JWT that the `OpenHumanBackendProvider` chat path uses.
//!
//! This is the default embedder for a fresh install. The local Ollama path
//! stays available, but the user has to explicitly opt in (either by setting
//! `memory.embedding_provider = "ollama"` in `config.toml`, or by enabling
//! the local-AI runtime with `local_ai.usage.embeddings = true`).
//!
//! The JWT and API URL are resolved per call so a session refresh between
//! embed batches is picked up transparently — matching
//! [`crate::openhuman::providers::openhuman_backend::OpenHumanBackendProvider`].
use std::path::PathBuf;
use async_trait::async_trait;
use super::openai::OpenAiEmbedding;
use super::EmbeddingProvider;
use crate::api::config::effective_api_url;
use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER};
/// Default cloud embedding model — backed by `voyage-3.5` (1024 dims) on the
/// OpenHuman backend. See `tinyhumansai/backend#746`.
pub const DEFAULT_CLOUD_EMBEDDING_MODEL: &str = "embedding-v1";
/// Default output dimensionality for [`DEFAULT_CLOUD_EMBEDDING_MODEL`].
pub const DEFAULT_CLOUD_EMBEDDING_DIMENSIONS: usize = 1024;
/// OpenHuman-backend-backed embedding provider.
pub struct OpenHumanCloudEmbedding {
api_url: Option<String>,
openhuman_dir: Option<PathBuf>,
secrets_encrypt: bool,
model: String,
dims: usize,
}
impl OpenHumanCloudEmbedding {
/// Construct a cloud embedder. `api_url` and `openhuman_dir` are looked up
/// per request; pass `None` to fall back to the runtime defaults
/// ([`effective_api_url`] / `~/.openhuman`).
pub fn new(
api_url: Option<String>,
openhuman_dir: Option<PathBuf>,
secrets_encrypt: bool,
model: impl Into<String>,
dims: usize,
) -> Self {
Self {
api_url: api_url
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
openhuman_dir,
secrets_encrypt,
model: model.into(),
dims,
}
}
fn state_dir(&self) -> PathBuf {
self.openhuman_dir.clone().unwrap_or_else(|| {
directories::UserDirs::new()
.map(|d| d.home_dir().join(".openhuman"))
.unwrap_or_else(|| PathBuf::from(".openhuman"))
})
}
fn resolve_bearer(&self) -> anyhow::Result<String> {
let auth = AuthService::new(&self.state_dir(), self.secrets_encrypt);
if let Some(t) = auth
.get_provider_bearer_token(APP_SESSION_PROVIDER, None)?
.filter(|s| !s.trim().is_empty())
{
return Ok(t);
}
anyhow::bail!(
"No backend session for cloud embeddings: log in to OpenHuman, or set \
memory.embedding_provider to \"ollama\" / \"none\" in config.toml"
)
}
fn base_url(&self) -> String {
let u = effective_api_url(&self.api_url);
format!("{}/openai/v1", u.trim_end_matches('/'))
}
}
#[async_trait]
impl EmbeddingProvider for OpenHumanCloudEmbedding {
fn name(&self) -> &str {
"cloud"
}
fn dimensions(&self) -> usize {
self.dims
}
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let token = self.resolve_bearer()?;
let inner = OpenAiEmbedding::new(&self.base_url(), &token, &self.model, self.dims);
inner.embed(texts).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_and_dimensions() {
let p = OpenHumanCloudEmbedding::new(
None,
None,
true,
DEFAULT_CLOUD_EMBEDDING_MODEL,
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
);
assert_eq!(p.name(), "cloud");
assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS);
}
#[test]
fn base_url_appends_openai_v1() {
let p = OpenHumanCloudEmbedding::new(
Some("https://api.openhuman.example/".into()),
None,
true,
DEFAULT_CLOUD_EMBEDDING_MODEL,
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
);
assert_eq!(p.base_url(), "https://api.openhuman.example/openai/v1");
}
#[tokio::test]
async fn embed_empty_returns_empty_without_auth() {
// Empty input should short-circuit *before* hitting the AuthService —
// otherwise the no-op path would spuriously fail in unauthenticated
// contexts (e.g. ingestion of an empty chunk batch).
let p = OpenHumanCloudEmbedding::new(
None,
None,
false,
DEFAULT_CLOUD_EMBEDDING_MODEL,
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
);
assert!(p.embed(&[]).await.unwrap().is_empty());
}
}
+26 -3
View File
@@ -2,13 +2,17 @@
use std::sync::Arc;
use super::cloud::{
OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL,
};
use super::provider_trait::EmbeddingProvider;
use super::{NoopEmbedding, OllamaEmbedding, OpenAiEmbedding};
/// Creates an embedding provider based on the specified name and configuration.
///
/// Supported provider names:
/// - `"ollama"` → local Ollama server (default, preferred)
/// - `"cloud"` → OpenHuman backend (Voyage-backed) — default, preferred
/// - `"ollama"` → local Ollama server (opt-in for offline-only installs)
/// - `"openai"` → OpenAI API
/// - `"custom:<url>"` → OpenAI-compatible endpoint
/// - `"none"` → no-op (keyword-only search, no embeddings)
@@ -22,6 +26,9 @@ pub fn create_embedding_provider(
dims: usize,
) -> anyhow::Result<Box<dyn EmbeddingProvider>> {
match provider {
"cloud" => Ok(Box::new(OpenHumanCloudEmbedding::new(
None, None, true, model, dims,
))),
"ollama" => Ok(Box::new(OllamaEmbedding::new("", model, dims))),
"openai" => Ok(Box::new(OpenAiEmbedding::new(
"https://api.openai.com",
@@ -36,12 +43,28 @@ pub fn create_embedding_provider(
"none" => Ok(Box::new(NoopEmbedding)),
unknown => Err(anyhow::anyhow!(
"unknown embedding provider: \"{unknown}\". \
Supported: \"ollama\", \"openai\", \"custom:<url>\", \"none\""
Supported: \"cloud\", \"ollama\", \"openai\", \"custom:<url>\", \"none\""
)),
}
}
/// Returns the default local embedding provider (Ollama-backed).
/// Returns the default embedding provider — cloud (OpenHuman backend, Voyage).
///
/// The cloud embedder lazily resolves the session JWT and API URL on each
/// call, so this can be constructed before login completes; the first
/// `embed()` will fail with a clear message if the user is unauthenticated.
pub fn default_embedding_provider() -> Arc<dyn EmbeddingProvider> {
Arc::new(OpenHumanCloudEmbedding::new(
None,
None,
true,
DEFAULT_CLOUD_EMBEDDING_MODEL,
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
))
}
/// Returns the local Ollama-backed embedding provider. Only used when the
/// caller has explicitly opted into local-only embeddings.
pub fn default_local_embedding_provider() -> Arc<dyn EmbeddingProvider> {
Arc::new(OllamaEmbedding::default())
}
+32 -3
View File
@@ -2,11 +2,16 @@
//!
//! 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.
//! - **Cloud** (default): Routes through the OpenHuman backend's
//! `POST /openai/v1/embeddings` (Voyage-backed). The recommended path —
//! works on a fresh install without requiring a local Ollama daemon.
//! - **Ollama**: Local Ollama server. Opt-in for offline-only setups
//! (set `memory.embedding_provider = "ollama"` or enable
//! `local_ai.usage.embeddings`).
//! - **OpenAI**: Cloud-based embeddings via the OpenAI API or compatible endpoints.
//! - **Noop**: A fallback provider for keyword-only search.
pub mod cloud;
mod factory;
pub mod noop;
pub mod ollama;
@@ -14,7 +19,12 @@ pub mod openai;
mod provider_trait;
pub mod store;
pub use factory::{create_embedding_provider, default_local_embedding_provider};
pub use cloud::{
OpenHumanCloudEmbedding, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL,
};
pub use factory::{
create_embedding_provider, default_embedding_provider, default_local_embedding_provider,
};
pub use noop::NoopEmbedding;
pub use ollama::{OllamaEmbedding, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL};
pub use openai::OpenAiEmbedding;
@@ -125,8 +135,27 @@ mod tests {
.contains("fastembed"));
}
#[test]
fn factory_cloud() {
let p = create_embedding_provider(
"cloud",
DEFAULT_CLOUD_EMBEDDING_MODEL,
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
)
.unwrap();
assert_eq!(p.name(), "cloud");
assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS);
}
// ── Default provider ─────────────────────────────────────
#[test]
fn default_provider_uses_cloud() {
let p = default_embedding_provider();
assert_eq!(p.name(), "cloud");
assert_eq!(p.dimensions(), DEFAULT_CLOUD_EMBEDDING_DIMENSIONS);
}
#[test]
fn default_local_provider_uses_ollama() {
let p = default_local_embedding_provider();
+5 -4
View File
@@ -32,10 +32,11 @@ pub use schemas::{
all_registered_controllers as all_memory_registered_controllers,
};
pub use store::{
create_memory, create_memory_for_migration, create_memory_with_storage,
create_memory_with_storage_and_routes, effective_memory_backend_name, MemoryClient,
MemoryClientRef, MemoryItemKind, MemoryState, NamespaceDocumentInput, NamespaceMemoryHit,
NamespaceQueryResult, NamespaceRetrievalContext, RetrievalScoreBreakdown, UnifiedMemory,
create_memory, create_memory_for_migration, create_memory_with_local_ai,
create_memory_with_storage, create_memory_with_storage_and_routes,
effective_embedding_settings, effective_memory_backend_name, MemoryClient, MemoryClientRef,
MemoryItemKind, MemoryState, NamespaceDocumentInput, NamespaceMemoryHit, NamespaceQueryResult,
NamespaceRetrievalContext, RetrievalScoreBreakdown, UnifiedMemory,
};
pub use sync_status::{
all_memory_sync_status_controller_schemas, all_memory_sync_status_registered_controllers,
+22 -6
View File
@@ -31,11 +31,21 @@ pub type MemoryClientRef = Arc<MemoryClient>;
/// be initialized.
pub struct MemoryState(pub std::sync::Mutex<Option<MemoryClientRef>>);
/// Local-only memory client backed by SQLite in the user's workspace directory.
/// SQLite-backed memory client rooted at the user's workspace directory.
///
/// All memory storage and retrieval happens on-device; there is no remote sync.
/// Remote/cloud memory sync is a future consideration — until then the memory
/// subsystem operates entirely locally via [`UnifiedMemory`].
/// Storage (documents, vectors, graph) remains on-device via [`UnifiedMemory`].
/// Embedding generation is delegated to whichever provider the
/// [`MemoryConfig.embedding_provider`](crate::openhuman::config::MemoryConfig)
/// resolves to — cloud (OpenHuman backend, the default returned by
/// [`crate::openhuman::embeddings::default_embedding_provider`]) or local Ollama
/// when explicitly opted into. The cloud embedder resolves its session JWT
/// lazily, so an unauthenticated session will surface as a clear error on the
/// first `embed` call rather than at client construction.
///
/// Callers that need a non-default embedder should construct the underlying
/// store via [`crate::openhuman::memory::create_memory_with_storage_and_routes`]
/// (or [`crate::openhuman::memory::create_memory_with_local_ai`]) with the
/// appropriate `MemoryConfig.embedding_provider`.
#[derive(Clone)]
pub struct MemoryClient {
/// The underlying memory implementation.
@@ -94,8 +104,14 @@ impl MemoryClient {
std::fs::create_dir_all(&workspace_dir)
.map_err(|e| format!("Create workspace dir {}: {e}", workspace_dir.display()))?;
// Initialize the default local embedding provider (Ollama).
let embedder: Arc<dyn EmbeddingProvider> = embeddings::default_local_embedding_provider();
// Default to cloud embeddings (OpenHuman backend, Voyage-backed). The
// cloud embedder is lazy: JWT + API URL are resolved per call, so an
// unauthenticated session produces a clear error on first embed rather
// than blocking client construction. Callers that need the local
// Ollama path should build their memory store via
// `create_memory_with_storage_and_routes` with the appropriate
// `MemoryConfig.embedding_provider`.
let embedder: Arc<dyn EmbeddingProvider> = embeddings::default_embedding_provider();
// Create the underlying UnifiedMemory instance.
let memory =
+107 -11
View File
@@ -11,11 +11,49 @@
use std::path::Path;
use std::sync::Arc;
use crate::openhuman::config::{EmbeddingRouteConfig, MemoryConfig, StorageProviderConfig};
use crate::openhuman::embeddings::{self, EmbeddingProvider};
use crate::openhuman::config::{
EmbeddingRouteConfig, LocalAiConfig, MemoryConfig, StorageProviderConfig,
};
use crate::openhuman::embeddings::{
self, EmbeddingProvider, DEFAULT_OLLAMA_DIMENSIONS, DEFAULT_OLLAMA_MODEL,
};
use crate::openhuman::memory::store::unified::UnifiedMemory;
use crate::openhuman::memory::traits::Memory;
/// Returns the effective `(provider, model, dimensions)` triple for the
/// embedding backend.
///
/// The user-facing default is `"cloud"` (OpenHuman backend, Voyage-backed) so
/// fresh installs work without a local Ollama daemon. When the user has
/// explicitly opted into local AI for embeddings —
/// [`LocalAiConfig::use_local_for_embeddings`] — we route through the local
/// Ollama embedder regardless of what `memory.embedding_provider` says, since
/// that toggle is a stronger statement of intent than the per-section default.
pub fn effective_embedding_settings(
memory: &MemoryConfig,
local_ai: Option<&LocalAiConfig>,
) -> (String, String, usize) {
if local_ai
.map(LocalAiConfig::use_local_for_embeddings)
.unwrap_or(false)
{
// Trim once and reuse — the emptiness check and the final model
// string must agree, otherwise a value like " bge-m3 " would pass
// through to Ollama with surrounding whitespace and 404.
let model = local_ai
.map(|c| c.embedding_model_id.trim())
.filter(|m| !m.is_empty())
.unwrap_or(DEFAULT_OLLAMA_MODEL)
.to_string();
return ("ollama".to_string(), model, DEFAULT_OLLAMA_DIMENSIONS);
}
(
memory.embedding_provider.clone(),
memory.embedding_model.clone(),
memory.embedding_dimensions,
)
}
/// Returns the effective name of the memory backend being used.
///
/// Currently, this always returns "namespace" as the unified memory system
@@ -41,27 +79,85 @@ pub fn create_memory_with_storage(
storage_provider: Option<&StorageProviderConfig>,
workspace_dir: &Path,
) -> anyhow::Result<Box<dyn Memory>> {
create_memory_with_storage_and_routes(config, &[], storage_provider, workspace_dir)
create_memory_full(config, &[], storage_provider, None, workspace_dir)
}
/// Create a memory instance honoring both the `memory` and `local_ai` sections.
///
/// Used by top-level entry points (agent harness, channels runtime) that have
/// the full `Config` in scope and want the local-AI opt-in to flip the
/// embedder to Ollama.
pub fn create_memory_with_local_ai(
memory: &MemoryConfig,
local_ai: &LocalAiConfig,
embedding_routes: &[EmbeddingRouteConfig],
storage_provider: Option<&StorageProviderConfig>,
workspace_dir: &Path,
) -> anyhow::Result<Box<dyn Memory>> {
create_memory_full(
memory,
embedding_routes,
storage_provider,
Some(local_ai),
workspace_dir,
)
}
/// Back-compat wrapper preserved for existing call sites that don't have a
/// `LocalAiConfig` to pass. The local-AI opt-in is not honored on this path —
/// use [`create_memory_with_local_ai`] when both sections are available.
pub fn create_memory_with_storage_and_routes(
config: &MemoryConfig,
embedding_routes: &[EmbeddingRouteConfig],
storage_provider: Option<&StorageProviderConfig>,
workspace_dir: &Path,
) -> anyhow::Result<Box<dyn Memory>> {
create_memory_full(
config,
embedding_routes,
storage_provider,
None,
workspace_dir,
)
}
/// The most comprehensive factory function for creating a memory instance.
///
/// This function initializes the embedding provider and then creates a
/// `UnifiedMemory` instance.
pub fn create_memory_with_storage_and_routes(
fn create_memory_full(
config: &MemoryConfig,
_embedding_routes: &[EmbeddingRouteConfig],
_storage_provider: Option<&StorageProviderConfig>,
local_ai: Option<&LocalAiConfig>,
workspace_dir: &Path,
) -> anyhow::Result<Box<dyn Memory>> {
// 1. Create the embedding provider based on config (Local vs Remote).
let embedder: Arc<dyn EmbeddingProvider> = Arc::from(embeddings::create_embedding_provider(
&config.embedding_provider,
&config.embedding_model,
config.embedding_dimensions,
)?);
// 1. Resolve the effective (provider, model, dims) — local-AI opt-in
// overrides the per-section default when both are present.
let (provider, model, dims) = effective_embedding_settings(config, local_ai);
log::debug!(
"[memory::factory] effective embedding settings: provider={} model={} dims={} (local_ai_opt_in={})",
provider,
model,
dims,
local_ai
.map(LocalAiConfig::use_local_for_embeddings)
.unwrap_or(false),
);
// 2. Instantiate UnifiedMemory which handles SQLite and vector storage.
// 2. Create the embedding provider.
let embedder: Arc<dyn EmbeddingProvider> = Arc::from(
embeddings::create_embedding_provider(&provider, &model, dims).inspect_err(|err| {
log::warn!(
"[memory::factory] create_embedding_provider failed provider={} model={} dims={}: {err}",
provider,
model,
dims,
);
})?,
);
// 3. Instantiate UnifiedMemory which handles SQLite and vector storage.
let mem = UnifiedMemory::new(workspace_dir, embedder, config.sqlite_open_timeout_secs)?;
Ok(Box::new(mem))
}
+3 -2
View File
@@ -25,8 +25,9 @@ mod memory_trait;
pub use client::{MemoryClient, MemoryClientRef, MemoryState};
pub use factories::{
create_memory, create_memory_for_migration, create_memory_with_storage,
create_memory_with_storage_and_routes, effective_memory_backend_name,
create_memory, create_memory_for_migration, create_memory_with_local_ai,
create_memory_with_storage, create_memory_with_storage_and_routes,
effective_embedding_settings, effective_memory_backend_name,
};
pub use types::{
GraphRelationRecord, MemoryItemKind, MemoryKvRecord, NamespaceDocumentInput,