Реалізація AI-рекомендацій контенту на сайті
Рекомендації контенту утримують користувачів та збільшують глибину перегляду. Підхід залежить від наявності даних про поведінку: для нового сайту без історії — content-based фільтрація на embeddings; для сайту з тисячами користувачів та подіями — колаборативна фільтрація або гібридні системи.
Вибір підходу
| Підхід | Дані | Складність | Коли |
|---|---|---|---|
| Content-based (embeddings) | Тільки контент | Низька | Новий сайт, мала аудиторія |
| Колаборативна фільтрація | Історія взаємодій | Середня | 10K+ користувачів |
| Гібридна | Контент + поведінка | Висока | Медіа, блоги, новинні сайти |
| LLM-based | Контент + профіль | Середня | Персоналізовані добірки |
Content-Based: Подібний контент через Embeddings
Найшвидший старт — подібні статті на основі векторної відстані:
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 дні







