Building Content-Based Recommendation Engines: A Practical Guide

We design and deploy artificial intelligence systems: from prototype to production-ready solutions. Our team combines expertise in machine learning, data engineering and MLOps to make AI work not in the lab, but in real business.
Showing 1 of 1All 1566 services
Building Content-Based Recommendation Engines: A Practical Guide
Medium
~1-2 weeks
Frequently Asked Questions

AI Development Areas

AI Solution Development Stages

Latest works

  • image_website-b2b-advance_0.webp
    B2B ADVANCE company website development
    1317
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1226
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    925
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1156
  • image_logo-advance_0.webp
    B2B Advance company logo design
    620
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    894

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

  1. Data collection: item_id, title, description, categories, tags, price, rating, and other numeric features.
  2. Text vectorization: we use multilingual sentence-transformers (paraphrase-multilingual-mpnet-base-v2), obtaining 768-dimensional embeddings.
  3. TF-IDF features: additionally extract 5000 n-grams (1–2), compress to 50 components via SVD.
  4. Categorical features: binarize using MultiLabelBinarizer.
  5. Numerical features: StandardScaler for price, rating, review_count, release_year.
  6. 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.