// src/services/advanced-search.service.ts
import { PrismaClient } from '@prisma/client';
import { EmbeddingService } from './embedding.service';
const prisma = new PrismaClient();
export interface ProductFilters {
category?: string;
brand?: string;
minPrice?: number;
maxPrice?: number;
colors?: string[];
inStock?: boolean;
tags?: string[];
}
export interface AdvancedSearchOptions {
query: string;
filters?: ProductFilters;
limit?: number;
minSimilarity?: number;
useHybrid?: boolean;
}
export class AdvancedSearchService {
/**
* Hybrid search with filters
*/
static async searchProducts(options: AdvancedSearchOptions) {
const {
query,
filters = {},
limit = 20,
minSimilarity = 0.6,
useHybrid = true,
} = options;
const queryEmbedding = await EmbeddingService.getEmbedding(query);
// Build filter conditions
const conditions: string[] = ['embedding IS NOT NULL'];
if (filters.category) {
conditions.push(`category = '${filters.category}'`);
}
if (filters.brand) {
conditions.push(`brand = '${filters.brand}'`);
}
if (filters.minPrice !== undefined) {
conditions.push(`price >= ${filters.minPrice}`);
}
if (filters.maxPrice !== undefined) {
conditions.push(`price <= ${filters.maxPrice}`);
}
if (filters.colors && filters.colors.length > 0) {
const colorList = filters.colors.map(c => `'${c}'`).join(',');
conditions.push(`color = ANY(ARRAY[${colorList}])`);
}
if (filters.inStock !== undefined) {
conditions.push(`in_stock = ${filters.inStock}`);
}
if (filters.tags && filters.tags.length > 0) {
const tagList = filters.tags.map(t => `'${t}'`).join(',');
conditions.push(`tags && ARRAY[${tagList}]::text[]`);
}
const whereClause = conditions.join(' AND ');
if (useHybrid) {
// Hybrid: Vector + Keyword + Filters
return await prisma.$queryRaw`
WITH vector_search AS (
SELECT
id, name, description, brand, price, color,
1 - (embedding <=> ${queryEmbedding}::vector) as vec_score
FROM products
WHERE ${whereClause}
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT 100
),
keyword_search AS (
SELECT
id, name, description, brand, price, color,
ts_rank(to_tsvector('english', name || ' ' || description), plainto_tsquery('english', ${query})) as keyword_score
FROM products
WHERE ${whereClause}
AND to_tsvector('english', name || ' ' || description) @@ plainto_tsquery('english', ${query})
LIMIT 100
)
SELECT DISTINCT
COALESCE(v.id, k.id) as id,
COALESCE(v.name, k.name) as name,
COALESCE(v.description, k.description) as description,
COALESCE(v.brand, k.brand) as brand,
COALESCE(v.price, k.price) as price,
COALESCE(v.color, k.color) as color,
(COALESCE(v.vec_score, 0) * 0.7 + COALESCE(k.keyword_score, 0) * 0.3) as combined_score
FROM vector_search v
FULL OUTER JOIN keyword_search k ON v.id = k.id
WHERE (COALESCE(v.vec_score, 0) * 0.7 + COALESCE(k.keyword_score, 0) * 0.3) > ${minSimilarity}
ORDER BY combined_score DESC
LIMIT ${limit}
`;
} else {
// Vector only with filters
return await prisma.$queryRaw`
SELECT
id, name, description, brand, price, color,
1 - (embedding <=> ${queryEmbedding}::vector) as similarity
FROM products
WHERE ${whereClause}
AND 1 - (embedding <=> ${queryEmbedding}::vector) > ${minSimilarity}
ORDER BY embedding <=> ${queryEmbedding}::vector
LIMIT ${limit}
`;
}
}
}