Реализация AI-рекомендаций контента на сайте
Рекомендации контента удерживают пользователей и увеличивают глубину просмотра. Подход зависит от наличия данных о поведении: для нового сайта без истории — content-based фильтрация на эмбеддингах; для сайта с тысячами пользователей и событиями — коллаборативная фильтрация или двухуровневые системы.
Выбор подхода
| Подход | Данные | Сложность | Когда |
|---|---|---|---|
| Content-based (эмбеддинги) | Только контент | Низкая | Новый сайт, малая аудитория |
| Коллаборативная фильтрация | История взаимодействий | Средняя | 10K+ пользователей |
| Гибридная | Контент + поведение | Высокая | Медиа, блоги, новостные сайты |
| LLM-based | Контент + профиль | Средняя | Персонализованные подборки |
Content-Based: похожий контент через эмбеддинги
Самый быстрый старт — похожие статьи на основе векторного расстояния:
import OpenAI from 'openai';
import { sql } from '@vercel/postgres';
const openai = new OpenAI();
// Индексирование при публикации статьи
async function indexArticle(article) {
const textToEmbed = [
article.title,
article.excerpt,
article.tags.join(', '),
article.body.slice(0, 2000), // первые 2000 символов
].join('\n\n');
const { data: [{ embedding }] } = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: textToEmbed,
});
await sql`
UPDATE articles
SET embedding = ${JSON.stringify(embedding)}::vector
WHERE id = ${article.id}
`;
}
// Получение похожих статей
async function getSimilarArticles(articleId, limit = 6) {
const result = await sql`
WITH source AS (
SELECT embedding FROM articles WHERE id = ${articleId}
)
SELECT
a.id, a.title, a.slug, a.excerpt, a.published_at,
a.category, a.read_time,
1 - (a.embedding <=> source.embedding) AS similarity
FROM articles a, source
WHERE a.id != ${articleId}
AND a.published = true
AND a.embedding IS NOT NULL
ORDER BY a.embedding <=> source.embedding
LIMIT ${limit}
`;
return result.rows;
}
Коллаборативная фильтрация: "пользователи похожие на тебя читали"
Матричная факторизация через implicit feedback (просмотры, время на странице):
# Python-скрипт для периодического обучения (cron)
import implicit
import numpy as np
from scipy.sparse import csr_matrix
import pickle
def train_collaborative_model():
# Загружаем события: user_id, article_id, weight
# weight = 1 (просмотр) + 2 (scroll 50%) + 5 (прочитал до конца) + 10 (поделился)
events = fetch_events_from_db()
users = {u: i for i, u in enumerate(events['user_id'].unique())}
items = {a: i for i, a in enumerate(events['article_id'].unique())}
rows = events['user_id'].map(users)
cols = events['article_id'].map(items)
data = events['weight']
matrix = csr_matrix((data, (rows, cols)))
model = implicit.als.AlternatingLeastSquares(
factors=128,
regularization=0.01,
iterations=50,
use_gpu=False,
)
model.fit(matrix)
# Сохраняем модель и маппинги
with open('/models/collab_model.pkl', 'wb') as f:
pickle.dump({ 'model': model, 'users': users, 'items': items }, f)
// Node.js: получение рекомендаций через Python-сервис
async function getCollaborativeRecs(userId, limit = 10) {
const response = await fetch('http://ml-service:5000/recommend', {
method: 'POST',
body: JSON.stringify({ user_id: userId, limit }),
});
return response.json();
}
Гибридная система с персонализацией
Объединяем content-based и коллаборативные сигналы:
async function getPersonalizedRecommendations(userId, currentArticleId) {
const [contentBased, collaborative, trending] = await Promise.all([
getSimilarArticles(currentArticleId, 10),
getCollaborativeRecs(userId, 10),
getTrendingArticles(10), // по просмотрам за последние 24 часа
]);
// Объединяем с весами
const scores = new Map();
contentBased.forEach((article, i) => {
scores.set(article.id, (scores.get(article.id) || 0) + (10 - i) * 0.4);
});
collaborative.forEach((article, i) => {
scores.set(article.id, (scores.get(article.id) || 0) + (10 - i) * 0.5);
});
trending.forEach((article, i) => {
scores.set(article.id, (scores.get(article.id) || 0) + (10 - i) * 0.1);
});
// Сортируем по итоговому скору
const allArticleIds = [...scores.keys()];
const articles = await fetchArticlesByIds(allArticleIds);
return articles
.map(a => ({ ...a, score: scores.get(a.id) }))
.sort((a, b) => b.score - a.score)
.slice(0, 6);
}
LLM-рекомендации с объяснением
Для более умного подбора и персонализированного объяснения:
async function getLLMRecommendations(user, readHistory, availableArticles) {
const userProfile = `
Прочитал: ${readHistory.map(a => a.title).join(', ')}
Категории интересов: ${getTopCategories(readHistory).join(', ')}
Среднее время чтения: ${user.avgReadTime} мин
`;
const articlesList = availableArticles.slice(0, 20).map(a =>
`ID:${a.id} | ${a.title} | ${a.category} | ${a.tags.join(',')}`
).join('\n');
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
response_format: { type: 'json_object' },
messages: [
{
role: 'system',
content: 'Ты рекомендательная система. Отвечай JSON: { recommendations: [{id, reason}] }',
},
{
role: 'user',
content: `Профиль: ${userProfile}\n\nДоступные статьи:\n${articlesList}\n\nВыбери 4 наиболее релевантных для этого пользователя.`,
},
],
max_tokens: 400,
});
const { recommendations } = JSON.parse(response.choices[0].message.content);
// Обогащаем данными из БД
return Promise.all(recommendations.map(async rec => ({
...await fetchArticle(rec.id),
reason: rec.reason, // "Вы читали похожие материалы о React"
})));
}
Трекинг событий
Данные о поведении — основа для улучшения рекомендаций:
// Клиентский трекер
class ReadingTracker {
constructor(articleId) {
this.articleId = articleId;
this.startTime = Date.now();
this.maxScroll = 0;
this.trackScroll();
}
trackScroll() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const progress = entry.target.dataset.progress;
if (progress > this.maxScroll) {
this.maxScroll = progress;
this.sendEvent('scroll', { progress });
}
}
});
});
document.querySelectorAll('[data-progress]').forEach(el => observer.observe(el));
}
async sendEvent(type, data = {}) {
navigator.sendBeacon('/api/track', JSON.stringify({
type,
articleId: this.articleId,
timeOnPage: Date.now() - this.startTime,
...data,
}));
}
}
Кэширование рекомендаций
Рекомендации — дорогая операция, кэшируем:
async function getCachedRecommendations(userId, articleId) {
const cacheKey = `recs:${userId}:${articleId}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const recs = await getPersonalizedRecommendations(userId, articleId);
await redis.setex(cacheKey, 3600, JSON.stringify(recs)); // 1 час
return recs;
}
Сроки
- Content-based рекомендации через pgvector — 3–4 дня
- Трекинг событий + аналитика поведения — плюс 2 дня
- Коллаборативная фильтрация (implicit ALS) — плюс 3–4 дня
- Гибридная система с LLM-объяснениями — 2–3 недели полного цикла
- A/B тестирование алгоритмов — плюс 2–3 дня







