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
209 lines
5.5 KiB
TypeScript
209 lines
5.5 KiB
TypeScript
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<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');
|
|
}
|
|
|
|
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<string, any>,
|
|
) {
|
|
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 [];
|
|
}
|
|
}
|