UNPKG

@claude-vector/core

Version:

Core vector search engine for code intelligence

255 lines (209 loc) 6.9 kB
/** * Core Vector Search Engine */ import { OpenAI } from 'openai'; import fs from 'fs/promises'; import path from 'path'; import { SimpleCache } from './cache.js'; export class VectorSearchEngine { constructor(config = {}) { this.config = { openaiApiKey: config.openaiApiKey || process.env.OPENAI_API_KEY, embeddingModel: config.embeddingModel || 'text-embedding-3-small', searchThreshold: config.searchThreshold || 0.4, maxResults: config.maxResults || 10, cacheEnabled: config.cacheEnabled ?? true, cacheTTL: config.cacheTTL || 3600, ...config }; if (!this.config.openaiApiKey) { throw new Error('OpenAI API key is required. Set OPENAI_API_KEY environment variable or pass it in config.'); } this.openai = new OpenAI({ apiKey: this.config.openaiApiKey }); this.embeddings = null; this.chunks = null; this.index = null; if (this.config.cacheEnabled) { const cacheDir = this.config.cacheDir || path.join(process.cwd(), '.claude-vector-cache'); this.cache = new SimpleCache(path.join(cacheDir, 'queries'), this.config.cacheTTL); } } /** * Load pre-computed embeddings and chunks */ async loadIndex(embeddingsPath, chunksPath) { const startTime = Date.now(); try { // Load embeddings const embeddingsData = await fs.readFile(embeddingsPath, 'utf-8'); this.embeddings = JSON.parse(embeddingsData); // Load chunks const chunksData = await fs.readFile(chunksPath, 'utf-8'); this.chunks = JSON.parse(chunksData); // Validate data if (!Array.isArray(this.embeddings) || !Array.isArray(this.chunks)) { throw new Error('Invalid index format'); } if (this.embeddings.length !== this.chunks.length) { throw new Error('Embeddings and chunks count mismatch'); } const loadTime = Date.now() - startTime; console.log(`✓ Loaded ${this.embeddings.length} embeddings in ${loadTime}ms`); return { embeddingsCount: this.embeddings.length, loadTime }; } catch (error) { throw new Error(`Failed to load index: ${error.message}`); } } /** * Generate embedding for a query */ async generateQueryEmbedding(query) { try { const response = await this.openai.embeddings.create({ model: this.config.embeddingModel, input: query }); return response.data[0].embedding; } catch (error) { throw new Error(`Failed to generate embedding: ${error.message}`); } } /** * Calculate cosine similarity between two vectors */ cosineSimilarity(a, b) { if (a.length !== b.length) { throw new Error('Vectors must have the same length'); } let dotProduct = 0; let normA = 0; let normB = 0; for (let i = 0; i < a.length; i++) { dotProduct += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } normA = Math.sqrt(normA); normB = Math.sqrt(normB); if (normA === 0 || normB === 0) { return 0; } return dotProduct / (normA * normB); } /** * Search for similar chunks */ async search(query, options = {}) { if (!this.embeddings || !this.chunks) { throw new Error('Index not loaded. Call loadIndex() first.'); } const config = { ...this.config, ...options }; // Check cache if enabled if (this.cache && !options.noCache) { const cacheKey = `search:${query}:${JSON.stringify(options)}`; const cached = await this.cache.get(cacheKey); if (cached) { return cached; } } const startTime = Date.now(); // Generate query embedding const queryEmbedding = await this.generateQueryEmbedding(query); // Calculate similarities const results = []; for (let i = 0; i < this.embeddings.length; i++) { const similarity = this.cosineSimilarity(queryEmbedding, this.embeddings[i]); if (similarity >= config.searchThreshold) { results.push({ chunk: this.chunks[i], score: similarity, index: i }); } } // Sort by score results.sort((a, b) => b.score - a.score); // Limit results const limitedResults = results.slice(0, config.maxResults); const searchTime = Date.now() - startTime; const response = { query, results: limitedResults, totalMatches: results.length, searchTime, config: { threshold: config.searchThreshold, maxResults: config.maxResults } }; // Cache results if enabled if (this.cache && !options.noCache) { const cacheKey = `search:${query}:${JSON.stringify(options)}`; await this.cache.set(cacheKey, response); } return response; } /** * Find related chunks by similarity to a given chunk */ async findRelated(chunkIndex, options = {}) { if (!this.embeddings || !this.chunks) { throw new Error('Index not loaded. Call loadIndex() first.'); } if (chunkIndex < 0 || chunkIndex >= this.embeddings.length) { throw new Error('Invalid chunk index'); } const config = { ...this.config, ...options }; const embedding = this.embeddings[chunkIndex]; const results = []; for (let i = 0; i < this.embeddings.length; i++) { if (i === chunkIndex) continue; // Skip self const similarity = this.cosineSimilarity(embedding, this.embeddings[i]); if (similarity >= config.searchThreshold) { results.push({ chunk: this.chunks[i], score: similarity, index: i }); } } results.sort((a, b) => b.score - a.score); return { sourceChunk: this.chunks[chunkIndex], relatedChunks: results.slice(0, config.maxResults), totalMatches: results.length }; } /** * Get index statistics */ getStats() { if (!this.embeddings || !this.chunks) { return { loaded: false }; } const totalTokens = this.chunks.reduce((sum, chunk) => sum + (chunk.tokens || 0), 0); const avgTokensPerChunk = totalTokens / this.chunks.length; return { loaded: true, totalChunks: this.chunks.length, totalTokens, avgTokensPerChunk: Math.round(avgTokensPerChunk), embeddingDimensions: this.embeddings[0]?.length || 0, indexSizeEstimate: { embeddings: `${(JSON.stringify(this.embeddings).length / 1024 / 1024).toFixed(2)} MB`, chunks: `${(JSON.stringify(this.chunks).length / 1024 / 1024).toFixed(2)} MB` } }; } /** * Clear loaded index from memory */ clearIndex() { this.embeddings = null; this.chunks = null; this.index = null; } }