mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 10:45:33 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
+71
-1
@@ -286,7 +286,12 @@ class LocalEmbeddingProvider implements EmbeddingProvider {
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
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<number[]> {
|
||||
const result = await this.embedBatch([text]);
|
||||
return result[0] ?? [];
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
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,
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
export * from "./types";
|
||||
export * from "./embedding";
|
||||
export * from "./qdrant";
|
||||
export * from "./store";
|
||||
|
||||
@@ -117,7 +117,11 @@ export class QdrantVectorStorage implements VectorStorage {
|
||||
|
||||
async createCollection(name: string, dimension: number): Promise<void> {
|
||||
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: {
|
||||
|
||||
@@ -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<MemoryStoreConfig> = {}) {
|
||||
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<void> {
|
||||
if (this.initialized) return;
|
||||
await this.storage.createCollection(
|
||||
`${this.namespace}_memories`,
|
||||
this.dimension
|
||||
);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a memory
|
||||
*/
|
||||
async save(input: MemoryInput): Promise<MemoryEntry> {
|
||||
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<MemorySearchResult[]> {
|
||||
await this.init();
|
||||
|
||||
// Generate query embedding
|
||||
const queryVector = await this.embedding.embed(params.query);
|
||||
|
||||
// Build filter
|
||||
const filter: Record<string, unknown> = {};
|
||||
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<MemoryEntry | null> {
|
||||
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<MemoryEntry[]> {
|
||||
await this.init();
|
||||
|
||||
// Build filter
|
||||
const filter: Record<string, unknown> = {
|
||||
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<void> {
|
||||
await this.init();
|
||||
await this.storage.delete([id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete memories matching a filter
|
||||
*/
|
||||
async deleteWhere(filter: {
|
||||
category?: MemoryCategory;
|
||||
namespace?: string;
|
||||
olderThan?: number;
|
||||
}): Promise<number> {
|
||||
await this.init();
|
||||
|
||||
const qdrantFilter: Record<string, unknown> = {};
|
||||
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<string, number>;
|
||||
}> {
|
||||
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<string, number> = {};
|
||||
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<MemoryStoreConfig>): MemoryStore {
|
||||
if (!_instance) {
|
||||
_instance = new MemoryStore(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the shared instance (for testing)
|
||||
*/
|
||||
export function resetMemoryStore(): void {
|
||||
_instance = null;
|
||||
}
|
||||
Reference in New Issue
Block a user