Personalized Recommendations
On this page
Objectives
- Build a recommendation system based on embeddings
- Implement content-based and user-based recommendation
- Handle cold start and diversity
The Principle: Similarity Equals Recommendation
A classic recommendation engine observes behavior: “People who read this article also read that one.” It needs lots of traffic before it can say anything useful—this is the cold start problem, which hits both new catalogs and articles published this morning.
The embeddings approach skips this wait. If a user liked an article, you recommend the ones with the closest embeddings. This is content-based recommendation: it relies on content, not on other users’ behavior. An article published ten minutes ago, which no reader has opened yet, already has an embedding and becomes recommendable immediately.
Content-Based Recommendation
The catalog below intentionally mixes two worlds—machine learning on one side, software engineering on the other—so the results are verifiable. Each article is encoded by concatenating its title and content: the title captures the subject in a nutshell, the content provides the vocabulary, and using one without the other degrades the quality of the neighbors found.
The trick lies in the line scores[idx] = -1. Without it, the article most similar to an article would always be itself, with a score of 1.0—and you would be recommending to a reader what they just finished.
from openai import OpenAI
import numpy as np
client = OpenAI()
# Article catalog
articles = [
{"id": "a1", "titre": "Introduction au machine learning",
"contenu": "Le ML permet aux machines d'apprendre à partir de données..."},
{"id": "a2", "titre": "Deep learning avec PyTorch",
"contenu": "PyTorch est un framework de deep learning flexible..."},
{"id": "a3", "titre": "Déployer une API REST avec FastAPI",
"contenu": "FastAPI est un framework Python pour créer des APIs..."},
{"id": "a4", "titre": "Les bases de PostgreSQL",
"contenu": "PostgreSQL est un SGBD relationnel open source..."},
{"id": "a5", "titre": "Réseaux de neurones convolutifs",
"contenu": "Les CNN sont utilisés pour la vision par ordinateur..."},
{"id": "a6", "titre": "NLP avec les transformers",
"contenu": "Les transformers ont révolutionné le traitement du langage..."},
]
# Index the catalog
textes = [f"{a['titre']}. {a['contenu']}" for a in articles]
response = client.embeddings.create(
input=textes, model="text-embedding-3-large"
)
catalogue_embs = np.array(
[d.embedding for d in sorted(response.data, key=lambda x: x.index)]
)
def recommander_similaires(
article_id: str,
k: int = 3
) -> list[dict]:
"""Recommends the k most similar articles."""
idx = next(i for i, a in enumerate(articles) if a["id"] == article_id)
article_emb = catalogue_embs[idx]
# Similarity with all other articles
scores = catalogue_embs @ article_emb
scores[idx] = -1 # Exclude the article itself
top_k = np.argsort(scores)[-k:][::-1]
return [
{"article": articles[i], "score": float(scores[i])}
for i in top_k
]
# A user reads "Introduction to ML"
recos = recommander_similaires("a1", k=3)
print("You read: Introduction au machine learning")
print("Recommendations:")
for r in recos:
print(f" [{r['score']:.3f}] {r['article']['titre']}")
User Profile from History
Recommending based on a single article quickly traps the reader. To account for their entire journey, you build a profile vector by combining the embeddings of the articles they viewed: this average point represents their interests in the same space as the catalog, and you then look for the articles closest to this point.
However, reads from six months ago shouldn’t weigh as much as yesterday’s. Recency weighting handles this: the factor 0.5 ** (n - 1 - i) halves the weight at each step back in the history. A reader who followed machine learning for a long time but recently switched to APIs will see their recommendations shift within a few articles instead of staying frozen. The final normalization brings the profile back to the unit sphere, a condition for the dot product with the catalog to be a proper cosine similarity.
def creer_profil_utilisateur(
articles_consultes: list[str],
poids_recency: bool = True
) -> np.ndarray:
"""Creates a profile vector from history."""
indices = [
i for i, a in enumerate(articles)
if a["id"] in articles_consultes
]
if not indices:
return np.zeros(catalogue_embs.shape[1])
embs = catalogue_embs[indices]
if poids_recency:
# Recent articles count more
n = len(indices)
poids = np.array([0.5 ** (n - 1 - i) for i in range(n)])
poids /= poids.sum()
profil = np.average(embs, axis=0, weights=poids)
else:
profil = np.mean(embs, axis=0)
# Normalize
profil /= np.linalg.norm(profil)
return profil
def recommander_pour_utilisateur(
articles_consultes: list[str],
k: int = 3
) -> list[dict]:
"""Recommends articles based on the user profile."""
profil = creer_profil_utilisateur(articles_consultes)
scores = catalogue_embs @ profil
# Exclude already viewed articles
for i, a in enumerate(articles):
if a["id"] in articles_consultes:
scores[i] = -1
top_k = np.argsort(scores)[-k:][::-1]
return [
{"article": articles[i], "score": float(scores[i])}
for i in top_k
]
# User who read ML and deep learning
recos = recommander_pour_utilisateur(["a1", "a2"], k=3)
print("History: ML + PyTorch")
print("Recommendations:")
for r in recos:
print(f" [{r['score']:.3f}] {r['article']['titre']}")
Recommendation with Text Query
The behavioral profile ignores one thing the user knows better than you: what they are looking for today. Since the catalog is already encoded, you just need to encode a free-text phrase to query the same space. “I want to learn how to create APIs” brings up the FastAPI article, even if the reader’s history contains only machine learning. This is also the only option available for a visitor you know nothing about.
def recommander_par_interet(
description: str,
k: int = 3
) -> list[dict]:
"""Recommends articles based on an interest description."""
query_emb = client.embeddings.create(
input=description,
model="text-embedding-3-large"
).data[0].embedding
scores = catalogue_embs @ np.array(query_emb)
top_k = np.argsort(scores)[-k:][::-1]
return [
{"article": articles[i], "score": float(scores[i])}
for i in top_k
]
# "I want to learn how to create APIs"
recos = recommander_par_interet("créer des APIs web en Python")
for r in recos:
print(f" [{r['score']:.3f}] {r['article']['titre']}")
Diversifying Recommendations
Sorting by pure similarity produces a visible flaw in production: the five recommendations look alike. A reader of the CNN article gets five variants of computer vision, when the second or third suggestion could have opened the door to transformers.
MMR (Maximal Marginal Relevance) fixes this by selecting articles one by one: at each turn, it picks the one that maximizes relevance to the query, minus the maximum redundancy with what is already selected. The lambda_param sets the trade-off—at 1.0 you get classic sorting, at 0.5 you strongly favor variety, and the default value of 0.7 gives a relevant list that breathes.
def recommander_divers(
query_emb: np.ndarray,
k: int = 5,
lambda_param: float = 0.7
) -> list[int]:
"""MMR: balances relevance and diversity."""
scores = catalogue_embs @ query_emb
candidats = list(range(len(articles)))
selectionnes = []
for _ in range(k):
if not candidats:
break
meilleur_score = -float("inf")
meilleur_idx = -1
for idx in candidats:
# Relevance
pertinence = scores[idx]
# Max redundancy with already selected
if selectionnes:
sims_selectionnes = [
float(np.dot(catalogue_embs[idx], catalogue_embs[s]))
for s in selectionnes
]
redondance = max(sims_selectionnes)
else:
redondance = 0
# MMR Score
mmr_score = (
lambda_param * pertinence
- (1 - lambda_param) * redondance
)
if mmr_score > meilleur_score:
meilleur_score = mmr_score
meilleur_idx = idx
selectionnes.append(meilleur_idx)
candidats.remove(meilleur_idx)
return selectionnes
Summary
- Embeddings allow recommendation by content similarity
- The user profile is built by averaging viewed embeddings
- Recency weighting gives more weight to recent views
- MMR diversifies recommendations by penalizing redundancy