Product recommendations are the backbone of modern e-commerce success. Amazon attributes 35% of its revenue to recommendation systems, while Netflix estimates their recommendation engine saves $1 billion annually by reducing churn. According to McKinsey research, companies that excel at personalization generate 40% more revenue from those activities than average players.
In this comprehensive guide, we'll build a production-ready recommendation system from scratch. You'll learn collaborative filtering, content-based recommendations, session-based approaches for anonymous users, and hybrid architectures that solve real e-commerce challenges. We'll also tackle the infamous cold-start problem and implement A/B testing to measure real business impact.
Understanding Recommendation System Fundamentals
Before diving into implementation, let's understand the three fundamental approaches to building recommendation systems, each with distinct strengths and use cases:
// recommendation-types.ts
/**
* E-commerce Recommendation System Architecture
*
* Three core approaches with different data requirements and trade-offs
*/
// 1. Collaborative Filtering
// "Users who bought this also bought..."
// Requires: User interaction history (purchases, views, ratings)
// Strengths: Discovers unexpected patterns, no domain knowledge needed
// Weaknesses: Cold-start problem, sparsity issues
interface CollaborativeFilteringConfig {
type: 'user-based' | 'item-based' | 'matrix-factorization';
similarityMetric: 'cosine' | 'pearson' | 'jaccard';
minCommonItems: number; // Minimum overlap for similarity
neighborhoodSize: number; // K nearest neighbors
}
// 2. Content-Based Filtering
// "Similar products based on attributes..."
// Requires: Rich product metadata (category, brand, features, description)
// Strengths: No cold-start for items, explainable recommendations
// Weaknesses: Limited discovery, requires good metadata
interface ContentBasedConfig {
features: ProductFeature[];
weightings: Record;
embeddingModel?: 'tfidf' | 'word2vec' | 'sentence-transformers';
similarityThreshold: number;
}
// 3. Session-Based Recommendations
// "Based on what you're browsing now..."
// Requires: Current session behavior (clicks, views, cart actions)
// Strengths: Works for anonymous users, real-time adaptation
// Weaknesses: Limited history, requires sequence modeling
interface SessionBasedConfig {
sequenceLength: number; // How many recent actions to consider
decayFactor: number; // Weight recent actions more heavily
modelType: 'gru4rec' | 'sasrec' | 'bert4rec' | 'simple-rnn';
}
// Product and interaction data structures
interface Product {
id: string;
name: string;
category: string;
subcategory: string;
brand: string;
price: number;
attributes: Record;
tags: string[];
description: string;
imageUrl: string;
popularity: number;
averageRating: number;
reviewCount: number;
}
interface UserInteraction {
userId: string;
productId: string;
interactionType: 'view' | 'add_to_cart' | 'purchase' | 'wishlist' | 'review';
timestamp: number;
sessionId: string;
rating?: number; // For explicit feedback
context: InteractionContext;
}
interface InteractionContext {
device: 'mobile' | 'tablet' | 'desktop';
source: 'search' | 'category' | 'recommendation' | 'direct';
dayOfWeek: number;
hourOfDay: number;
}
Implementing Collaborative Filtering
Collaborative filtering is the most widely used recommendation technique in e-commerce. It identifies patterns in user behavior to make predictions about what users will like. Here's a complete implementation with both user-based and item-based approaches:
// collaborative-filtering-engine.ts
interface SimilarityScore {
id: string;
similarity: number;
}
interface Recommendation {
productId: string;
score: number;
explanation: string;
}
class CollaborativeFilteringEngine {
private userItemMatrix: Map<string, Map<string, number>> = new Map();
private itemUserMatrix: Map<string, Map<string, number>> = new Map();
private userSimilarityCache: Map<string, SimilarityScore[]> = new Map();
private itemSimilarityCache: Map<string, SimilarityScore[]> = new Map();
// Interaction weights for implicit feedback
private readonly interactionWeights: Record<string, number> = {
'view': 1,
'add_to_cart': 3,
'wishlist': 2,
'purchase': 5,
'review': 4
};
constructor(interactions: UserInteraction[]) {
this.buildMatrices(interactions);
}
private buildMatrices(interactions: UserInteraction[]): void {
// Aggregate interactions into ratings
const aggregated = new Map<string, Map<string, number>>();
interactions.forEach(({ userId, productId, interactionType }) => {
const weight = this.interactionWeights[interactionType] || 1;
if (!aggregated.has(userId)) {
aggregated.set(userId, new Map());
}
const userRatings = aggregated.get(userId)!;
const currentScore = userRatings.get(productId) || 0;
userRatings.set(productId, Math.min(currentScore + weight, 10)); // Cap at 10
});
// Build both matrices
aggregated.forEach((items, userId) => {
this.userItemMatrix.set(userId, items);
items.forEach((rating, productId) => {
if (!this.itemUserMatrix.has(productId)) {
this.itemUserMatrix.set(productId, new Map());
}
this.itemUserMatrix.get(productId)!.set(userId, rating);
});
});
}
/**
* Cosine similarity between two rating vectors
* Handles sparse vectors efficiently
*/
private cosineSimilarity(
vectorA: Map<string, number>,
vectorB: Map<string, number>
): number {
// Find common items/users
const commonKeys = [...vectorA.keys()].filter(key => vectorB.has(key));
if (commonKeys.length < 2) return 0; // Need minimum overlap
let dotProduct = 0;
let normA = 0;
let normB = 0;
commonKeys.forEach(key => {
const a = vectorA.get(key)!;
const b = vectorB.get(key)!;
dotProduct += a * b;
});
vectorA.forEach(val => normA += val * val);
vectorB.forEach(val => normB += val * val);
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Adjusted cosine similarity (accounts for user rating bias)
*/
private adjustedCosineSimilarity(
item1: string,
item2: string
): number {
const item1Users = this.itemUserMatrix.get(item1);
const item2Users = this.itemUserMatrix.get(item2);
if (!item1Users || !item2Users) return 0;
const commonUsers = [...item1Users.keys()].filter(
userId => item2Users.has(userId)
);
if (commonUsers.length < 2) return 0;
// Calculate user averages
const userAverages = new Map<string, number>();
commonUsers.forEach(userId => {
const userRatings = this.userItemMatrix.get(userId)!;
const avg = [...userRatings.values()].reduce((a, b) => a + b, 0) /
userRatings.size;
userAverages.set(userId, avg);
});
let numerator = 0;
let sumSq1 = 0;
let sumSq2 = 0;
commonUsers.forEach(userId => {
const avg = userAverages.get(userId)!;
const r1 = item1Users.get(userId)! - avg;
const r2 = item2Users.get(userId)! - avg;
numerator += r1 * r2;
sumSq1 += r1 * r1;
sumSq2 += r2 * r2;
});
if (sumSq1 === 0 || sumSq2 === 0) return 0;
return numerator / (Math.sqrt(sumSq1) * Math.sqrt(sumSq2));
}
/**
* Find K most similar users to the target user
*/
public findSimilarUsers(userId: string, k: number = 20): SimilarityScore[] {
if (this.userSimilarityCache.has(userId)) {
return this.userSimilarityCache.get(userId)!.slice(0, k);
}
const targetUserRatings = this.userItemMatrix.get(userId);
if (!targetUserRatings) return [];
const similarities: SimilarityScore[] = [];
this.userItemMatrix.forEach((ratings, otherUserId) => {
if (otherUserId !== userId) {
const similarity = this.cosineSimilarity(targetUserRatings, ratings);
if (similarity > 0.1) { // Minimum threshold
similarities.push({ id: otherUserId, similarity });
}
}
});
similarities.sort((a, b) => b.similarity - a.similarity);
this.userSimilarityCache.set(userId, similarities);
return similarities.slice(0, k);
}
/**
* Find K most similar items to the target item
*/
public findSimilarItems(productId: string, k: number = 20): SimilarityScore[] {
if (this.itemSimilarityCache.has(productId)) {
return this.itemSimilarityCache.get(productId)!.slice(0, k);
}
const targetItemRatings = this.itemUserMatrix.get(productId);
if (!targetItemRatings) return [];
const similarities: SimilarityScore[] = [];
this.itemUserMatrix.forEach((ratings, otherProductId) => {
if (otherProductId !== productId) {
const similarity = this.adjustedCosineSimilarity(
productId,
otherProductId
);
if (similarity > 0.1) {
similarities.push({ id: otherProductId, similarity });
}
}
});
similarities.sort((a, b) => b.similarity - a.similarity);
this.itemSimilarityCache.set(productId, similarities);
return similarities.slice(0, k);
}
/**
* User-based collaborative filtering recommendations
* "Users similar to you also liked..."
*/
public recommendForUser(
userId: string,
n: number = 10,
excludeOwned: boolean = true
): Recommendation[] {
const userRatings = this.userItemMatrix.get(userId) || new Map();
const ownedItems = excludeOwned ? new Set(userRatings.keys()) : new Set();
const similarUsers = this.findSimilarUsers(userId, 30);
const itemScores: Map<string, {
weightedSum: number;
similaritySum: number;
contributors: string[];
}> = new Map();
similarUsers.forEach(({ id: similarUserId, similarity }) => {
const similarUserRatings = this.userItemMatrix.get(similarUserId)!;
similarUserRatings.forEach((rating, productId) => {
if (!ownedItems.has(productId)) {
const current = itemScores.get(productId) || {
weightedSum: 0,
similaritySum: 0,
contributors: []
};
current.weightedSum += similarity * rating;
current.similaritySum += Math.abs(similarity);
current.contributors.push(similarUserId);
itemScores.set(productId, current);
}
});
});
const recommendations: Recommendation[] = [];
itemScores.forEach((scores, productId) => {
if (scores.similaritySum > 0) {
recommendations.push({
productId,
score: scores.weightedSum / scores.similaritySum,
explanation: `Based on ${scores.contributors.length} similar users`
});
}
});
recommendations.sort((a, b) => b.score - a.score);
return recommendations.slice(0, n);
}
/**
* Item-based collaborative filtering
* "Customers who bought this also bought..."
*/
public recommendSimilarItems(
productIds: string[],
n: number = 10
): Recommendation[] {
const excludeItems = new Set(productIds);
const itemScores: Map<string, number> = new Map();
productIds.forEach(productId => {
const similarItems = this.findSimilarItems(productId, 30);
similarItems.forEach(({ id: similarProductId, similarity }) => {
if (!excludeItems.has(similarProductId)) {
const currentScore = itemScores.get(similarProductId) || 0;
itemScores.set(similarProductId, currentScore + similarity);
}
});
});
const recommendations: Recommendation[] = [...itemScores.entries()]
.map(([productId, score]) => ({
productId,
score,
explanation: 'Frequently bought together'
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
return recommendations;
}
}
Content-Based Filtering Implementation
Content-based filtering recommends products based on their attributes and features. This approach is essential for handling the cold-start problem with new products and providing explainable recommendations:
// content-based-engine.ts
interface ProductVector {
productId: string;
vector: number[];
metadata: Product;
}
interface FeatureWeight {
feature: string;
weight: number;
}
class ContentBasedEngine {
private productVectors: Map<string, ProductVector> = new Map();
private categoryIndex: Map<string, Set<string>> = new Map();
private brandIndex: Map<string, Set<string>> = new Map();
private featureNames: string[] = [];
private readonly defaultWeights: Record<string, number> = {
category: 0.3,
subcategory: 0.2,
brand: 0.15,
priceRange: 0.1,
tags: 0.15,
attributes: 0.1
};
constructor(
products: Product[],
weights?: Partial<Record<string, number>>
) {
const finalWeights = { ...this.defaultWeights, ...weights };
this.buildProductVectors(products, finalWeights);
this.buildIndexes(products);
}
private buildProductVectors(
products: Product[],
weights: Record<string, number>
): void {
// Extract all unique feature values
const categories = new Set<string>();
const subcategories = new Set<string>();
const brands = new Set<string>();
const allTags = new Set<string>();
const priceRanges = ['budget', 'mid', 'premium', 'luxury'];
const allAttributes = new Map<string, Set<string>>();
products.forEach(product => {
categories.add(product.category);
subcategories.add(product.subcategory);
brands.add(product.brand);
product.tags.forEach(tag => allTags.add(tag));
Object.entries(product.attributes).forEach(([key, value]) => {
if (!allAttributes.has(key)) {
allAttributes.set(key, new Set());
}
allAttributes.get(key)!.add(String(value));
});
});
// Build feature name array for vector indexing
this.featureNames = [
...[...categories].map(c => `category:${c}`),
...[...subcategories].map(s => `subcategory:${s}`),
...[...brands].map(b => `brand:${b}`),
...priceRanges.map(p => `price:${p}`),
...[...allTags].map(t => `tag:${t}`),
...Array.from(allAttributes.entries()).flatMap(
([key, values]) => [...values].map(v => `attr:${key}:${v}`)
)
];
// Create vectors for each product
products.forEach(product => {
const vector = this.createProductVector(product, weights);
this.productVectors.set(product.id, {
productId: product.id,
vector,
metadata: product
});
});
}
private createProductVector(
product: Product,
weights: Record<string, number>
): number[] {
const vector = new Array(this.featureNames.length).fill(0);
this.featureNames.forEach((feature, index) => {
if (feature.startsWith('category:') &&
feature === `category:${product.category}`) {
vector[index] = weights.category;
} else if (feature.startsWith('subcategory:') &&
feature === `subcategory:${product.subcategory}`) {
vector[index] = weights.subcategory;
} else if (feature.startsWith('brand:') &&
feature === `brand:${product.brand}`) {
vector[index] = weights.brand;
} else if (feature.startsWith('price:')) {
const range = this.getPriceRange(product.price);
if (feature === `price:${range}`) {
vector[index] = weights.priceRange;
}
} else if (feature.startsWith('tag:')) {
const tag = feature.replace('tag:', '');
if (product.tags.includes(tag)) {
vector[index] = weights.tags / product.tags.length;
}
} else if (feature.startsWith('attr:')) {
const [, key, value] = feature.split(':');
if (String(product.attributes[key]) === value) {
vector[index] = weights.attributes;
}
}
});
return this.normalizeVector(vector);
}
private getPriceRange(price: number): string {
if (price < 25) return 'budget';
if (price < 100) return 'mid';
if (price < 500) return 'premium';
return 'luxury';
}
private normalizeVector(vector: number[]): number[] {
const magnitude = Math.sqrt(
vector.reduce((sum, val) => sum + val * val, 0)
);
if (magnitude === 0) return vector;
return vector.map(val => val / magnitude);
}
private buildIndexes(products: Product[]): void {
products.forEach(product => {
// Category index
if (!this.categoryIndex.has(product.category)) {
this.categoryIndex.set(product.category, new Set());
}
this.categoryIndex.get(product.category)!.add(product.id);
// Brand index
if (!this.brandIndex.has(product.brand)) {
this.brandIndex.set(product.brand, new Set());
}
this.brandIndex.get(product.brand)!.add(product.id);
});
}
/**
* Cosine similarity between two product vectors
*/
private cosineSimilarity(vectorA: number[], vectorB: number[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < vectorA.length; i++) {
dotProduct += vectorA[i] * vectorB[i];
normA += vectorA[i] * vectorA[i];
normB += vectorB[i] * vectorB[i];
}
if (normA === 0 || normB === 0) return 0;
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Find products similar to a given product
*/
public findSimilarProducts(
productId: string,
n: number = 10,
filters?: { category?: string; priceRange?: [number, number] }
): Recommendation[] {
const targetProduct = this.productVectors.get(productId);
if (!targetProduct) return [];
const similarities: Recommendation[] = [];
this.productVectors.forEach((productVector, otherId) => {
if (otherId === productId) return;
// Apply filters
if (filters?.category &&
productVector.metadata.category !== filters.category) {
return;
}
if (filters?.priceRange) {
const [min, max] = filters.priceRange;
const price = productVector.metadata.price;
if (price < min || price > max) return;
}
const similarity = this.cosineSimilarity(
targetProduct.vector,
productVector.vector
);
if (similarity > 0.1) {
similarities.push({
productId: otherId,
score: similarity,
explanation: this.generateExplanation(
targetProduct.metadata,
productVector.metadata
)
});
}
});
similarities.sort((a, b) => b.score - a.score);
return similarities.slice(0, n);
}
/**
* Build user preference profile from interaction history
*/
public buildUserProfile(
interactions: UserInteraction[],
products: Map<string, Product>
): number[] {
const profile = new Array(this.featureNames.length).fill(0);
let totalWeight = 0;
const interactionWeights: Record<string, number> = {
'view': 1,
'add_to_cart': 3,
'wishlist': 2,
'purchase': 5,
'review': 4
};
interactions.forEach(interaction => {
const product = products.get(interaction.productId);
if (!product) return;
const productVector = this.productVectors.get(interaction.productId);
if (!productVector) return;
const weight = interactionWeights[interaction.interactionType] || 1;
totalWeight += weight;
productVector.vector.forEach((val, index) => {
profile[index] += val * weight;
});
});
if (totalWeight > 0) {
return profile.map(val => val / totalWeight);
}
return profile;
}
/**
* Recommend products based on user profile
*/
public recommendFromProfile(
userProfile: number[],
n: number = 10,
excludeIds: Set<string> = new Set()
): Recommendation[] {
const recommendations: Recommendation[] = [];
this.productVectors.forEach((productVector, productId) => {
if (excludeIds.has(productId)) return;
const similarity = this.cosineSimilarity(userProfile, productVector.vector);
if (similarity > 0.1) {
recommendations.push({
productId,
score: similarity,
explanation: 'Matches your preferences'
});
}
});
recommendations.sort((a, b) => b.score - a.score);
return recommendations.slice(0, n);
}
private generateExplanation(source: Product, target: Product): string {
const reasons: string[] = [];
if (source.category === target.category) {
reasons.push(`Same category: ${source.category}`);
}
if (source.brand === target.brand) {
reasons.push(`Same brand: ${source.brand}`);
}
const commonTags = source.tags.filter(tag => target.tags.includes(tag));
if (commonTags.length > 0) {
reasons.push(`Similar: ${commonTags.slice(0, 2).join(', ')}`);
}
return reasons.length > 0 ? reasons[0] : 'Similar product';
}
}
Session-Based Recommendations for Anonymous Users
Session-based recommendations are crucial for e-commerce where up to 97% of visitors may be anonymous. This approach analyzes the current session's behavior to provide real-time recommendations:
// session-based-engine.ts
interface SessionEvent {
productId: string;
eventType: 'view' | 'add_to_cart' | 'click' | 'search';
timestamp: number;
searchQuery?: string;
}
interface SessionState {
sessionId: string;
events: SessionEvent[];
startTime: number;
lastActivity: number;
}
class SessionBasedEngine {
private cooccurrenceMatrix: Map<string, Map<string, number>> = new Map();
private transitionProbabilities: Map<string, Map<string, number>> = new Map();
private productPopularity: Map<string, number> = new Map();
private searchQueryIndex: Map<string, string[]> = new Map();
private readonly decayFactor: number = 0.9; // Recent items weighted more
private readonly sessionTimeout: number = 30 * 60 * 1000; // 30 minutes
constructor(historicalSessions: SessionState[]) {
this.buildModels(historicalSessions);
}
private buildModels(sessions: SessionState[]): void {
// Build co-occurrence matrix and transition probabilities
sessions.forEach(session => {
const viewedProducts = session.events
.filter(e => e.eventType === 'view')
.map(e => e.productId);
// Co-occurrence within session
for (let i = 0; i < viewedProducts.length; i++) {
const productA = viewedProducts[i];
// Update popularity
this.productPopularity.set(
productA,
(this.productPopularity.get(productA) || 0) + 1
);
for (let j = i + 1; j < viewedProducts.length; j++) {
const productB = viewedProducts[j];
this.incrementCooccurrence(productA, productB);
}
// Transition probabilities (sequential patterns)
if (i < viewedProducts.length - 1) {
const nextProduct = viewedProducts[i + 1];
this.incrementTransition(productA, nextProduct);
}
}
// Index search queries
session.events
.filter(e => e.eventType === 'search' && e.searchQuery)
.forEach(event => {
const query = event.searchQuery!.toLowerCase();
// Find next viewed product after search
const searchIndex = session.events.indexOf(event);
const nextView = session.events
.slice(searchIndex + 1)
.find(e => e.eventType === 'view');
if (nextView) {
if (!this.searchQueryIndex.has(query)) {
this.searchQueryIndex.set(query, []);
}
this.searchQueryIndex.get(query)!.push(nextView.productId);
}
});
});
// Normalize transition probabilities
this.normalizeTransitions();
}
private incrementCooccurrence(productA: string, productB: string): void {
// Ensure symmetric matrix
[productA, productB].forEach((p1, _, arr) => {
const p2 = arr[1 - _];
if (!this.cooccurrenceMatrix.has(p1)) {
this.cooccurrenceMatrix.set(p1, new Map());
}
const current = this.cooccurrenceMatrix.get(p1)!.get(p2) || 0;
this.cooccurrenceMatrix.get(p1)!.set(p2, current + 1);
});
}
private incrementTransition(from: string, to: string): void {
if (!this.transitionProbabilities.has(from)) {
this.transitionProbabilities.set(from, new Map());
}
const current = this.transitionProbabilities.get(from)!.get(to) || 0;
this.transitionProbabilities.get(from)!.set(to, current + 1);
}
private normalizeTransitions(): void {
this.transitionProbabilities.forEach((transitions, from) => {
const total = [...transitions.values()].reduce((a, b) => a + b, 0);
transitions.forEach((count, to) => {
transitions.set(to, count / total);
});
});
}
/**
* Get recommendations based on current session behavior
*/
public getSessionRecommendations(
currentSession: SessionState,
n: number = 10
): Recommendation[] {
const viewedProducts = currentSession.events
.filter(e => e.eventType === 'view')
.map(e => e.productId);
if (viewedProducts.length === 0) {
return this.getPopularityRecommendations(n);
}
const excludeSet = new Set(viewedProducts);
const scores: Map<string, number> = new Map();
// Weight recent items more heavily
const weightedProducts = viewedProducts.map((productId, index) => ({
productId,
weight: Math.pow(this.decayFactor, viewedProducts.length - 1 - index)
}));
// Co-occurrence based scoring
weightedProducts.forEach(({ productId, weight }) => {
const cooccurrences = this.cooccurrenceMatrix.get(productId);
if (cooccurrences) {
cooccurrences.forEach((count, relatedProduct) => {
if (!excludeSet.has(relatedProduct)) {
const currentScore = scores.get(relatedProduct) || 0;
scores.set(relatedProduct, currentScore + count * weight);
}
});
}
});
// Transition probability boost for most recent item
const lastViewed = viewedProducts[viewedProducts.length - 1];
const transitions = this.transitionProbabilities.get(lastViewed);
if (transitions) {
transitions.forEach((probability, nextProduct) => {
if (!excludeSet.has(nextProduct)) {
const currentScore = scores.get(nextProduct) || 0;
// Boost transition-based recommendations
scores.set(nextProduct, currentScore + probability * 2);
}
});
}
// Cart items boost (if user added to cart, recommend complementary)
const cartItems = currentSession.events
.filter(e => e.eventType === 'add_to_cart')
.map(e => e.productId);
cartItems.forEach(cartProduct => {
const cooccurrences = this.cooccurrenceMatrix.get(cartProduct);
if (cooccurrences) {
cooccurrences.forEach((count, relatedProduct) => {
if (!excludeSet.has(relatedProduct) &&
!cartItems.includes(relatedProduct)) {
const currentScore = scores.get(relatedProduct) || 0;
scores.set(relatedProduct, currentScore + count * 1.5);
}
});
}
});
const recommendations: Recommendation[] = [...scores.entries()]
.map(([productId, score]) => ({
productId,
score,
explanation: this.generateSessionExplanation(
productId,
viewedProducts,
cartItems
)
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
return recommendations;
}
/**
* Recommend based on search query
*/
public getSearchBasedRecommendations(
query: string,
n: number = 10
): Recommendation[] {
const normalizedQuery = query.toLowerCase();
const directMatches = this.searchQueryIndex.get(normalizedQuery) || [];
if (directMatches.length > 0) {
// Count frequency of products for this query
const productCounts = new Map<string, number>();
directMatches.forEach(productId => {
productCounts.set(
productId,
(productCounts.get(productId) || 0) + 1
);
});
return [...productCounts.entries()]
.map(([productId, count]) => ({
productId,
score: count,
explanation: `Popular result for "${query}"`
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
}
// Fallback: fuzzy match on query words
const queryWords = normalizedQuery.split(/\s+/);
const matchedProducts = new Map<string, number>();
this.searchQueryIndex.forEach((products, indexedQuery) => {
const indexedWords = indexedQuery.split(/\s+/);
const commonWords = queryWords.filter(w =>
indexedWords.some(iw => iw.includes(w) || w.includes(iw))
);
if (commonWords.length > 0) {
const matchScore = commonWords.length / queryWords.length;
products.forEach(productId => {
const current = matchedProducts.get(productId) || 0;
matchedProducts.set(productId, current + matchScore);
});
}
});
return [...matchedProducts.entries()]
.map(([productId, score]) => ({
productId,
score,
explanation: `Related to "${query}"`
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
}
private getPopularityRecommendations(n: number): Recommendation[] {
return [...this.productPopularity.entries()]
.map(([productId, popularity]) => ({
productId,
score: popularity,
explanation: 'Popular product'
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
}
private generateSessionExplanation(
productId: string,
viewedProducts: string[],
cartItems: string[]
): string {
if (cartItems.length > 0) {
return 'Complements items in your cart';
}
if (viewedProducts.length > 0) {
return 'Based on your browsing';
}
return 'Recommended for you';
}
}
Solving the Cold-Start Problem
The cold-start problem occurs when we lack sufficient data to make personalized recommendations for new users or new products. Here's a comprehensive strategy to address this challenge:
// cold-start-handler.ts
interface ColdStartStrategy {
type: 'popularity' | 'content' | 'demographic' | 'contextual' | 'hybrid';
priority: number;
condition: (context: RecommendationContext) => boolean;
}
interface RecommendationContext {
userId?: string;
sessionId: string;
userInteractionCount: number;
productId?: string;
productAge?: number; // Days since product added
device: 'mobile' | 'tablet' | 'desktop';
referrer?: string;
timeOfDay: number;
dayOfWeek: number;
location?: { country: string; region: string };
}
class ColdStartHandler {
private popularProducts: string[] = [];
private trendingProducts: string[] = [];
private categoryTrends: Map<string, string[]> = new Map();
private demographicPreferences: Map<string, string[]> = new Map();
private contextualRules: Map<string, string[]> = new Map();
private readonly strategies: ColdStartStrategy[] = [
{
type: 'contextual',
priority: 1,
condition: (ctx) => !!ctx.referrer || !!ctx.location
},
{
type: 'demographic',
priority: 2,
condition: (ctx) => !!ctx.location
},
{
type: 'popularity',
priority: 3,
condition: () => true // Always available
}
];
constructor(
products: Product[],
interactions: UserInteraction[],
config?: { trendingWindow?: number; popularityThreshold?: number }
) {
this.buildPopularityData(products, interactions, config);
this.buildContextualRules(interactions);
}
private buildPopularityData(
products: Product[],
interactions: UserInteraction[],
config?: { trendingWindow?: number; popularityThreshold?: number }
): void {
const trendingWindow = config?.trendingWindow || 7 * 24 * 60 * 60 * 1000; // 7 days
const now = Date.now();
// Calculate popularity scores
const popularityScores = new Map<string, number>();
const trendingScores = new Map<string, number>();
const categoryScores = new Map<string, Map<string, number>>();
interactions.forEach(interaction => {
const weight = interaction.interactionType === 'purchase' ? 5 :
interaction.interactionType === 'add_to_cart' ? 3 : 1;
// Overall popularity
popularityScores.set(
interaction.productId,
(popularityScores.get(interaction.productId) || 0) + weight
);
// Trending (recent interactions)
if (now - interaction.timestamp < trendingWindow) {
const recencyBoost = 1 + (trendingWindow - (now - interaction.timestamp)) /
trendingWindow;
trendingScores.set(
interaction.productId,
(trendingScores.get(interaction.productId) || 0) + weight * recencyBoost
);
}
});
// Category-wise popularity
products.forEach(product => {
const score = popularityScores.get(product.id) || 0;
if (!categoryScores.has(product.category)) {
categoryScores.set(product.category, new Map());
}
categoryScores.get(product.category)!.set(product.id, score);
});
// Sort and store
this.popularProducts = [...popularityScores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 100)
.map(([id]) => id);
this.trendingProducts = [...trendingScores.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([id]) => id);
categoryScores.forEach((products, category) => {
this.categoryTrends.set(
category,
[...products.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.map(([id]) => id)
);
});
}
private buildContextualRules(interactions: UserInteraction[]): void {
// Device-based preferences
const devicePreferences = new Map<string, Map<string, number>>();
// Time-based preferences
const timePreferences = new Map<string, Map<string, number>>();
interactions.forEach(interaction => {
const { device, hourOfDay } = interaction.context;
// Device preferences
if (!devicePreferences.has(device)) {
devicePreferences.set(device, new Map());
}
const deviceProducts = devicePreferences.get(device)!;
deviceProducts.set(
interaction.productId,
(deviceProducts.get(interaction.productId) || 0) + 1
);
// Time of day preferences (morning, afternoon, evening, night)
const timeSlot = hourOfDay < 6 ? 'night' :
hourOfDay < 12 ? 'morning' :
hourOfDay < 18 ? 'afternoon' : 'evening';
if (!timePreferences.has(timeSlot)) {
timePreferences.set(timeSlot, new Map());
}
const timeProducts = timePreferences.get(timeSlot)!;
timeProducts.set(
interaction.productId,
(timeProducts.get(interaction.productId) || 0) + 1
);
});
// Store top products per context
devicePreferences.forEach((products, device) => {
this.contextualRules.set(
`device:${device}`,
[...products.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 30)
.map(([id]) => id)
);
});
timePreferences.forEach((products, timeSlot) => {
this.contextualRules.set(
`time:${timeSlot}`,
[...products.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 30)
.map(([id]) => id)
);
});
}
/**
* Get recommendations for cold-start scenarios
*/
public getRecommendations(
context: RecommendationContext,
n: number = 10
): Recommendation[] {
const candidates: Map<string, { score: number; source: string }> = new Map();
// Apply strategies in priority order
this.strategies
.filter(strategy => strategy.condition(context))
.sort((a, b) => a.priority - b.priority)
.forEach(strategy => {
const strategyResults = this.applyStrategy(strategy.type, context);
strategyResults.forEach((productId, index) => {
if (!candidates.has(productId)) {
candidates.set(productId, {
score: (strategyResults.length - index) / strategyResults.length,
source: strategy.type
});
}
});
});
// Add trending products with decay
this.trendingProducts.slice(0, 20).forEach((productId, index) => {
const existing = candidates.get(productId);
if (existing) {
existing.score += 0.3 * (20 - index) / 20;
} else {
candidates.set(productId, {
score: 0.3 * (20 - index) / 20,
source: 'trending'
});
}
});
return [...candidates.entries()]
.map(([productId, { score, source }]) => ({
productId,
score,
explanation: this.getExplanation(source)
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
}
private applyStrategy(
type: string,
context: RecommendationContext
): string[] {
switch (type) {
case 'contextual':
return this.getContextualRecommendations(context);
case 'demographic':
return this.getDemographicRecommendations(context);
case 'popularity':
default:
return this.popularProducts;
}
}
private getContextualRecommendations(context: RecommendationContext): string[] {
const results: string[] = [];
// Device-based
const deviceKey = `device:${context.device}`;
if (this.contextualRules.has(deviceKey)) {
results.push(...this.contextualRules.get(deviceKey)!);
}
// Time-based
const timeSlot = context.timeOfDay < 6 ? 'night' :
context.timeOfDay < 12 ? 'morning' :
context.timeOfDay < 18 ? 'afternoon' : 'evening';
const timeKey = `time:${timeSlot}`;
if (this.contextualRules.has(timeKey)) {
results.push(...this.contextualRules.get(timeKey)!);
}
return [...new Set(results)]; // Deduplicate
}
private getDemographicRecommendations(context: RecommendationContext): string[] {
if (!context.location) return [];
const key = `${context.location.country}:${context.location.region}`;
return this.demographicPreferences.get(key) ||
this.demographicPreferences.get(context.location.country) ||
[];
}
private getExplanation(source: string): string {
const explanations: Record<string, string> = {
'contextual': 'Recommended for you',
'demographic': 'Popular in your area',
'popularity': 'Best seller',
'trending': 'Trending now'
};
return explanations[source] || 'You might like';
}
/**
* Handle new product cold-start
*/
public getNewProductRecommendationSlots(
product: Product,
contentEngine: ContentBasedEngine
): string[] {
// Use content-based similarity to find placement opportunities
const similarProducts = contentEngine.findSimilarProducts(product.id, 20);
// Recommend new product alongside similar popular products
return similarProducts
.filter(rec => this.popularProducts.includes(rec.productId))
.slice(0, 5)
.map(rec => rec.productId);
}
}
Building a Hybrid Recommendation System
The most effective recommendation systems combine multiple approaches. Here's a complete hybrid system that orchestrates collaborative, content-based, and session-based recommendations:
// hybrid-recommendation-engine.ts
interface HybridConfig {
collaborativeWeight: number;
contentWeight: number;
sessionWeight: number;
popularityWeight: number;
diversityFactor: number; // 0-1, higher = more diverse
}
interface RankedRecommendation extends Recommendation {
sources: string[];
diversity: number;
}
class HybridRecommendationEngine {
private collaborativeEngine: CollaborativeFilteringEngine;
private contentEngine: ContentBasedEngine;
private sessionEngine: SessionBasedEngine;
private coldStartHandler: ColdStartHandler;
private config: HybridConfig = {
collaborativeWeight: 0.4,
contentWeight: 0.25,
sessionWeight: 0.25,
popularityWeight: 0.1,
diversityFactor: 0.3
};
constructor(
interactions: UserInteraction[],
products: Product[],
sessions: SessionState[],
config?: Partial<HybridConfig>
) {
this.collaborativeEngine = new CollaborativeFilteringEngine(interactions);
this.contentEngine = new ContentBasedEngine(products);
this.sessionEngine = new SessionBasedEngine(sessions);
this.coldStartHandler = new ColdStartHandler(products, interactions);
if (config) {
this.config = { ...this.config, ...config };
}
}
/**
* Get personalized recommendations combining all approaches
*/
public async getRecommendations(
context: RecommendationContext,
currentSession: SessionState,
n: number = 10
): Promise<RankedRecommendation[]> {
const candidates: Map<string, {
scores: Map<string, number>;
explanations: string[];
}> = new Map();
// Check for cold-start scenarios
const isColdStartUser = context.userInteractionCount < 5;
const hasSessionData = currentSession.events.length > 0;
// 1. Collaborative filtering (if user has history)
if (!isColdStartUser && context.userId) {
const collaborativeRecs = this.collaborativeEngine.recommendForUser(
context.userId,
n * 2
);
this.addCandidates(
candidates,
collaborativeRecs,
'collaborative',
this.config.collaborativeWeight
);
}
// 2. Content-based (if user has any interactions)
if (context.userId && context.userInteractionCount > 0) {
// Build profile from user's interaction history
// This would typically come from a user service
const userProfile = await this.getUserProfile(context.userId);
if (userProfile) {
const contentRecs = this.contentEngine.recommendFromProfile(
userProfile,
n * 2
);
this.addCandidates(
candidates,
contentRecs,
'content',
this.config.contentWeight
);
}
}
// 3. Session-based (always applicable)
if (hasSessionData) {
const sessionRecs = this.sessionEngine.getSessionRecommendations(
currentSession,
n * 2
);
this.addCandidates(
candidates,
sessionRecs,
'session',
this.config.sessionWeight
);
}
// 4. Cold-start handling (for new users or sparse data)
if (isColdStartUser || candidates.size < n) {
const coldStartRecs = this.coldStartHandler.getRecommendations(
context,
n * 2
);
this.addCandidates(
candidates,
coldStartRecs,
'coldstart',
isColdStartUser ? 0.5 : this.config.popularityWeight
);
}
// Combine and rank
const rankedRecommendations = this.rankCandidates(candidates, n);
// Apply diversity re-ranking
return this.applyDiversity(rankedRecommendations, n);
}
private addCandidates(
candidates: Map<string, { scores: Map<string, number>; explanations: string[] }>,
recommendations: Recommendation[],
source: string,
weight: number
): void {
recommendations.forEach(rec => {
if (!candidates.has(rec.productId)) {
candidates.set(rec.productId, {
scores: new Map(),
explanations: []
});
}
const candidate = candidates.get(rec.productId)!;
candidate.scores.set(source, rec.score * weight);
candidate.explanations.push(rec.explanation);
});
}
private rankCandidates(
candidates: Map<string, { scores: Map<string, number>; explanations: string[] }>,
n: number
): RankedRecommendation[] {
const ranked: RankedRecommendation[] = [];
candidates.forEach((data, productId) => {
const totalScore = [...data.scores.values()].reduce((a, b) => a + b, 0);
const sources = [...data.scores.keys()];
ranked.push({
productId,
score: totalScore,
explanation: this.selectBestExplanation(data.explanations, sources),
sources,
diversity: 0 // Will be calculated in diversity step
});
});
ranked.sort((a, b) => b.score - a.score);
return ranked.slice(0, n * 2); // Keep extra for diversity filtering
}
/**
* Apply diversity re-ranking using Maximal Marginal Relevance (MMR)
*/
private async applyDiversity(
recommendations: RankedRecommendation[],
n: number
): Promise<RankedRecommendation[]> {
if (recommendations.length === 0) return [];
const lambda = 1 - this.config.diversityFactor;
const selected: RankedRecommendation[] = [];
const remaining = [...recommendations];
// Select first item (highest score)
selected.push(remaining.shift()!);
while (selected.length < n && remaining.length > 0) {
let bestScore = -Infinity;
let bestIndex = 0;
remaining.forEach((candidate, index) => {
// Calculate similarity to already selected items
const maxSimilarity = selected.reduce((maxSim, selectedItem) => {
const sim = this.calculateDiversitySimilarity(
candidate.productId,
selectedItem.productId
);
return Math.max(maxSim, sim);
}, 0);
// MMR score: lambda * relevance - (1-lambda) * max_similarity
const mmrScore = lambda * candidate.score -
(1 - lambda) * maxSimilarity;
if (mmrScore > bestScore) {
bestScore = mmrScore;
bestIndex = index;
}
});
const selected_item = remaining.splice(bestIndex, 1)[0];
selected_item.diversity = 1 - (bestScore / selected_item.score);
selected.push(selected_item);
}
return selected;
}
private calculateDiversitySimilarity(
productA: string,
productB: string
): number {
// Use content-based similarity for diversity calculation
const similarItems = this.contentEngine.findSimilarProducts(productA, 50);
const match = similarItems.find(item => item.productId === productB);
return match ? match.score : 0;
}
private selectBestExplanation(
explanations: string[],
sources: string[]
): string {
// Prioritize more specific explanations
const priorityOrder = ['collaborative', 'content', 'session', 'coldstart'];
for (const source of priorityOrder) {
const index = sources.indexOf(source);
if (index !== -1 && explanations[index]) {
return explanations[index];
}
}
return explanations[0] || 'Recommended for you';
}
private async getUserProfile(userId: string): Promise<number[] | null> {
// In production, this would fetch from a user profile service
// For now, return null to skip content-based if profile not available
return null;
}
/**
* Get cross-sell recommendations for cart
*/
public getCrossSellRecommendations(
cartItems: string[],
n: number = 4
): Recommendation[] {
// Combine item-based collaborative filtering with content-based
const collaborativeRecs = this.collaborativeEngine.recommendSimilarItems(
cartItems,
n * 2
);
const contentRecs: Recommendation[] = [];
cartItems.forEach(itemId => {
const similar = this.contentEngine.findSimilarProducts(itemId, 5, {
// Different category for cross-sell
});
contentRecs.push(...similar);
});
// Merge and deduplicate
const combined = new Map<string, number>();
collaborativeRecs.forEach(rec => {
combined.set(rec.productId, (combined.get(rec.productId) || 0) + rec.score);
});
contentRecs.forEach(rec => {
combined.set(rec.productId, (combined.get(rec.productId) || 0) + rec.score * 0.5);
});
// Remove cart items from recommendations
cartItems.forEach(id => combined.delete(id));
return [...combined.entries()]
.map(([productId, score]) => ({
productId,
score,
explanation: 'Frequently bought together'
}))
.sort((a, b) => b.score - a.score)
.slice(0, n);
}
}
A/B Testing and Measuring Impact
To achieve the 25%+ increase in average order value that leading e-commerce companies report, you need rigorous A/B testing. Here's a framework for testing recommendation algorithms:
// recommendation-ab-testing.ts
interface ABTestConfig {
testId: string;
name: string;
variants: {
id: string;
name: string;
config: Partial<HybridConfig>;
weight: number; // Traffic allocation %
}[];
metrics: MetricDefinition[];
minSampleSize: number;
confidenceLevel: number; // e.g., 0.95
startDate: Date;
endDate?: Date;
}
interface MetricDefinition {
name: string;
type: 'conversion' | 'revenue' | 'engagement';
aggregation: 'sum' | 'mean' | 'rate';
}
interface TestResults {
testId: string;
variantResults: {
variantId: string;
metrics: Record<string, number>;
sampleSize: number;
confidence: number;
}[];
winner?: string;
isSignificant: boolean;
}
class RecommendationABTesting {
private activeTests: Map<string, ABTestConfig> = new Map();
private userAssignments: Map<string, Map<string, string>> = new Map();
private eventLog: any[] = [];
/**
* Create a new A/B test for recommendation algorithms
*/
public createTest(config: ABTestConfig): void {
// Validate weights sum to 100
const totalWeight = config.variants.reduce((sum, v) => sum + v.weight, 0);
if (Math.abs(totalWeight - 100) > 0.01) {
throw new Error('Variant weights must sum to 100');
}
this.activeTests.set(config.testId, config);
}
/**
* Get variant assignment for a user
*/
public getVariantAssignment(
userId: string,
testId: string
): { variantId: string; config: Partial<HybridConfig> } | null {
const test = this.activeTests.get(testId);
if (!test) return null;
// Check existing assignment
if (this.userAssignments.has(userId)) {
const assignments = this.userAssignments.get(userId)!;
if (assignments.has(testId)) {
const variantId = assignments.get(testId)!;
const variant = test.variants.find(v => v.id === variantId);
return variant ? { variantId, config: variant.config } : null;
}
}
// Deterministic assignment based on user ID hash
const hash = this.hashString(`${userId}-${testId}`);
const bucket = hash % 100;
let cumulativeWeight = 0;
for (const variant of test.variants) {
cumulativeWeight += variant.weight;
if (bucket < cumulativeWeight) {
// Store assignment
if (!this.userAssignments.has(userId)) {
this.userAssignments.set(userId, new Map());
}
this.userAssignments.get(userId)!.set(testId, variant.id);
// Log exposure event
this.logEvent({
type: 'ab_exposure',
testId,
variantId: variant.id,
userId,
timestamp: Date.now()
});
return { variantId: variant.id, config: variant.config };
}
}
return null;
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
/**
* Track conversion/engagement events
*/
public trackEvent(
userId: string,
eventType: string,
data: Record<string, any>
): void {
const userTests = this.userAssignments.get(userId);
if (!userTests) return;
userTests.forEach((variantId, testId) => {
this.logEvent({
type: eventType,
testId,
variantId,
userId,
timestamp: Date.now(),
...data
});
});
}
private logEvent(event: any): void {
this.eventLog.push(event);
// In production, send to analytics service
}
/**
* Calculate test results with statistical significance
*/
public analyzeTest(testId: string): TestResults {
const test = this.activeTests.get(testId);
if (!test) {
throw new Error(`Test ${testId} not found`);
}
const variantResults = test.variants.map(variant => {
const metrics: Record<string, number> = {};
test.metrics.forEach(metric => {
metrics[metric.name] = this.calculateMetric(
testId,
variant.id,
metric
);
});
const sampleSize = this.getSampleSize(testId, variant.id);
return {
variantId: variant.id,
metrics,
sampleSize,
confidence: 0 // Will be calculated
};
});
// Calculate statistical significance
const controlVariant = variantResults[0];
variantResults.slice(1).forEach(variant => {
// Using primary metric (first defined)
const primaryMetric = test.metrics[0].name;
variant.confidence = this.calculateSignificance(
controlVariant.metrics[primaryMetric],
controlVariant.sampleSize,
variant.metrics[primaryMetric],
variant.sampleSize
);
});
// Determine winner
const primaryMetric = test.metrics[0].name;
let winner: string | undefined;
let maxImprovement = 0;
variantResults.slice(1).forEach(variant => {
if (variant.confidence >= test.confidenceLevel) {
const improvement = (variant.metrics[primaryMetric] -
controlVariant.metrics[primaryMetric]) /
controlVariant.metrics[primaryMetric];
if (improvement > maxImprovement) {
maxImprovement = improvement;
winner = variant.variantId;
}
}
});
return {
testId,
variantResults,
winner,
isSignificant: winner !== undefined
};
}
private calculateMetric(
testId: string,
variantId: string,
metric: MetricDefinition
): number {
const events = this.eventLog.filter(e =>
e.testId === testId && e.variantId === variantId
);
const exposures = events.filter(e => e.type === 'ab_exposure');
const conversions = events.filter(e =>
e.type === 'purchase' || e.type === 'recommendation_click'
);
switch (metric.type) {
case 'conversion':
const uniqueConverters = new Set(conversions.map(e => e.userId));
const uniqueExposed = new Set(exposures.map(e => e.userId));
return uniqueConverters.size / uniqueExposed.size;
case 'revenue':
const totalRevenue = conversions.reduce(
(sum, e) => sum + (e.orderValue || 0), 0
);
return metric.aggregation === 'mean' ?
totalRevenue / exposures.length :
totalRevenue;
case 'engagement':
const clicks = events.filter(e => e.type === 'recommendation_click');
return clicks.length / exposures.length;
default:
return 0;
}
}
private getSampleSize(testId: string, variantId: string): number {
const exposures = this.eventLog.filter(e =>
e.testId === testId &&
e.variantId === variantId &&
e.type === 'ab_exposure'
);
return new Set(exposures.map(e => e.userId)).size;
}
private calculateSignificance(
controlMean: number,
controlN: number,
treatmentMean: number,
treatmentN: number
): number {
// Z-test for proportions
const pooledProp = (controlMean * controlN + treatmentMean * treatmentN) /
(controlN + treatmentN);
const standardError = Math.sqrt(
pooledProp * (1 - pooledProp) * (1/controlN + 1/treatmentN)
);
if (standardError === 0) return 0;
const zScore = (treatmentMean - controlMean) / standardError;
// Two-tailed p-value to confidence
const pValue = 2 * (1 - this.normalCDF(Math.abs(zScore)));
return 1 - pValue;
}
private normalCDF(x: number): number {
const a1 = 0.254829592;
const a2 = -0.284496736;
const a3 = 1.421413741;
const a4 = -1.453152027;
const a5 = 1.061405429;
const p = 0.3275911;
const sign = x < 0 ? -1 : 1;
x = Math.abs(x) / Math.sqrt(2);
const t = 1.0 / (1.0 + p * x);
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) *
t * Math.exp(-x * x);
return 0.5 * (1.0 + sign * y);
}
}
Real-Time vs Batch Processing Architecture
Production recommendation systems need both real-time updates for immediate responsiveness and batch processing for model training. Here's an architecture that balances both:
// recommendation-architecture.ts
interface RecommendationArchitecture {
realTime: RealTimeComponents;
batch: BatchComponents;
storage: StorageComponents;
}
interface RealTimeComponents {
sessionTracker: SessionBasedEngine;
eventStream: EventProcessor;
cache: RecommendationCache;
}
interface BatchComponents {
collaborativeModel: CollaborativeFilteringEngine;
contentModel: ContentBasedEngine;
trainingSchedule: string; // cron expression
}
// Real-time event processor
class EventProcessor {
private eventQueue: UserInteraction[] = [];
private flushInterval: number = 5000;
private batchSize: number = 100;
constructor(
private sessionEngine: SessionBasedEngine,
private cache: RecommendationCache
) {
this.startFlushTimer();
}
public async processEvent(event: UserInteraction): Promise<void> {
// Immediate cache invalidation for user
await this.cache.invalidateUser(event.userId);
// Update session state
await this.updateSessionState(event);
// Queue for batch processing
this.eventQueue.push(event);
if (this.eventQueue.length >= this.batchSize) {
await this.flushQueue();
}
}
private async updateSessionState(event: UserInteraction): Promise<void> {
// Update session-based model in real-time
// This enables immediate adaptation to user behavior
}
private async flushQueue(): Promise<void> {
const events = [...this.eventQueue];
this.eventQueue = [];
// Send to batch processing pipeline
await this.sendToBatchPipeline(events);
}
private startFlushTimer(): void {
setInterval(() => this.flushQueue(), this.flushInterval);
}
private async sendToBatchPipeline(events: UserInteraction[]): Promise<void> {
// In production: send to Kafka/Kinesis for batch processing
}
}
// Recommendation cache with TTL and invalidation
class RecommendationCache {
private cache: Map<string, {
recommendations: Recommendation[];
timestamp: number;
ttl: number;
}> = new Map();
private readonly defaultTTL: number = 5 * 60 * 1000; // 5 minutes
public async get(
key: string
): Promise<Recommendation[] | null> {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.recommendations;
}
public async set(
key: string,
recommendations: Recommendation[],
ttl?: number
): Promise<void> {
this.cache.set(key, {
recommendations,
timestamp: Date.now(),
ttl: ttl || this.defaultTTL
});
}
public async invalidateUser(userId: string): Promise<void> {
// Invalidate all cache entries for this user
const keysToDelete: string[] = [];
this.cache.forEach((_, key) => {
if (key.startsWith(`user:${userId}`)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach(key => this.cache.delete(key));
}
public async invalidateProduct(productId: string): Promise<void> {
// Invalidate caches containing this product
const keysToDelete: string[] = [];
this.cache.forEach((entry, key) => {
if (entry.recommendations.some(r => r.productId === productId)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach(key => this.cache.delete(key));
}
}
// Complete API for recommendation service
class RecommendationService {
private hybridEngine: HybridRecommendationEngine;
private cache: RecommendationCache;
private abTesting: RecommendationABTesting;
constructor(
hybridEngine: HybridRecommendationEngine,
cache: RecommendationCache,
abTesting: RecommendationABTesting
) {
this.hybridEngine = hybridEngine;
this.cache = cache;
this.abTesting = abTesting;
}
public async getHomePageRecommendations(
userId: string,
sessionId: string,
context: RecommendationContext
): Promise<{ section: string; recommendations: Recommendation[] }[]> {
// Check A/B test assignment
const testAssignment = this.abTesting.getVariantAssignment(
userId,
'homepage-recs-v2'
);
const sections = [
{
section: 'Recommended for You',
cacheKey: `user:${userId}:personalized`,
n: 12
},
{
section: 'Trending Now',
cacheKey: 'global:trending',
n: 8
},
{
section: 'Recently Viewed',
cacheKey: `user:${userId}:recent`,
n: 6
}
];
const results = await Promise.all(
sections.map(async ({ section, cacheKey, n }) => {
// Check cache
let recommendations = await this.cache.get(cacheKey);
if (!recommendations) {
recommendations = await this.generateRecommendations(
userId,
sessionId,
context,
n,
testAssignment?.config
);
await this.cache.set(cacheKey, recommendations);
}
return { section, recommendations };
})
);
// Track impression
this.abTesting.trackEvent(userId, 'recommendation_impression', {
page: 'home',
sections: sections.map(s => s.section)
});
return results;
}
private async generateRecommendations(
userId: string,
sessionId: string,
context: RecommendationContext,
n: number,
overrideConfig?: Partial<HybridConfig>
): Promise<Recommendation[]> {
const currentSession: SessionState = {
sessionId,
events: [], // Would be populated from session store
startTime: Date.now(),
lastActivity: Date.now()
};
const recommendations = await this.hybridEngine.getRecommendations(
context,
currentSession,
n
);
return recommendations;
}
}
Key Takeaways
Remember These Points
- Hybrid systems outperform single approaches: Combine collaborative, content-based, and session-based methods for the best results
- Session-based recommendations are essential: With 97% of e-commerce visitors being anonymous, real-time session analysis is critical
- Solve cold-start proactively: Use popularity, contextual signals, and content-based methods for new users and products
- Diversity matters: Apply MMR or similar algorithms to avoid filter bubbles and improve discovery
- A/B test everything: Measure business metrics like AOV and revenue per visitor, not just CTR
- Balance real-time and batch: Use real-time for session updates and cache invalidation, batch for model training
- Cache aggressively: Recommendations are expensive to compute but change slowly for most users
Conclusion
Building an effective e-commerce recommendation system requires combining multiple algorithmic approaches with solid engineering practices. The implementation patterns in this guide provide a foundation for achieving the 25%+ increase in average order value that industry leaders report.
Start with collaborative filtering for proven results, add content-based methods to handle new products, implement session-based recommendations for anonymous users, and continuously optimize through A/B testing. According to Barilliance research, personalized product recommendations can increase conversion rates by up to 320%.
For further learning, explore Google's Recommendation Systems course, the Neural Collaborative Filtering paper, and Dive into Deep Learning's recommender systems chapter. Tools like Surprise for Python prototyping and Amazon Personalize for managed solutions can accelerate your implementation.