import { QdrantClient } from '@qdrant/js-client-rest'; import redis from './redis'; const isDockerContainer = process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app'); const qdrantUrl = process.env.QDRANT_URL || (isDockerContainer ? 'http://klz-qdrant:6333' : 'http://localhost:6333'); const qdrantApiKey = process.env.QDRANT_API_KEY || ''; export const qdrant = new QdrantClient({ url: qdrantUrl, apiKey: qdrantApiKey || undefined, // Disable qdrant client's own version check to avoid the warning spam checkCompatibility: false, }); export const COLLECTION_NAME = 'klz_products'; export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small // Cache TTLs const EMBEDDING_CACHE_TTL = 60 * 60 * 24; // 24h — embeddings are deterministic const SEARCH_CACHE_TTL = 60 * 30; // 30 min — product data could change // Track collection existence in-memory (don't re-check every request) let collectionVerified = false; /** * Ensure the collection exists in Qdrant (only checks once per process lifetime). */ export async function ensureCollection() { if (collectionVerified) return; try { const collections = await qdrant.getCollections(); const exists = collections.collections.some((c) => c.name === COLLECTION_NAME); if (!exists) { await qdrant.createCollection(COLLECTION_NAME, { vectors: { size: VECTOR_SIZE, distance: 'Cosine', }, }); console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`); } collectionVerified = true; } catch (error) { console.error('Error ensuring Qdrant collection:', error); } } /** * Simple hash for cache keys */ function hashKey(text: string): string { let hash = 0; for (let i = 0; i < text.length; i++) { const chr = text.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; } return hash.toString(36); } /** * Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy). * Results are cached in Redis for 24h since embeddings are deterministic. * * NOTE: We keep OpenRouter for embeddings because the Qdrant collection uses 1536-dim * vectors (OpenAI text-embedding-3-small). Switching to Mistral embed (1024-dim) would * require re-indexing the entire product catalog. * User-facing chat uses Mistral AI directly for DSGVO compliance. */ export async function generateEmbedding(text: string): Promise { const cacheKey = `emb:${hashKey(text.toLowerCase().trim())}`; // Try Redis cache first try { const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); } } catch { // Redis down — proceed without cache } const openRouterKey = process.env.OPENROUTER_API_KEY; if (!openRouterKey) { throw new Error('OPENROUTER_API_KEY is not set'); } const response = await fetch('https://openrouter.ai/api/v1/embeddings', { method: 'POST', headers: { Authorization: `Bearer ${openRouterKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://klz-cables.com', 'X-Title': 'KLZ Cables Search AI', }, body: JSON.stringify({ model: 'openai/text-embedding-3-small', input: text, }), }); if (!response.ok) { const errorBody = await response.text(); throw new Error( `Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`, ); } const data = await response.json(); const embedding = data.data[0].embedding; // Cache the embedding in Redis try { await redis.set(cacheKey, JSON.stringify(embedding), 'EX', EMBEDDING_CACHE_TTL); } catch { // Redis down — proceed without caching } return embedding; } /** * Upsert a product into Qdrant */ export async function upsertProductVector( id: string | number, text: string, payload: Record, ) { try { await ensureCollection(); const vector = await generateEmbedding(text); await qdrant.upsert(COLLECTION_NAME, { wait: true, points: [ { id: id, vector, payload, }, ], }); } catch (error) { console.error('Error writing to Qdrant:', error); } } /** * Delete a product from Qdrant */ export async function deleteProductVector(id: string | number) { try { await ensureCollection(); await qdrant.delete(COLLECTION_NAME, { wait: true, points: [id] as [string | number], }); } catch (error) { console.error('Error deleting from Qdrant:', error); } } /** * Search products in Qdrant. * Results are cached in Redis for 30 minutes keyed by query text. */ export async function searchProducts(query: string, limit = 5) { const cacheKey = `search:${hashKey(query.toLowerCase().trim())}:${limit}`; // Try Redis cache first try { const cached = await redis.get(cacheKey); if (cached) { console.log(`[Qdrant] Cache HIT for query: "${query.substring(0, 50)}"`); return JSON.parse(cached); } } catch { // Redis down — proceed without cache } try { await ensureCollection(); const vector = await generateEmbedding(query); const results = await qdrant.search(COLLECTION_NAME, { vector, limit, with_payload: true, }); // Cache results in Redis try { await redis.set(cacheKey, JSON.stringify(results), 'EX', SEARCH_CACHE_TTL); } catch { // Redis down — proceed without caching } return results; } catch (error) { console.error('Error searching in Qdrant:', error); return []; } }