Eine Recommendation Engine bauen: Die Grundlagen
Eine praktische Einführung in Empfehlungssysteme: Collaborative Filtering, Content-based Filtering und hybride Ansätze, in TypeScript umgesetzt.

Recommendation Engines stecken hinter den „Das könnte dir auch gefallen"-Funktionen aller großen Plattformen – Netflix, Spotify, Amazon, YouTube. Im Kern lösen sie ein trügerisch einfaches Problem: Aus dem, was wir über das bisherige Verhalten eines Nutzers wissen, vorhersagen, was er als Nächstes wollen wird.
Es gibt zwei grundlegende Ansätze: Collaborative Filtering (ähnliche Nutzer finden und empfehlen, was ihnen gefallen hat) und Content-based Filtering (Elemente finden, die dem ähneln, was dem Nutzer bereits gefallen hat). Die meisten Produktivsysteme nutzen eine Mischung aus beiden.
Collaborative Filtering: Nutzer, denen X gefiel, mochten auch Y
Collaborative Filtering muss nicht verstehen, was ein Element ist. Es funktioniert rein auf Basis von Nutzerverhaltensmustern – wenn die Nutzer A und B beide die Elemente 1, 2 und 3 mochten und Nutzer A außerdem Element 4 mochte, dann wird Nutzer B wahrscheinlich auch Element 4 mögen.
// 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: Ähnliche Elemente
Content-based Filtering nutzt die Attribute der Elemente, um ähnliche Elemente zu finden. Wenn ein Nutzer Actionfilme mit Sci-Fi-Elementen mochte, empfiehl andere Action-Sci-Fi-Filme – unabhängig davon, was andere Nutzer denken.
// 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;
}Der hybride Ansatz
Weder Collaborative Filtering noch Content-based Filtering lösen allein das gesamte Problem. Collaborative Filtering leidet unter dem Cold-Start-Problem (neue Nutzer haben keinen Verlauf), und Content-based Filtering erzeugt Filterblasen (es empfiehlt nur ähnliche Elemente).
// 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 };
}Bewertungsmetriken
Woher weißt du, ob deine Recommendation Engine funktioniert? Die üblichen Klassifikationsmetriken gelten, allerdings mit einigen domänenspezifischen Feinheiten.
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;
}Die wichtigsten Erkenntnisse
- Collaborative Filtering findet ähnliche Nutzer – es funktioniert allein aus Verhaltensmustern, ganz ohne Wissen über die Elemente, hat aber Probleme mit dem Cold Start
- Content-based Filtering findet ähnliche Elemente – es nutzt Element-Merkmale, um Präferenzen zu treffen, funktioniert für neue Nutzer, erzeugt aber Filterblasen
- Hybride Ansätze kombinieren beides – gewichte Collaborative Filtering stärker, je mehr Nutzerverlauf vorliegt, und Content-based Filtering stärker bei neuen Nutzern
- Kosinus-Ähnlichkeit ist das Arbeitstier – sie misst den Winkel zwischen Vektoren und normalisiert dabei Betragsunterschiede
- Behandle Cold Start explizit – neue Nutzer brauchen content-basierte oder popularitätsbasierte Empfehlungen, bis genügend Interaktionsdaten vorliegen
- Miss mehr als nur Genauigkeit – Coverage, Diversität und Serendipität sind für die Nutzerzufriedenheit genauso wichtig wie Precision


