feat(ai-search): optimize dev server, add qdrant boot sync, fix orb overflow
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 1m0s
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🏗️ Build (push) Has been cancelled

This commit is contained in:
2026-03-06 22:35:48 +01:00
parent 81ce3a4588
commit 4dcdb717f0
16 changed files with 1981 additions and 380 deletions

View File

@@ -1,4 +1,5 @@
import { QdrantClient } from '@qdrant/js-client-rest';
import redis from './redis';
const isDockerContainer =
process.env.IS_DOCKER === 'true' || process.env.HOSTNAME?.includes('klz-app');
@@ -10,15 +11,26 @@ 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.
* 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);
@@ -31,15 +43,47 @@ export async function ensureCollection() {
});
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
}
collectionVerified = true;
} catch (error) {
console.error('Error ensuring Qdrant collection:', error);
}
}
/**
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
* 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<number[]> {
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');
@@ -67,7 +111,16 @@ export async function generateEmbedding(text: string): Promise<number[]> {
}
const data = await response.json();
return data.data[0].embedding;
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;
}
/**
@@ -113,9 +166,23 @@ export async function deleteProductVector(id: string | number) {
}
/**
* Search products in Qdrant
* 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);
@@ -126,6 +193,13 @@ export async function searchProducts(query: string, limit = 5) {
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);