feat: ai search
This commit is contained in:
124
src/lib/qdrant.ts
Normal file
124
src/lib/qdrant.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
|
||||
const qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333';
|
||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
||||
|
||||
export const qdrant = new QdrantClient({
|
||||
url: qdrantUrl,
|
||||
apiKey: qdrantApiKey || undefined,
|
||||
});
|
||||
|
||||
export const COLLECTION_NAME = 'klz_products';
|
||||
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
|
||||
|
||||
/**
|
||||
* Ensure the collection exists in Qdrant.
|
||||
*/
|
||||
export async function ensureCollection() {
|
||||
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}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error ensuring Qdrant collection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
|
||||
*/
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
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();
|
||||
return data.data[0].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
|
||||
*/
|
||||
export async function searchProducts(query: string, limit = 5) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(query);
|
||||
|
||||
const results = await qdrant.search(COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error searching in Qdrant:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
16
src/lib/redis.ts
Normal file
16
src/lib/redis.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://klz-redis:6379';
|
||||
|
||||
// Only create a single instance in Node.js
|
||||
const globalForRedis = global as unknown as { redis: Redis };
|
||||
|
||||
export const redis = globalForRedis.redis || new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
|
||||
export default redis;
|
||||
@@ -37,6 +37,51 @@ export const Products: CollectionConfig = {
|
||||
};
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req, operation }) => {
|
||||
// Run index sync asynchronously to not block the CMS save operation
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const { upsertProductVector, deleteProductVector } = await import('../../lib/qdrant');
|
||||
|
||||
// Check if product is published
|
||||
if (doc._status !== 'published') {
|
||||
await deleteProductVector(doc.id);
|
||||
req.payload.logger.info(`Removed drafted product ${doc.sku} from Qdrant`);
|
||||
} else {
|
||||
// Serialize payload
|
||||
const contentText = `${doc.title} - SKU: ${doc.sku}\n${doc.description || ''}`;
|
||||
const payload = {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
slug: doc.slug,
|
||||
description: doc.description,
|
||||
featuredImage: doc.featuredImage, // usually just ID or URL depending on depth
|
||||
};
|
||||
await upsertProductVector(doc.id, contentText, payload);
|
||||
req.payload.logger.info(`Upserted product ${doc.sku} to Qdrant`);
|
||||
}
|
||||
} catch (error) {
|
||||
req.payload.logger.error({ msg: 'Error syncing product to Qdrant', err: error, productId: doc.id });
|
||||
}
|
||||
}, 0);
|
||||
return doc;
|
||||
},
|
||||
],
|
||||
afterDelete: [
|
||||
async ({ id, req }) => {
|
||||
try {
|
||||
const { deleteProductVector } = await import('../../lib/qdrant');
|
||||
await deleteProductVector(id as string | number);
|
||||
req.payload.logger.info(`Deleted product ${id} from Qdrant`);
|
||||
} catch (error) {
|
||||
req.payload.logger.error({ msg: 'Error deleting product from Qdrant', err: error, productId: id });
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
|
||||
Reference in New Issue
Block a user