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
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user