Building a Recommendation Engine: The Basics
A practical introduction to recommendation systems: collaborative filtering, content-based filtering and hybrid approaches, implemented in TypeScript.

Recommendation engines power the "you might also like" features across every major platform — Netflix, Spotify, Amazon, YouTube. At their core, they solve a deceptively simple problem: given what we know about a user's past behavior, predict what they will want next.
There are two fundamental approaches: collaborative filtering (find similar users and recommend what they liked) and content-based filtering (find similar items to what the user already liked). Most production systems use a hybrid of both.
Collaborative Filtering: Users Who Liked X Also Liked Y
Collaborative filtering does not need to understand what an item is. It works purely from user behavior patterns — if users A and B both liked items 1, 2, and 3, and user A also liked item 4, then user B will probably like item 4 too.
// User-item interaction matrix
// Rows: users, Columns: items, Values: ratings (or 1/0 for implicit feedback)
type RatingMatrix = number[][];
const ratings: RatingMatrix = [
// Item0 Item1 Item2 Item3 Item4
/* User0 */ [5, 3, 0, 1, 0],
/* User1 */ [4, 0, 0, 1, 1],
/* User2 */ [1, 1, 0, 5, 0],
/* User3 */ [0, 0, 5, 4, 4],
/* User4 */ [0, 1, 4, 0, 5],
];
// 0 means "no rating" — the user hasn't interacted with that item
// Cosine similarity between two users
function cosineSimilarity(userA: number[], userB: number[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < userA.length; i++) {
// Only compare items both users have rated
if (userA[i] !== 0 && userB[i] !== 0) {
dotProduct += userA[i] * userB[i];
normA += userA[i] * userA[i];
normB += userB[i] * userB[i];
}
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
// Find the K most similar users
function findSimilarUsers(
targetUser: number,
ratings: RatingMatrix,
k: number
): Array<{ userId: number; similarity: number }> {
const similarities: Array<{ userId: number; similarity: number }> = [];
for (let i = 0; i < ratings.length; i++) {
if (i === targetUser) continue;
const sim = cosineSimilarity(ratings[targetUser], ratings[i]);
if (sim > 0) {
similarities.push({ userId: i, similarity: sim });
}
}
return similarities
.sort((a, b) => b.similarity - a.similarity)
.slice(0, k);
}// Generate recommendations based on similar users
function recommendItems(
targetUser: number,
ratings: RatingMatrix,
k: number = 3,
numRecommendations: number = 5
): Array<{ itemId: number; predictedScore: number }> {
const similarUsers = findSimilarUsers(targetUser, ratings, k);
const userRatings = ratings[targetUser];
const predictions: Array<{ itemId: number; predictedScore: number }> = [];
for (let item = 0; item < userRatings.length; item++) {
// Skip items the user has already rated
if (userRatings[item] !== 0) continue;
// Weighted average of similar users' ratings for this item
let weightedSum = 0;
let similaritySum = 0;
for (const { userId, similarity } of similarUsers) {
const rating = ratings[userId][item];
if (rating !== 0) {
weightedSum += similarity * rating;
similaritySum += Math.abs(similarity);
}
}
if (similaritySum > 0) {
predictions.push({
itemId: item,
predictedScore: weightedSum / similaritySum,
});
}
}
return predictions
.sort((a, b) => b.predictedScore - a.predictedScore)
.slice(0, numRecommendations);
}
// Example: Recommend items for User 0
const recs = recommendItems(0, ratings);
// Might return: [{ itemId: 4, predictedScore: 3.2 }]
// "Users similar to you also liked Item 4"Content-Based Filtering: Similar Items
Content-based filtering uses item attributes to find similar items. If a user liked action movies with sci-fi elements, recommend other action-sci-fi movies — regardless of what other users think.
// Content-based: represent items as feature vectors
interface Item {
id: string;
title: string;
features: Record<string, number>; // Feature name → weight
}
const articles: Item[] = [
{
id: 'a1',
title: 'React Performance Optimization',
features: { react: 1, performance: 1, frontend: 1, javascript: 0.8 },
},
{
id: 'a2',
title: 'PostgreSQL Query Tuning',
features: { postgresql: 1, performance: 1, database: 1, sql: 0.8 },
},
{
id: 'a3',
title: 'React State Management',
features: { react: 1, state: 1, frontend: 1, javascript: 0.8 },
},
{
id: 'a4',
title: 'Database Indexing Strategies',
features: { database: 1, performance: 0.8, postgresql: 0.5, indexing: 1 },
},
];
// Compute similarity between two items based on their features
function itemSimilarity(itemA: Item, itemB: Item): number {
const allFeatures = new Set([
...Object.keys(itemA.features),
...Object.keys(itemB.features),
]);
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (const feature of allFeatures) {
const a = itemA.features[feature] ?? 0;
const b = itemB.features[feature] ?? 0;
dotProduct += a * b;
normA += a * a;
normB += b * b;
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
// Recommend items similar to ones the user liked
function contentBasedRecommend(
likedItems: Item[],
allItems: Item[],
numRecommendations: number = 3
): Item[] {
const likedIds = new Set(likedItems.map(i => i.id));
const candidates = allItems
.filter(item => !likedIds.has(item.id))
.map(candidate => {
// Average similarity to all liked items
const avgSim = likedItems.reduce(
(sum, liked) => sum + itemSimilarity(liked, candidate),
0
) / likedItems.length;
return { item: candidate, score: avgSim };
})
.sort((a, b) => b.score - a.score);
return candidates.slice(0, numRecommendations).map(c => c.item);
}// ❌ Recommend based on a single signal
function naiveRecommend(user: User): Item[] {
// Only uses the most recent item — ignores the full preference profile
const lastViewed = user.history[user.history.length - 1];
return findSimilar(lastViewed);
}
// ✅ Build a user preference profile from all interactions
function buildUserProfile(
interactions: UserInteraction[]
): Record<string, number> {
const profile: Record<string, number> = {};
for (const interaction of interactions) {
const weight = interactionWeight(interaction.type);
// 'purchase' > 'bookmark' > 'view' > 'skip'
for (const [feature, value] of Object.entries(interaction.item.features)) {
profile[feature] = (profile[feature] ?? 0) + value * weight;
}
}
// Normalize the profile
const maxValue = Math.max(...Object.values(profile), 1);
for (const key of Object.keys(profile)) {
profile[key] /= maxValue;
}
return profile;
}The Hybrid Approach
Neither collaborative nor content-based filtering alone solves the full problem. Collaborative filtering suffers from the cold start problem (new users have no history), and content-based filtering creates filter bubbles (only recommends similar items).
// Hybrid recommendation: combine both approaches
function hybridRecommend(
userId: string,
userHistory: UserInteraction[],
ratings: RatingMatrix,
allItems: Item[],
config: { collaborativeWeight: number; contentWeight: number }
): RecommendedItem[] {
// Get collaborative filtering scores
const collabScores = collaborativeRecommend(userId, ratings);
// Get content-based scores
const likedItems = userHistory
.filter(h => h.type === 'like' || h.type === 'purchase')
.map(h => h.item);
const contentScores = contentBasedScores(likedItems, allItems);
// Combine scores with configurable weights
const combined = new Map<string, number>();
for (const [itemId, score] of collabScores) {
combined.set(itemId,
(combined.get(itemId) ?? 0) + score * config.collaborativeWeight
);
}
for (const [itemId, score] of contentScores) {
combined.set(itemId,
(combined.get(itemId) ?? 0) + score * config.contentWeight
);
}
// Sort by combined score
return [...combined.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([itemId, score]) => ({ itemId, score }));
}
// Cold start strategy: use content-based for new users,
// blend in collaborative as interaction history grows
function getWeights(interactionCount: number) {
if (interactionCount < 5) {
return { collaborativeWeight: 0.1, contentWeight: 0.9 };
}
if (interactionCount < 20) {
return { collaborativeWeight: 0.4, contentWeight: 0.6 };
}
return { collaborativeWeight: 0.6, contentWeight: 0.4 };
}Evaluation Metrics
How do you know if your recommendation engine is working? Standard classification metrics apply, but with some domain-specific nuance.
interface RecommendationMetrics {
// Precision@K: of the top K recommendations, how many did the user like?
precisionAtK: number;
// Recall@K: of all items the user would like, how many appeared in top K?
recallAtK: number;
// NDCG: normalized discounted cumulative gain — measures ranking quality
ndcg: number;
// Coverage: what percentage of items ever get recommended?
catalogCoverage: number;
// Diversity: how different are the recommendations from each other?
intralistDiversity: number;
}
function precisionAtK(
recommended: string[],
relevant: Set<string>,
k: number
): number {
const topK = recommended.slice(0, k);
const hits = topK.filter(item => relevant.has(item)).length;
return hits / k;
}Key Takeaways
- Collaborative filtering finds similar users — it works from behavior patterns alone, no item knowledge needed, but struggles with cold start
- Content-based filtering finds similar items — it uses item features to match preferences, works for new users, but creates filter bubbles
- Hybrid approaches combine both — weight collaborative filtering more as user history grows, and content-based more for new users
- Cosine similarity is the workhorse — it measures the angle between vectors, normalizing for magnitude differences
- Handle cold start explicitly — new users need content-based or popularity-based recommendations until enough interaction data accumulates
- Measure beyond accuracy — coverage, diversity, and serendipity matter as much as precision for user satisfaction


