Implementing AI-Powered Content Recommendations
Content recommendations keep users engaged and increase page depth. The approach depends on available behavioral data: for a new site without history — content-based filtering with embeddings; for a site with thousands of users and events — collaborative filtering or hybrid systems.
Choosing an Approach
| Approach | Data | Complexity | When |
|---|---|---|---|
| Content-based (embeddings) | Content only | Low | New site, small audience |
| Collaborative filtering | Interaction history | Medium | 10K+ users |
| Hybrid | Content + behavior | High | Media, blogs, news sites |
| LLM-based | Content + profile | Medium | Personalized curations |
Content-Based: Similar Content via Embeddings
Fastest approach — similar articles based on vector distance:
import OpenAI from 'openai';
import { sql } from '@vercel/postgres';
const openai = new OpenAI();
// Index on article publish
async function indexArticle(article) {
const textToEmbed = [
article.title,
article.excerpt,
article.tags.join(', '),
article.body.slice(0, 2000), // first 2000 chars
].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}
`;
}
// Get similar articles
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;
}
Collaborative Filtering: "Users Like You Read"
Matrix factorization via implicit feedback (views, time on page):
# Python script for periodic training (cron)
import implicit
import numpy as np
from scipy.sparse import csr_matrix
import pickle
def train_collaborative_model():
# Load events: user_id, article_id, weight
# weight = 1 (view) + 2 (scroll 50%) + 5 (read to end) + 10 (shared)
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)
# Save model and mappings
with open('/models/collab_model.pkl', 'wb') as f:
pickle.dump({ 'model': model, 'users': users, 'items': items }, f)
// Node.js: get recommendations via Python service
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();
}
Hybrid System with Personalization
Combine content-based and collaborative signals:
async function getPersonalizedRecommendations(userId, currentArticleId) {
const [contentBased, collaborative, trending] = await Promise.all([
getSimilarArticles(currentArticleId, 10),
getCollaborativeRecs(userId, 10),
getTrendingArticles(10), // by views in last 24h
]);
// Merge with weights
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);
});
// Sort by total score
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-Based Recommendations with Explanation
For smarter matching and personalized explanations:
async function getLLMRecommendations(user, readHistory, availableArticles) {
const userProfile = `
Read: ${readHistory.map(a => a.title).join(', ')}
Interest categories: ${getTopCategories(readHistory).join(', ')}
Average reading time: ${user.avgReadTime} min
`;
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: 'You are a recommendation system. Respond in JSON: { recommendations: [{id, reason}] }',
},
{
role: 'user',
content: `Profile: ${userProfile}\n\nAvailable articles:\n${articlesList}\n\nSelect 4 most relevant for this user.`,
},
],
max_tokens: 400,
});
const { recommendations } = JSON.parse(response.choices[0].message.content);
// Enrich with DB data
return Promise.all(recommendations.map(async rec => ({
...await fetchArticle(rec.id),
reason: rec.reason, // "You've read similar material about React"
})));
}
Event Tracking
Behavioral data is the foundation for improving recommendations:
// Client tracker
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,
}));
}
}
Caching Recommendations
Recommendations are expensive, so cache them:
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 hour
return recs;
}
Timeline
- Content-based recommendations via pgvector — 3–4 days
- Event tracking + behavior analytics — plus 2 days
- Collaborative filtering (implicit ALS) — plus 3–4 days
- Hybrid system with LLM explanations — 2–3 weeks full cycle
- A/B testing algorithms — plus 2–3 days







