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.

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:
- Candidate generation (retrieval) narrows millions of items down to a few hundred plausible candidates per user, optimized for recall.
- 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:
- You can't train a regression against a 1-5 scale. You need models built for implicit signals (ALS with confidence weighting, BPR, WARP).
- Popularity bias is severe — a naive model just relearns "recommend whatever is popular." Down-weighting or negative sampling matters.
- Exposure bias means your training data only reflects what your current system already shows people, which quietly caps how much a new model can improve things until you inject some exploration.
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:
- Cross features between user and item (recency of last interaction with this category, price relative to the user's typical spend).
- A gradient-boosted tree (LightGBM/XGBoost with a pairwise or LambdaMART objective) is a strong, cheap-to-operate default here — it handles heterogeneous tabular features better than a deep model in most cases.
- If you're already in the TFRS ecosystem,
tfrs.tasks.Rankingwith a pointwise or listwise loss is the natural next step once you have logged features from the retrieval stage to train on.
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.
- Recall@k: of all items the user actually interacted with in the held-out period, what fraction appear in your top-k candidates? This is the primary metric for the retrieval stage.
- NDCG@k (Normalized Discounted Cumulative Gain): rewards getting the most relevant items near the top of the list, normalized against the ideal ordering (IDCG@k) — the standard metric for the ranking stage since order, not just presence, matters.
- Precision@k / MAP: useful complements, especially precision@k when you only show a handful of slots (e.g., a homepage carousel).
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
- Popularity collapse: recommendations converge to the same dozen bestsellers. Fix with weighting (BM25, inverse propensity) or an explicit diversity term in re-ranking.
- Feedback loops: a model trained only on what a prior model surfaced can't discover items it never showed. Inject a small amount of randomized exploration into production traffic and log it — this is also what lets you compute unbiased offline metrics later.
- Train/serve skew: features computed differently at training time (batch pipeline) versus serving time (real-time service) silently degrade the model. Share feature computation code between both paths, and log serving-time feature values so you can audit them against training data.
- Cold start ignored: if you don't explicitly design a fallback path for new users/items, your architecture will slowly rank them at the bottom forever since they'll never accumulate the interactions needed to compete.
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.