Building Content-Based Recommendation Engines
A new platform with video tutorials struggles with user retention: 80% churn in the first week. We solve this with a content-based recommendation system that doesn't require other users' data and delivers relevant recommendations after just 3–5 interactions. Our experience: over 50 deployed recommender systems for e-commerce, media, and EdTech. Our engineers are certified in MLOps and NLP, ensuring quality at every stage.
How a Content-Based Recommendation System Solves Cold Start
Content-based filtering builds a user profile solely from the attributes of items the user has interacted with. The method uses semantic text embeddings, TF-IDF, categorical and numerical features, combining them into a unified multimodal profile. Precision@10 on new users reaches 0.15–0.30, which is 10–30 times better than the popular popularity baseline (0.01–0.03).
Architecture of the Content-Based Recommendation System
Multimodal Content Profile: Step-by-Step Approach — Building the Recommendation Engine
- Data collection: item_id, title, description, categories, tags, price, rating, and other numeric features.
- Text vectorization: we use multilingual sentence-transformers (
paraphrase-multilingual-mpnet-base-v2), obtaining 768-dimensional embeddings. - TF-IDF features: additionally extract 5000 n-grams (1–2), compress to 50 components via SVD.
- Categorical features: binarize using MultiLabelBinarizer.
- Numerical features: StandardScaler for price, rating, review_count, release_year.
- Weighted concatenation: text (weight 2), TF-IDF (0.5), categories (1.0), numbers (0.3). Result is L2-normalized.
Click to expand code
import numpy as np
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.preprocessing import MultiLabelBinarizer, StandardScaler
from sklearn.metrics.pairwise import cosine_similarity
import torch
from sentence_transformers import SentenceTransformer
class ContentBasedRecommender:
def __init__(self):
self.text_model = SentenceTransformer('paraphrase-multilingual-mpnet-base-v2')
self.tfidf = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
self.mlb = MultiLabelBinarizer()
self.scaler = StandardScaler()
self.item_vectors = None
self.item_ids = []
def build_item_profiles(self, items_df: pd.DataFrame) -> np.ndarray:
"""
items_df: item_id, title, description, category (list), tags (list),
price, rating, release_year, ...
"""
feature_parts = []
# 1. Semantic text embeddings (description + title)
texts = (
items_df['title'].fillna('') + '. ' +
items_df.get('description', pd.Series([''] * len(items_df))).fillna('')
).tolist()
print(f"Encoding {len(texts)} items...")
text_embeddings = self.text_model.encode(
texts, batch_size=64, show_progress_bar=True,
convert_to_numpy=True, normalize_embeddings=True
)
feature_parts.append(text_embeddings)
# 2. TF-IDF features from text
tfidf_features = self.tfidf.fit_transform(texts).toarray()
# PCA for compression
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=50)
tfidf_compact = svd.fit_transform(tfidf_features)
feature_parts.append(tfidf_compact)
# 3. Categorical features
if 'categories' in items_df.columns:
cat_features = self.mlb.fit_transform(
items_df['categories'].apply(lambda x: x if isinstance(x, list) else [])
)
feature_parts.append(cat_features.astype(float))
# 4. Numerical features
num_cols = ['price', 'rating', 'review_count', 'release_year']
available_num = [c for c in num_cols if c in items_df.columns]
if available_num:
num_features = self.scaler.fit_transform(
items_df[available_num].fillna(items_df[available_num].median())
)
feature_parts.append(num_features)
# Weighted concatenation
weights = [2.0, 0.5, 1.0, 0.3][:len(feature_parts)]
weighted_parts = [p * w for p, w in zip(feature_parts, weights)]
combined = np.hstack(weighted_parts)
# L2 normalization
norms = np.linalg.norm(combined, axis=1, keepdims=True)
self.item_vectors = combined / (norms + 1e-10)
self.item_ids = items_df['item_id'].tolist()
return self.item_vectors
def build_user_profile(self, liked_items: list[str],
weights: list[float] = None) -> np.ndarray:
"""User profile as weighted average of liked items"""
item_indices = [
self.item_ids.index(item_id)
for item_id in liked_items
if item_id in self.item_ids
]
if not item_indices:
return None
liked_vectors = self.item_vectors[item_indices]
if weights is not None and len(weights) == len(item_indices):
w = np.array(weights[:len(item_indices)]).reshape(-1, 1)
user_vector = np.average(liked_vectors, axis=0, weights=w.flatten())
else:
# Recent interactions have higher weight
recency_weights = np.exp(np.linspace(-1, 0, len(item_indices)))
user_vector = np.average(liked_vectors, axis=0, weights=recency_weights)
norm = np.linalg.norm(user_vector)
return user_vector / (norm + 1e-10)
def recommend(self, user_profile: np.ndarray,
exclude_items: list[str] = None,
n: int = 10,
diversity_penalty: float = 0.1) -> list[tuple]:
"""Recommendations with MMR diversity penalty"""
if user_profile is None:
return []
# Base scores
scores = cosine_similarity(
user_profile.reshape(1, -1), self.item_vectors
)[0]
# Exclude already seen items
if exclude_items:
for item_id in exclude_items:
if item_id in self.item_ids:
idx = self.item_ids.index(item_id)
scores[idx] = -1
# MMR (Maximal Marginal Relevance) for diversity
selected_indices = []
selected_embeddings = []
while len(selected_indices) < n:
if not selected_embeddings:
# First: most relevant
best_idx = np.argmax(scores)
else:
# Subsequent: relevance minus similarity penalty to already chosen
selected_matrix = np.vstack(selected_embeddings)
similarity_to_selected = cosine_similarity(
self.item_vectors, selected_matrix
).max(axis=1)
adjusted_scores = scores - diversity_penalty * similarity_to_selected
# Mask already selected
for idx in selected_indices:
adjusted_scores[idx] = -1
best_idx = np.argmax(adjusted_scores)
if scores[best_idx] < 0:
break
selected_indices.append(best_idx)
selected_embeddings.append(self.item_vectors[best_idx])
return [
(self.item_ids[idx], float(scores[idx]))
for idx in selected_indices
]
def item_to_item(self, item_id: str, n: int = 10) -> list[tuple]:
"""Similar items for product page"""
if item_id not in self.item_ids:
return []
item_idx = self.item_ids.index(item_id)
item_vector = self.item_vectors[item_idx]
scores = cosine_similarity(
item_vector.reshape(1, -1), self.item_vectors
)[0]
scores[item_idx] = -1 # Exclude itself
top_indices = np.argsort(scores)[-n:][::-1]
return [(self.item_ids[idx], float(scores[idx])) for idx in top_indices]
Real-Time Profile Update
Click to expand code
class OnlineUserProfileUpdater:
"""Incremental profile update without full rebuild"""
def __init__(self, recommender: ContentBasedRecommender):
self.rec = recommender
self.user_profiles = {}
self.user_history = {}
def update_on_interaction(self, user_id: str, item_id: str,
interaction_type: str):
"""Update profile after interaction"""
weights = {
'view': 1.0, 'click': 1.5, 'add_to_cart': 3.0,
'purchase': 5.0, 'dislike': -2.0, 'skip': -0.5
}
weight = weights.get(interaction_type, 1.0)
if user_id not in self.user_history:
self.user_history[user_id] = []
self.user_history[user_id].append({
'item_id': item_id,
'weight': weight,
'interaction_type': interaction_type
})
# Recalculate profile (last 50 interactions)
history = self.user_history[user_id][-50:]
liked = [h['item_id'] for h in history if h['weight'] > 0]
liked_weights = [h['weight'] for h in history if h['weight'] > 0]
if liked:
self.user_profiles[user_id] = self.rec.build_user_profile(
liked, liked_weights
)
def get_recommendations(self, user_id: str, n: int = 10) -> list[tuple]:
profile = self.user_profiles.get(user_id)
if profile is None:
return []
exclude = [h['item_id'] for h in self.user_history.get(user_id, [])]
return self.rec.recommend(profile, exclude_items=exclude, n=n)
Content-based filtering with sentence-transformers achieves Precision@10 = 0.15–0.30 on new users (versus 0.01–0.03 for popular items). A profile can be built from as few as 3–5 interactions. Inference: SentenceTransformer encoding of 10K items takes 5–10 minutes on CPU, 30–60 seconds on GPU.
Performance comparison tables
| Metric | Content-Based (new users) | Popular Baseline | Improvement |
|---|---|---|---|
| Precision@10 | 0.15–0.30 | 0.01–0.03 | 10–30 times better |
| Recall@10 | 0.10–0.25 | 0.005–0.01 | 10–25 times better |
| Coverage | 80%+ | 1–5% | 10–30 times better |
Why Content-Based Filtering Beats Collaborative Filtering for New Projects
On platforms with few users or high content turnover, Collaborative Filtering suffers from sparse interaction matrices. According to Wikipedia, collaborative filtering requires large interaction matrices, while content-based filtering can operate with sparse data. Content-Based uses rich metadata and doesn't suffer from cold start—relevant results appear after the first click. For example, in a niche online cinema with 5000 movies and 200 active users, content-based filtering gives 80%+ coverage, which is up to 16 times better than collaborative filtering's 5–20% coverage. In terms of catalog coverage, content-based filtering (80%+) outperforms collaborative filtering (5-20%) by up to 16x. Average system payback period is 6–12 months due to reduced churn. For a platform with 10,000 users, reducing churn by 30% can save $1,000 monthly.
| Characteristic | Content-Based | Collaborative Filtering |
|---|---|---|
| Dependency on other users | No | Yes |
| Cold-start requirement | 3–5 actions | Requires lots of data |
| Diversity (coverage) | 80%+ | 5–20% |
| Serendipity | Low | High |
| Computational complexity | O(N * F) | O(users * items) |
What's Included in the Work
- Documentation on architecture, input data, and API endpoints.
- Source code for the vectorization pipeline and recommendation module.
- Access to trained model weights and vector indices.
- Training of your team for administration and model updates.
- Support for the first month after deployment (incident resolution, optimization).
Estimated Timeline and Cost
Realization time depends on data volume and integration complexity. A typical project takes from 4 to 8 weeks and starts at $15,000. With an average item price of $50, a 10% increase in click-through rate can generate an additional $1,000 daily revenue per 10,000 users. For an accurate estimate, send us a description of your domain and catalog size—we will calculate cost and timeline within one working day. Contact us—we will analyze your data and find the optimal architecture.
Get a consultation: we'll show you how content-based filtering affects your funnel. The system significantly boosts engagement during onboarding when users have no history. With our implementation, you get quality assurance: we've already encountered all typical pitfalls—from embedding selection to diversity penalty calibration. Over 50 projects, 7+ years of experience, and certified engineers—that's why clients trust us.







