From d8c52733ec28fdfd8d53a2e21356f3a136a16558 Mon Sep 17 00:00:00 2001 From: Artur Do Lago Date: Sun, 11 Jan 2026 01:45:08 +0100 Subject: [PATCH] feat(memory): upgrade to Qwen3-Embedding-8B via Nebius API - Add VoyageEmbeddingProvider for Voyage AI embeddings - Add MemoryStore with high-level store/search/list API - Configure Nebius cloud API for Qwen3-Embedding-8B (#1 on MTEB) - Fix LocalEmbeddingProvider to support both /v1/embeddings and /embeddings - Fix QdrantVectorStorage to set collection on existing collections Model: Qwen3-Embedding-8B (70.58 MTEB score, 99% zero-shot, 4096 dims) Provider: Nebius API (https://api.tokenfactory.nebius.com/v1) Co-Authored-By: Claude Opus 4.5 --- src/memory/embedding.ts | 72 ++++++++- src/memory/index.ts | 1 + src/memory/qdrant.ts | 6 +- src/memory/store.ts | 338 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 src/memory/store.ts diff --git a/src/memory/embedding.ts b/src/memory/embedding.ts index b5086e899e..f5172bf168 100644 --- a/src/memory/embedding.ts +++ b/src/memory/embedding.ts @@ -286,7 +286,12 @@ class LocalEmbeddingProvider implements EmbeddingProvider { async embedBatch(texts: string[]): Promise { if (texts.length === 0) return []; - const response = await fetch(`${this.baseUrl}/embeddings`, { + // Support both /v1/embeddings (OpenAI-compatible) and /embeddings (TEI) + const endpoint = this.baseUrl.includes("/v1") + ? `${this.baseUrl}/embeddings` + : `${this.baseUrl}/v1/embeddings`; + + const response = await fetch(endpoint, { method: "POST", headers: { Authorization: `Bearer ${this.apiKey}`, @@ -314,6 +319,67 @@ class LocalEmbeddingProvider implements EmbeddingProvider { } } +/** + * Voyage AI embedding client + * #1 on MTEB, 200M free tokens, supports query/document input types + */ +class VoyageEmbeddingProvider implements EmbeddingProvider { + readonly id = "voyage"; + readonly model: string; + readonly dimension: number; + private readonly apiKey: string; + private readonly baseUrl: string; + + constructor(config: EmbeddingConfig) { + this.apiKey = config.apiKey ?? process.env.VOYAGE_API_KEY ?? ""; + this.model = config.model ?? "voyage-3-large"; + this.dimension = config.dimensions ?? 1024; + this.baseUrl = config.baseUrl ?? "https://api.voyageai.com/v1"; + + if (!this.apiKey) { + throw new Error( + "Voyage API key required: set embedding.apiKey or VOYAGE_API_KEY env" + ); + } + } + + async embed(text: string): Promise { + const result = await this.embedBatch([text]); + return result[0] ?? []; + } + + async embedBatch(texts: string[]): Promise { + if (texts.length === 0) return []; + + const response = await fetch(`${this.baseUrl}/embeddings`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.model, + input: texts, + output_dimension: this.dimension, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Voyage embedding failed (${response.status}): ${errorText}` + ); + } + + const data = (await response.json()) as { + data: Array<{ embedding: number[]; index: number }>; + }; + + const sorted = data.data.sort((a, b) => a.index - b.index); + return sorted.map((item) => item.embedding); + } +} + // ============================================================================= // Caching Wrapper // ============================================================================= @@ -404,6 +470,9 @@ export function createEmbeddingProvider( case "openai": provider = new OpenAIEmbeddingProvider(config); break; + case "voyage": + provider = new VoyageEmbeddingProvider(config); + break; case "vllm": provider = new VLLMEmbeddingProvider(config); break; @@ -431,6 +500,7 @@ export function createEmbeddingProvider( export { EmbeddingCache, OpenAIEmbeddingProvider, + VoyageEmbeddingProvider, VLLMEmbeddingProvider, OllamaEmbeddingProvider, LocalEmbeddingProvider, diff --git a/src/memory/index.ts b/src/memory/index.ts index 0f40a13f0f..a25ee0f3e5 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -8,3 +8,4 @@ export * from "./types"; export * from "./embedding"; export * from "./qdrant"; +export * from "./store"; diff --git a/src/memory/qdrant.ts b/src/memory/qdrant.ts index 5688f42107..ff5d72b060 100644 --- a/src/memory/qdrant.ts +++ b/src/memory/qdrant.ts @@ -117,7 +117,11 @@ export class QdrantVectorStorage implements VectorStorage { async createCollection(name: string, dimension: number): Promise { const exists = await this.collectionExists(name); - if (exists) return; + if (exists) { + // Still set currentCollection even if collection already exists + this.currentCollection = name; + return; + } await this.request("PUT", `/collections/${name}`, { vectors: { diff --git a/src/memory/store.ts b/src/memory/store.ts new file mode 100644 index 0000000000..6fd51ead0d --- /dev/null +++ b/src/memory/store.ts @@ -0,0 +1,338 @@ +/** + * MemoryStore - High-level memory service for Zee + * + * Combines Qdrant vector storage with embedding generation + * to provide a simple store/search API for the memory tools. + */ + +import { randomUUID } from "node:crypto"; +import { QdrantVectorStorage } from "./qdrant"; +import { createEmbeddingProvider, type EmbeddingConfig } from "./embedding"; +import type { + MemoryEntry, + MemoryInput, + MemorySearchParams, + MemorySearchResult, + MemoryCategory, + EmbeddingProvider, +} from "./types"; + +// ============================================================================= +// Configuration +// ============================================================================= + +export interface MemoryStoreConfig { + qdrant: { + url?: string; + apiKey?: string; + collection?: string; + }; + embedding: EmbeddingConfig; + namespace?: string; +} + +const DEFAULT_CONFIG: MemoryStoreConfig = { + qdrant: { + url: process.env.QDRANT_URL ?? "http://localhost:6333", + collection: process.env.QDRANT_COLLECTION ?? "zee_memories", + }, + embedding: { + // Use Qwen3-Embedding-8B via Nebius - #1 honest model on MTEB (70.58, 99% zero-shot) + provider: (process.env.EMBEDDING_PROVIDER as any) ?? "local", + model: process.env.EMBEDDING_MODEL ?? "Qwen/Qwen3-Embedding-8B", + dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS ?? "4096", 10), + baseUrl: process.env.EMBEDDING_URL ?? "https://api.tokenfactory.nebius.com/v1", + apiKey: process.env.NEBIUS_API_KEY, + }, + namespace: "zee", +}; + +// ============================================================================= +// MemoryStore Class +// ============================================================================= + +export class MemoryStore { + private readonly storage: QdrantVectorStorage; + private readonly embedding: EmbeddingProvider; + private readonly namespace: string; + private readonly dimension: number; + private initialized = false; + + constructor(config: Partial = {}) { + const merged = { + ...DEFAULT_CONFIG, + ...config, + qdrant: { ...DEFAULT_CONFIG.qdrant, ...config.qdrant }, + embedding: { ...DEFAULT_CONFIG.embedding, ...config.embedding }, + }; + + this.storage = new QdrantVectorStorage(merged.qdrant); + this.embedding = createEmbeddingProvider(merged.embedding); + this.namespace = merged.namespace ?? "zee"; + this.dimension = merged.embedding.dimensions ?? 1536; + } + + /** Initialize the store (create collection if needed) */ + async init(): Promise { + if (this.initialized) return; + await this.storage.createCollection( + `${this.namespace}_memories`, + this.dimension + ); + this.initialized = true; + } + + /** + * Store a memory + */ + async save(input: MemoryInput): Promise { + await this.init(); + + const id = randomUUID(); + const now = Date.now(); + + // Generate embedding + const vector = await this.embedding.embed(input.content); + + const entry: MemoryEntry = { + id, + category: input.category, + content: input.content, + summary: input.summary, + embedding: vector, + metadata: input.metadata ?? {}, + createdAt: now, + accessedAt: now, + ttl: input.ttl, + namespace: input.namespace ?? this.namespace, + }; + + // Store in Qdrant + await this.storage.insert([ + { + id, + vector, + payload: { + category: entry.category, + content: entry.content, + summary: entry.summary, + metadata: entry.metadata, + createdAt: entry.createdAt, + accessedAt: entry.accessedAt, + ttl: entry.ttl, + namespace: entry.namespace, + }, + }, + ]); + + return entry; + } + + /** + * Search memories semantically + */ + async search(params: MemorySearchParams): Promise { + await this.init(); + + // Generate query embedding + const queryVector = await this.embedding.embed(params.query); + + // Build filter + const filter: Record = {}; + if (params.namespace) { + filter.namespace = params.namespace; + } else { + filter.namespace = this.namespace; + } + if (params.category) { + if (Array.isArray(params.category)) { + filter.category = { $in: params.category }; + } else { + filter.category = params.category; + } + } + if (params.tags?.length) { + filter["metadata.tags"] = { $in: params.tags }; + } + if (params.timeRange) { + if (params.timeRange.start) { + filter.createdAt = { ...(filter.createdAt as object ?? {}), $gte: params.timeRange.start }; + } + if (params.timeRange.end) { + filter.createdAt = { ...(filter.createdAt as object ?? {}), $lte: params.timeRange.end }; + } + } + + // Search in Qdrant + const results = await this.storage.search(queryVector, { + limit: params.limit ?? 10, + threshold: params.threshold ?? 0.5, + filter: Object.keys(filter).length > 0 ? filter : undefined, + }); + + // Map results to MemorySearchResult + return results.map((r) => ({ + entry: { + id: r.id, + category: r.payload.category as MemoryCategory, + content: r.payload.content as string, + summary: r.payload.summary as string | undefined, + metadata: r.payload.metadata as MemoryEntry["metadata"], + createdAt: r.payload.createdAt as number, + accessedAt: r.payload.accessedAt as number, + ttl: r.payload.ttl as number | undefined, + namespace: r.payload.namespace as string | undefined, + }, + score: r.score, + })); + } + + /** + * Get a specific memory by ID + */ + async get(id: string): Promise { + await this.init(); + + const results = await this.storage.get([id]); + const point = results[0]; + if (!point) return null; + + return { + id: point.id, + category: point.payload.category as MemoryCategory, + content: point.payload.content as string, + summary: point.payload.summary as string | undefined, + embedding: point.vector, + metadata: point.payload.metadata as MemoryEntry["metadata"], + createdAt: point.payload.createdAt as number, + accessedAt: point.payload.accessedAt as number, + ttl: point.payload.ttl as number | undefined, + namespace: point.payload.namespace as string | undefined, + }; + } + + /** + * List memories with optional filters + */ + async list(options: { + category?: MemoryCategory; + namespace?: string; + limit?: number; + offset?: number; + } = {}): Promise { + await this.init(); + + // Build filter + const filter: Record = { + namespace: options.namespace ?? this.namespace, + }; + if (options.category) { + filter.category = options.category; + } + + // Use scroll to list + const count = await this.storage.count(filter); + if (count === 0) return []; + + // Search with a dummy vector to get all matching entries + // This is a workaround - ideally we'd have a scroll/list method + const dummyVector = new Array(this.dimension).fill(0); + const results = await this.storage.search(dummyVector, { + limit: options.limit ?? 100, + filter, + }); + + return results.map((r) => ({ + id: r.id, + category: r.payload.category as MemoryCategory, + content: r.payload.content as string, + summary: r.payload.summary as string | undefined, + metadata: r.payload.metadata as MemoryEntry["metadata"], + createdAt: r.payload.createdAt as number, + accessedAt: r.payload.accessedAt as number, + ttl: r.payload.ttl as number | undefined, + namespace: r.payload.namespace as string | undefined, + })); + } + + /** + * Delete a memory by ID + */ + async delete(id: string): Promise { + await this.init(); + await this.storage.delete([id]); + } + + /** + * Delete memories matching a filter + */ + async deleteWhere(filter: { + category?: MemoryCategory; + namespace?: string; + olderThan?: number; + }): Promise { + await this.init(); + + const qdrantFilter: Record = {}; + if (filter.category) qdrantFilter.category = filter.category; + if (filter.namespace) qdrantFilter.namespace = filter.namespace; + if (filter.olderThan) qdrantFilter.createdAt = { $lt: filter.olderThan }; + + return this.storage.deleteWhere(qdrantFilter); + } + + /** + * Get statistics about the memory store + */ + async stats(): Promise<{ + total: number; + byCategory: Record; + }> { + await this.init(); + + const total = await this.storage.count({ namespace: this.namespace }); + const categories: MemoryCategory[] = [ + "conversation", + "fact", + "preference", + "task", + "decision", + "relationship", + "note", + "pattern", + ]; + + const byCategory: Record = {}; + for (const cat of categories) { + byCategory[cat] = await this.storage.count({ + namespace: this.namespace, + category: cat, + }); + } + + return { total, byCategory }; + } +} + +// ============================================================================= +// Singleton Instance +// ============================================================================= + +let _instance: MemoryStore | null = null; + +/** + * Get the shared MemoryStore instance + */ +export function getMemoryStore(config?: Partial): MemoryStore { + if (!_instance) { + _instance = new MemoryStore(config); + } + return _instance; +} + +/** + * Reset the shared instance (for testing) + */ +export function resetMemoryStore(): void { + _instance = null; +}