Реализация AI-рекомендаций товаров на сайте
Рекомендации товаров — одна из наиболее измеримых инвестиций в e-commerce: по данным McKinsey, 35% выручки Amazon генерируется через рекомендации. Технически задача сложнее, чем для контента: нужно учитывать категории, атрибуты товаров, сезонность, ценовые диапазоны, наличие на складе.
Типы рекомендаций в e-commerce
- «Похожие товары» — товары с близкими атрибутами (карточка товара)
- «Часто покупают вместе» — complementary items (корзина, чекаут)
- «Персональные рекомендации» — на главной, в разделе (история покупок + просмотры)
- «Альтернативы» — если товар отсутствует, предложить замену
- «После покупки» — upsell, аксессуары, расходники
Структура данных товара для эмбеддинга
function buildProductText(product) {
return [
product.name,
product.brand,
product.category + ' > ' + product.subcategory,
product.description?.slice(0, 500),
product.tags?.join(', '),
Object.entries(product.attributes || {})
.map(([k, v]) => `${k}: ${v}`)
.join(', '),
].filter(Boolean).join('\n');
}
// Индексирование
async function indexProduct(product) {
if (!product.active || product.stock === 0) return; // не индексируем неактивные
const text = buildProductText(product);
const { data: [{ embedding }] } = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
await db.query(`
INSERT INTO product_embeddings (product_id, embedding, updated_at)
VALUES ($1, $2::vector, NOW())
ON CONFLICT (product_id) DO UPDATE
SET embedding = $2::vector, updated_at = NOW()
`, [product.id, JSON.stringify(embedding)]);
}
Похожие товары
async function getSimilarProducts(productId, options = {}) {
const { limit = 8, minPrice, maxPrice, inStockOnly = true } = options;
const result = await db.query(`
WITH source AS (
SELECT pe.embedding, p.price, p.category_id
FROM product_embeddings pe
JOIN products p ON p.id = pe.product_id
WHERE pe.product_id = $1
)
SELECT
p.id, p.name, p.slug, p.price,
p.main_image, p.rating, p.reviews_count,
1 - (pe.embedding <=> source.embedding) AS similarity
FROM product_embeddings pe
JOIN products p ON p.id = pe.product_id
CROSS JOIN source
WHERE pe.product_id != $1
AND p.active = true
AND ($2::boolean IS FALSE OR p.stock > 0)
AND ($3::numeric IS NULL OR p.price >= $3)
AND ($4::numeric IS NULL OR p.price <= $4)
ORDER BY pe.embedding <=> source.embedding
LIMIT $5
`, [productId, inStockOnly, minPrice || null, maxPrice || null, limit]);
return result.rows;
}
Ассоциативные правила: "часто покупают вместе"
Market Basket Analysis через Apriori или FP-Growth на истории заказов:
# Python: периодическое обновление (cron раз в сутки)
from mlxtend.frequent_patterns import fpgrowth, association_rules
import pandas as pd
def compute_frequently_bought_together():
# Загружаем заказы
orders = fetch_orders_last_90_days() # [(order_id, product_id)]
# Создаём матрицу заказ-товар
basket = orders.groupby(['order_id', 'product_id'])['product_id'] \
.count().unstack().fillna(0)
basket = basket.map(lambda x: 1 if x > 0 else 0)
# FP-Growth
frequent_sets = fpgrowth(basket, min_support=0.005, use_colnames=True)
rules = association_rules(frequent_sets, metric='lift', min_threshold=1.5)
# Сохраняем в БД
for _, rule in rules.iterrows():
antecedent = list(rule['antecedents'])[0]
consequent = list(rule['consequents'])[0]
save_association(antecedent, consequent, rule['confidence'], rule['lift'])
// Node.js: получение ассоциаций
async function getFrequentlyBoughtTogether(productId, limit = 4) {
const result = await db.query(`
SELECT
p.id, p.name, p.slug, p.price, p.main_image,
ar.confidence, ar.lift
FROM association_rules ar
JOIN products p ON p.id = ar.consequent_id
WHERE ar.antecedent_id = $1
AND p.active = true AND p.stock > 0
ORDER BY ar.lift DESC
LIMIT $2
`, [productId, limit]);
return result.rows;
}
Персональные рекомендации через матричную факторизацию
# Обучение модели на implicit feedback
import implicit
from scipy.sparse import csr_matrix
def train_product_model(events):
# events: user_id, product_id, weight
# weight: view=1, add_to_cart=3, purchase=10, review=8
users_idx = {u: i for i, u in enumerate(events['user_id'].unique())}
items_idx = {p: i for i, p in enumerate(events['product_id'].unique())}
matrix = csr_matrix((
events['weight'],
(events['user_id'].map(users_idx), events['product_id'].map(items_idx))
))
model = implicit.als.AlternatingLeastSquares(factors=64, iterations=30)
model.fit(matrix.T) # item-user
return model, users_idx, items_idx
# Получение рекомендаций для пользователя
def get_personal_recs(user_id, model_data, n=12):
model, users_idx, items_idx = model_data
items_idx_rev = {v: k for k, v in items_idx.items()}
if user_id not in users_idx:
return [] # cold start — вернём trending
user_items = get_user_item_matrix_row(user_id, users_idx, items_idx)
ids, scores = model.recommend(users_idx[user_id], user_items, N=n)
return [{'product_id': items_idx_rev[i], 'score': float(s)} for i, s in zip(ids, scores)]
Cold Start: новые пользователи и товары
Новый пользователь — показываем бестселлеры с персонализацией по UTM/категории входа:
async function getNewUserRecs(entryCategory, limit = 8) {
return db.query(`
SELECT p.*, ps.views_7d, ps.purchases_7d
FROM products p
JOIN product_stats ps ON ps.product_id = p.id
WHERE p.active = true AND p.stock > 0
AND ($1::text IS NULL OR p.category_slug = $1)
ORDER BY ps.purchases_7d DESC, ps.rating DESC
LIMIT $2
`, [entryCategory || null, limit]);
}
Новый товар — content-based эмбеддинг работает сразу, коллаборативная фильтрация подтянется через 50–100 событий.
Разнообразие рекомендаций (Diversity)
Блок из 8 одинаковых ноутбуков — плохо. Нужно разнообразие:
function diversify(recommendations, diversityFactor = 0.3) {
const selected = [recommendations[0]];
const remaining = recommendations.slice(1);
while (selected.length < 8 && remaining.length > 0) {
// Находим наименее похожий на уже выбранные
const scores = remaining.map(candidate => {
const maxSimilarity = Math.max(
...selected.map(s => categorySimilarity(s, candidate))
);
return {
item: candidate,
score: candidate.score * (1 - diversityFactor * maxSimilarity),
};
});
scores.sort((a, b) => b.score - a.score);
selected.push(scores[0].item);
remaining.splice(remaining.indexOf(scores[0].item), 1);
}
return selected;
}
function categorySimilarity(a, b) {
if (a.category_id === b.category_id) return 1;
if (a.parent_category_id === b.parent_category_id) return 0.5;
return 0;
}
A/B тестирование алгоритмов
async function getRecommendations(userId, productId) {
const variant = await getABVariant(userId, 'recs-algorithm');
switch (variant) {
case 'content-based':
return getSimilarProducts(productId);
case 'collaborative':
return getPersonalRecs(userId);
case 'hybrid':
return getHybridRecs(userId, productId);
default:
return getSimilarProducts(productId);
}
}
// Трекинг конверсии рекомендаций
async function trackRecommendationClick(userId, productId, position, algorithm) {
await db.query(`
INSERT INTO rec_events (user_id, product_id, position, algorithm, event_type, created_at)
VALUES ($1, $2, $3, $4, 'click', NOW())
`, [userId, productId, position, algorithm]);
}
Сроки
- Content-based похожие товары через pgvector — 3–4 дня
- "Часто покупают вместе" через ассоциативные правила — плюс 2–3 дня
- Персональные рекомендации (ALS) + Python-сервис — плюс 4–5 дней
- Полная система (все типы рекомендаций + A/B + аналитика) — 3–4 недели







