Skip to content
AI.info

Implementation Guides

Building AI-Powered Recommendation Systems

A practical guide to building recommendation engines using collaborative filtering, content-based methods, and modern deep learning approaches. From design to deployment.

Building AI-Powered Recommendation Systems

Gabriele Masetti ·

Framing the problem: retrieval, then ranking

Every production recommender that serves more than a toy catalog is built as two stages, not one model:

  1. Candidate generation (retrieval) narrows millions of items down to a few hundred plausible candidates per user, optimized for recall.
  2. Ranking takes those candidates and orders them precisely, optimized for precision at the top of the list, usually with a heavier model that can afford to score a few hundred items instead of millions.

Trying to run a single deep model over your entire catalog at request time doesn't scale past a few thousand items. Design for the two-stage split from day one, even if your ranking stage is a thin heuristic at first.

Data: implicit feedback is what you actually have

Most teams don't have star ratings; they have clicks, add-to-carts, watch time, and purchases. This is implicit feedback: positive signals only, no explicit negatives, and the absence of an interaction doesn't mean dislike — it might mean "never shown." This distinction drives everything downstream:

Start by building a sparse user-item interaction matrix, weighting events (e.g., purchase=5, add-to-cart=3, click=1) as a proxy for confidence, following the approach from Hu, Koren & Volinsky's "Collaborative Filtering for Implicit Feedback Datasets" — this is the paper the implicit library's ALS implementation is based on.

Event Weight
Purchase 5
Add-to-cart 3
Click 1

Before any modeling, spend real time on the interaction pipeline: dedupe rapid repeat clicks (a user refreshing a page shouldn't count as five separate signals), cap outlier session lengths, and decide on a lookback window (30-90 days is typical) so the matrix reflects current behavior rather than stale history. This unglamorous work moves the needle on offline metrics more than swapping algorithms does, and it's the step most teams underinvest in.

Baseline: matrix factorization with implicit

Before touching a neural network, get a matrix factorization baseline running. The implicit library gives you a fast, multi-threaded Alternating Least Squares (ALS) implementation with Cython/OpenMP under the hood (and CUDA kernels for ALS and BPR on compatible GPUs). This should be your first checkpoint, not your last resort — it's genuinely competitive on many mid-sized catalogs and takes an afternoon to stand up.

import implicit
from implicit.nearest_neighbours import bm25_weight
from scipy.sparse import csr_matrix

# user_item_csr: sparse matrix, rows=users, cols=items, values=raw event counts
weighted = bm25_weight(user_item_csr, K1=100, B=0.8).tocsr()

model = implicit.als.AlternatingLeastSquares(
    factors=64,
    regularization=0.05,
    iterations=20,
    use_gpu=False,
)
model.fit(weighted)

# top-10 recommendations for a single user, excluding items already interacted with
user_id = 4821
recommended = model.recommend(
    user_id,
    weighted[user_id],
    N=10,
    filter_already_liked_items=True,
)

# item-to-item similarity for "more like this" surfaces
similar = model.similar_items(itemid=901, N=10)

BM25 weighting (borrowed from search ranking) down-weights users and items with runaway interaction counts before factorization — it's a cheap, effective fix for popularity bias that's easy to skip and shouldn't be.

Solving cold start with a hybrid model: LightFM

Pure collaborative filtering has no representation for a brand-new user or item — there's no interaction history to factorize. LightFM fixes this by learning latent representations from both interactions and side-feature metadata (item category, price bucket, user signup channel, tags), so an item with zero interactions still gets a usable embedding built from its features alone.

LightFM also implements WARP loss (Weighted Approximate-Rank Pairwise), which repeatedly samples negative items until it finds one that violates the desired ranking, then updates toward fixing that specific violation. This is a form of active learning over negatives, and it consistently outperforms the more common BPR loss for optimizing precision at the top of the list — LightFM is one of the few libraries with a production-ready WARP implementation.

from lightfm import LightFM
from lightfm.evaluation import precision_at_k, auc_score

model = LightFM(loss="warp", no_components=64, learning_rate=0.05)
model.fit(
    interactions_train,      # sparse COO matrix of user-item interactions
    item_features=item_features,   # sparse feature matrix, one-hot/multi-hot metadata
    epochs=30,
    num_threads=4,
)

precision = precision_at_k(
    model, interactions_test, item_features=item_features, k=10
).mean()
auc = auc_score(model, interactions_test, item_features=item_features).mean()
print(f"precision@10={precision:.4f} auc={auc:.4f}")

For new items, keep a simple fallback path alongside the model: rank by category popularity or a content-similarity score until an item accumulates enough interactions (a few dozen is often enough) to let the learned embedding dominate. For new users, blend the LightFM score with a non-personalized popularity or "trending in category" list, and shift weight toward the personalized score as the user's interaction count grows — a simple linear blend keyed on interaction count works surprisingly well and avoids the classic failure of showing a brand-new user an empty or nonsensical feed.

Negative sampling matters here too: because you only observe positives, LightFM (and BPR/WARP more generally) trains by contrasting observed interactions against sampled items the user didn't interact with. If negatives are drawn uniformly at random, the model mostly learns to distinguish popular items from obscure ones rather than learning fine-grained preference — sampling negatives proportional to item popularity (or excluding already-cold items from the negative pool early in training) produces sharper rankings.

Scaling retrieval with neural two-tower models

Once your catalog and feature set outgrow matrix factorization — you want to mix text embeddings, images, session sequences, or real-time context into the retrieval step — move to a two-tower (dual-encoder) architecture with TensorFlow Recommenders (TFRS). One tower encodes the user/query (recent history, demographics, context), the other encodes the candidate item; both output fixed-length embeddings in the same space, and the score is their dot product.

The key property that makes this scale: because the two towers only interact through a dot product, you can precompute every item embedding offline and serve retrieval as an approximate nearest-neighbor lookup against the user embedding computed at request time.

import tensorflow as tf
import tensorflow_recommenders as tfrs

class TwoTowerModel(tfrs.Model):
    def __init__(self, user_model, item_model, candidate_items):
        super().__init__()
        self.user_model = user_model
        self.item_model = item_model
        self.task = tfrs.tasks.Retrieval(
            metrics=tfrs.metrics.FactorizedTopK(
                candidates=candidate_items.batch(128).map(item_model)
            )
        )

    def compute_loss(self, features, training=False):
        user_embeddings = self.user_model(features["user_id"])
        item_embeddings = self.item_model(features["item_id"])
        return self.task(user_embeddings, item_embeddings)

model = TwoTowerModel(user_tower, item_tower, item_ids_dataset)
model.compile(optimizer=tf.keras.optimizers.Adagrad(0.1))
model.fit(train_dataset.batch(4096), epochs=5)

tfrs.metrics.FactorizedTopK computes retrieval metrics (recall at various k) against the full candidate set during training, which is the right sanity check before you ever deploy — if recall@50 is weak in training, it will be worse in production after quantization and approximate search.

For serving, export the item tower's embeddings into an approximate nearest-neighbor (ANN) index — FAISS or ScaNN (which TFRS integrates with via tfrs.layers.factorized_top_k.ScaNN) — so retrieval at request time is a sub-millisecond vector lookup rather than a full forward pass over every item.

Budget your latency explicitly across the pipeline: computing the user embedding, the ANN lookup, and the ranking pass each get a slice of your total request budget (often 50-150ms end to end for a user-facing surface). Refresh the item-embedding index on a schedule that matches catalog velocity — hourly for fast-moving inventory, daily for stable catalogs — rather than trying to update it in real time, which is rarely worth the operational complexity for the retrieval stage.

The ranking stage

Retrieval gives you a shortlist; ranking decides the order the user actually sees. This stage can afford richer, slower features because it only scores hundreds of candidates instead of millions:

Don't skip straight to a deep ranker. A well-featured GBDT ranker is usually the highest-leverage second step after your retrieval baseline, and it's much easier to debug feature importance on than a neural net.

Evaluation: offline metrics first, then A/B tests

Offline metrics tell you whether a model is worth shipping to an experiment; they don't tell you it will win in production.

Split by time, not randomly — hold out the most recent interactions per user rather than a random sample, since random splits leak future information and inflate offline scores relative to what you'll see live.

Offline numbers looking good is necessary, not sufficient. Move to an A/B test measuring CTR, conversion rate, and a guardrail metric (session length, return rate) before rolling out broadly. It's common for a model with better offline NDCG to lose an A/B test because it over-indexes on already-popular items or reduces catalog diversity in a way offline metrics don't penalize — watch coverage (fraction of catalog ever recommended) as a diversity guardrail alongside the primary business metric.

Common failure modes

Where to start, concretely

If you're building this today: stand up ALS with implicit against your interaction logs this week, get precision@k and recall@k baselines, then decide whether you need side features (move to LightFM) or scale/context (move to a two-tower TFRS model) based on where the baseline actually falls short — not based on which architecture is more interesting to build.

Explore

More articles