From 00317343e752ebb6516b9a8fd17f75d54bb5ac7f Mon Sep 17 00:00:00 2001 From: nico-hoff Date: Wed, 25 Feb 2026 10:08:56 +0100 Subject: [PATCH] feat(memory): add Ollama embedding provider - Add Ollama as embedding provider for memory search (provider/fallback) - Keep main state (Mistral) and support both in types, schema, runtime - Add embeddings-ollama.ts and tests Co-authored-by: Cursor --- src/agents/memory-search.ts | 4 +- src/config/schema.help.ts | 4 +- src/config/types.tools.ts | 4 +- src/config/zod-schema.agent-runtime.ts | 2 + src/memory/embeddings-ollama.test.ts | 31 +++++++++++ src/memory/embeddings-ollama.ts | 72 ++++++++++++++++++++++++++ src/memory/embeddings.ts | 11 +++- src/memory/manager-sync-ops.ts | 18 +++++-- src/memory/manager.ts | 24 ++++++--- 9 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 src/memory/embeddings-ollama.test.ts create mode 100644 src/memory/embeddings-ollama.ts diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index a8aadc15b2c..53fce45de84 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -9,7 +9,7 @@ export type ResolvedMemorySearchConfig = { enabled: boolean; sources: Array<"memory" | "sessions">; extraPaths: string[]; - provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto"; + provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto"; remote?: { baseUrl?: string; apiKey?: string; @@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = { experimental: { sessionMemory: boolean; }; - fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none"; + fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; model: string; local: { modelPath?: string; diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 3e4d558f349..f4f0023f7fd 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -724,7 +724,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.experimental.sessionMemory": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "agents.defaults.memorySearch.provider": - 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", or "local". Keep your most reliable provider here and configure fallback for resilience.', + 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', "agents.defaults.memorySearch.model": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", "agents.defaults.memorySearch.remote.baseUrl": @@ -746,7 +746,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.local.modelPath": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", "agents.defaults.memorySearch.fallback": - 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', + 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', "agents.defaults.memorySearch.store.path": "Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.", "agents.defaults.memorySearch.store.vector.enabled": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index 9d45a9d7fa4..67d65c1ba0e 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -324,7 +324,7 @@ export type MemorySearchConfig = { sessionMemory?: boolean; }; /** Embedding provider mode. */ - provider?: "openai" | "gemini" | "local" | "voyage" | "mistral"; + provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama"; remote?: { baseUrl?: string; apiKey?: string; @@ -343,7 +343,7 @@ export type MemorySearchConfig = { }; }; /** Fallback behavior when embeddings fail. */ - fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "none"; + fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; /** Embedding model id (remote) or alias (local). */ model?: string; /** Local embedding settings (node-llama-cpp). */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 3f1ddb61e71..d780dfea8f9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -557,6 +557,7 @@ export const MemorySearchSchema = z z.literal("gemini"), z.literal("voyage"), z.literal("mistral"), + z.literal("ollama"), ]) .optional(), remote: z @@ -584,6 +585,7 @@ export const MemorySearchSchema = z z.literal("local"), z.literal("voyage"), z.literal("mistral"), + z.literal("ollama"), z.literal("none"), ]) .optional(), diff --git a/src/memory/embeddings-ollama.test.ts b/src/memory/embeddings-ollama.test.ts new file mode 100644 index 00000000000..83bc249eeea --- /dev/null +++ b/src/memory/embeddings-ollama.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createOllamaEmbeddingProvider } from "./embeddings-ollama.js"; + +describe("embeddings-ollama", () => { + it("calls /api/embeddings and returns normalized vectors", async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ embedding: [3, 4] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + // @ts-expect-error test override + globalThis.fetch = fetchMock; + + const { provider } = await createOllamaEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "ollama", + model: "nomic-embed-text", + fallback: "none", + remote: { baseUrl: "http://127.0.0.1:11434" }, + }); + + const v = await provider.embedQuery("hi"); + expect(fetchMock).toHaveBeenCalledTimes(1); + // normalized [3,4] => [0.6,0.8] + expect(v[0]).toBeCloseTo(0.6, 5); + expect(v[1]).toBeCloseTo(0.8, 5); + }); +}); diff --git a/src/memory/embeddings-ollama.ts b/src/memory/embeddings-ollama.ts new file mode 100644 index 00000000000..d755f840d10 --- /dev/null +++ b/src/memory/embeddings-ollama.ts @@ -0,0 +1,72 @@ +import { formatErrorMessage } from "../infra/errors.js"; +import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; + +export type OllamaEmbeddingClient = { + embedBatch: (texts: string[]) => Promise; +}; + +function sanitizeAndNormalizeEmbedding(vec: number[]): number[] { + const sanitized = vec.map((value) => (Number.isFinite(value) ? value : 0)); + const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0)); + if (magnitude < 1e-10) { + return sanitized; + } + return sanitized.map((value) => value / magnitude); +} + +export async function createOllamaEmbeddingProvider( + options: EmbeddingProviderOptions, +): Promise<{ provider: EmbeddingProvider; client: OllamaEmbeddingClient }> { + const baseUrl = options.remote?.baseUrl?.trim() || "http://127.0.0.1:11434"; + const model = options.model || "nomic-embed-text"; + + const headers: Record = { + "content-type": "application/json", + ...options.remote?.headers, + }; + + // Ollama doesn't require an API key by default. If users set one (proxy), allow it. + const apiKey = options.remote?.apiKey; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + + const embedOne = async (text: string): Promise => { + const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/embeddings`, { + method: "POST", + headers, + body: JSON.stringify({ model, prompt: text }), + }); + if (!res.ok) { + throw new Error(`Ollama embeddings HTTP ${res.status}: ${await res.text()}`); + } + const json = (await res.json()) as { embedding?: number[] }; + if (!Array.isArray(json.embedding)) { + throw new Error(`Ollama embeddings response missing embedding[]`); + } + return sanitizeAndNormalizeEmbedding(json.embedding); + }; + + const provider: EmbeddingProvider = { + id: "ollama", + model, + embedQuery: embedOne, + embedBatch: async (texts: string[]) => { + // Ollama /api/embeddings is single-prompt; parallelize with a small fanout. + // Keep it simple and let caller batch size control overall load. + return await Promise.all(texts.map(embedOne)); + }, + }; + + const client: OllamaEmbeddingClient = { + embedBatch: async (texts) => { + try { + return await provider.embedBatch(texts); + } catch (err) { + throw new Error(formatErrorMessage(err), { cause: err }); + } + }, + }; + + return { provider, client }; +} diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts index cbca95a5d4f..34ff9519d98 100644 --- a/src/memory/embeddings.ts +++ b/src/memory/embeddings.ts @@ -8,6 +8,7 @@ import { createMistralEmbeddingProvider, type MistralEmbeddingClient, } from "./embeddings-mistral.js"; +import { createOllamaEmbeddingProvider, type OllamaEmbeddingClient } from "./embeddings-ollama.js"; import { createOpenAiEmbeddingProvider, type OpenAiEmbeddingClient } from "./embeddings-openai.js"; import { createVoyageEmbeddingProvider, type VoyageEmbeddingClient } from "./embeddings-voyage.js"; import { importNodeLlamaCpp } from "./node-llama.js"; @@ -25,6 +26,7 @@ export type { GeminiEmbeddingClient } from "./embeddings-gemini.js"; export type { MistralEmbeddingClient } from "./embeddings-mistral.js"; export type { OpenAiEmbeddingClient } from "./embeddings-openai.js"; export type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; +export type { OllamaEmbeddingClient } from "./embeddings-ollama.js"; export type EmbeddingProvider = { id: string; @@ -34,11 +36,11 @@ export type EmbeddingProvider = { embedBatch: (texts: string[]) => Promise; }; -export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral"; +export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; export type EmbeddingProviderRequest = EmbeddingProviderId | "auto"; export type EmbeddingProviderFallback = EmbeddingProviderId | "none"; -const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const; +const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral", "ollama"] as const; export type EmbeddingProviderResult = { provider: EmbeddingProvider | null; @@ -50,6 +52,7 @@ export type EmbeddingProviderResult = { gemini?: GeminiEmbeddingClient; voyage?: VoyageEmbeddingClient; mistral?: MistralEmbeddingClient; + ollama?: OllamaEmbeddingClient; }; export type EmbeddingProviderOptions = { @@ -152,6 +155,10 @@ export async function createEmbeddingProvider( const provider = await createLocalEmbeddingProvider(options); return { provider }; } + if (id === "ollama") { + const { provider, client } = await createOllamaEmbeddingProvider(options); + return { provider, ollama: client }; + } if (id === "gemini") { const { provider, client } = await createGeminiEmbeddingProvider(options); return { provider, gemini: client }; diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index ac7852eaa35..f405765075a 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -20,6 +20,7 @@ import { type EmbeddingProvider, type GeminiEmbeddingClient, type MistralEmbeddingClient, + type OllamaEmbeddingClient, type OpenAiEmbeddingClient, type VoyageEmbeddingClient, } from "./embeddings.js"; @@ -91,11 +92,12 @@ export abstract class MemoryManagerSyncOps { protected abstract readonly workspaceDir: string; protected abstract readonly settings: ResolvedMemorySearchConfig; protected provider: EmbeddingProvider | null = null; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral"; + protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; protected openAi?: OpenAiEmbeddingClient; protected gemini?: GeminiEmbeddingClient; protected voyage?: VoyageEmbeddingClient; protected mistral?: MistralEmbeddingClient; + protected ollama?: OllamaEmbeddingClient; protected abstract batch: { enabled: boolean; wait: boolean; @@ -350,7 +352,10 @@ export abstract class MemoryManagerSyncOps { this.fts.available = result.ftsAvailable; if (result.ftsError) { this.fts.loadError = result.ftsError; - log.warn(`fts unavailable: ${result.ftsError}`); + // Only warn when hybrid search is enabled; otherwise this is expected noise. + if (this.fts.enabled) { + log.warn(`fts unavailable: ${result.ftsError}`); + } } } @@ -958,7 +963,13 @@ export abstract class MemoryManagerSyncOps { if (this.fallbackFrom) { return false; } - const fallbackFrom = this.provider.id as "openai" | "gemini" | "local" | "voyage" | "mistral"; + const fallbackFrom = this.provider.id as + | "openai" + | "gemini" + | "local" + | "voyage" + | "mistral" + | "ollama"; const fallbackModel = fallback === "gemini" @@ -988,6 +999,7 @@ export abstract class MemoryManagerSyncOps { this.gemini = fallbackResult.gemini; this.voyage = fallbackResult.voyage; this.mistral = fallbackResult.mistral; + this.ollama = fallbackResult.ollama; this.providerKey = this.computeProviderKey(); this.batch = this.resolveBatchConfig(); log.warn(`memory embeddings: switched to fallback provider (${fallback})`, { reason }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 36460df87ad..1d2fb49e88b 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -13,6 +13,7 @@ import { type EmbeddingProviderResult, type GeminiEmbeddingClient, type MistralEmbeddingClient, + type OllamaEmbeddingClient, type OpenAiEmbeddingClient, type VoyageEmbeddingClient, } from "./embeddings.js"; @@ -48,14 +49,22 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem protected readonly workspaceDir: string; protected readonly settings: ResolvedMemorySearchConfig; protected provider: EmbeddingProvider | null; - private readonly requestedProvider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "auto"; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral"; + private readonly requestedProvider: + | "openai" + | "local" + | "gemini" + | "voyage" + | "mistral" + | "ollama" + | "auto"; + protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; protected fallbackReason?: string; private readonly providerUnavailableReason?: string; protected openAi?: OpenAiEmbeddingClient; protected gemini?: GeminiEmbeddingClient; protected voyage?: VoyageEmbeddingClient; protected mistral?: MistralEmbeddingClient; + protected ollama?: OllamaEmbeddingClient; protected batch: { enabled: boolean; wait: boolean; @@ -185,6 +194,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.gemini = params.providerResult.gemini; this.voyage = params.providerResult.voyage; this.mistral = params.providerResult.mistral; + this.ollama = params.providerResult.ollama; this.sources = new Set(params.settings.sources); this.db = this.openDatabase(); this.providerKey = this.computeProviderKey(); @@ -289,9 +299,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem return merged; } - const keywordResults = hybrid.enabled - ? await this.searchKeyword(cleaned, candidates).catch(() => []) - : []; + // If FTS isn't available, hybrid mode cannot use keyword search; degrade to vector-only. + const keywordResults = + hybrid.enabled && this.fts.enabled && this.fts.available + ? await this.searchKeyword(cleaned, candidates).catch(() => []) + : []; const queryVec = await this.embedQueryWithTimeout(cleaned); const hasVector = queryVec.some((v) => v !== 0); @@ -299,7 +311,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem ? await this.searchVector(queryVec, candidates).catch(() => []) : []; - if (!hybrid.enabled) { + if (!hybrid.enabled || !this.fts.enabled || !this.fts.available) { return vectorResults.filter((entry) => entry.score >= minScore).slice(0, maxResults); }