What it actually does
Describe every item as a vector of its own properties — the words in an article, the tags on a film, an embedding of a product photo — then describe a user as the average of the items they liked. To recommend, find the unseen items whose vector points the same way as the user’s. Nothing here looks at what other people did, which is its defining strength (a brand-new item can be recommended the moment it is described) and its defining weakness (it can only ever recommend more of what the user already showed they like). The method is a chain of three representation decisions — how items become vectors, how likes become a profile, how similarity becomes a score — and the third is where most implementations are quietly wrong. Cosine is right for TF-IDF because it divides out document length; Jaccard is right for binary tag sets because it penalises size imbalance; a raw dot product is right only when vector magnitude is meant to carry popularity or confidence. Choosing among them is a claim about the data, not a tuning knob.
- A user’s future preferences resemble the properties of items they liked before — the smoothness assumption, and the one that produces filter bubbles when it holds too strongly
- Item content features capture what drives preference; taste not expressed in the features is invisible to the model
- The chosen similarity matches the representation: cosine for TF-IDF and embeddings, Jaccard for sets, dot product only if magnitude is meaningful
- A single profile vector adequately summarises the user, which fails for people with several unrelated interests
- Past feedback is a fair proxy for current taste, so stale interactions need decay rather than equal weight
Loss function
Choosing the profile is a least-squares problem with a closed-form answer: the point that minimizes the weighted squared distance to everything the user liked is the weighted mean of those items — the centroid, which is exactly what Rocchio’s relevance-feedback update computes. This is the one place the model is "trained", and it is trained per user, in one pass, with no iteration. The ranking step then scores unseen items by cosine similarity to that centroid, and the two pieces are consistent for a reason worth knowing: for unit-length vectors, squared Euclidean distance equals two minus two times the cosine, so minimizing squared distance to the liked items and maximizing total cosine to them select the same profile direction. If the profile weights are instead learned — a logistic or pairwise ranking loss over liked and not-liked items — the model becomes a linear classifier over content features, and the centroid is only the initialisation.
- the user profile, a point in the same space as the item vectors
- the set of items the user liked, or engaged with positively
- the content vector of item i — TF-IDF weights, a one-hot tag vector, or an embedding
- the weight of that signal: a mean-centred rating, an implicit-feedback strength, or an exponential time decay
How it is minimized
Closed-form centroid (Rocchio) per user, then a similarity-ranked scan — no iterative fit
- the previous profile or the initial query; alpha controls how much of it survives an update
- Rocchio weights on the old profile, the liked centroid, and the disliked centroid; classic IR defaults are around 1, 0.75, and 0.15
- the items the user explicitly rejected; the negative term pushes the profile away from them
- the relevance score of candidate item j — cosine similarity between profile and item
- the identity that makes cosine and Euclidean rank-equivalent once both vectors are L2-normalised
There is nothing to iterate, so the engineering questions are all about the similarity function, and it is the part of this entry worth reading slowly. Cosine on TF-IDF: the dot product divided by both norms removes vector length, so a 5,000-word article does not outrank a 300-word one merely for containing more terms. A raw dot product on the same vectors ranks by document length; Euclidean distance on them ranks short documents as near everything and long documents as far from everything, neither of which has anything to do with topic. Jaccard on binary tag sets: intersection over union, which penalises size imbalance — a film with 40 tags overlapping 4 of a user’s 5 tags scores low, as it should, whereas a plain overlap count (the dot product on binary vectors) would crown the most heavily tagged item for every user. Cosine on binary vectors sits between the two, dividing by the geometric mean of the set sizes rather than the union. Dot product, deliberately: when embeddings are trained so that norm encodes popularity or confidence — as in two-tower retrieval — the magnitude is signal, and normalising it away throws it out; the price is that popular items dominate unless that is the intent. The identity ‖a−b‖² = 2 − 2cos(a,b) holds for unit vectors, so once items and profile are L2-normalised, cosine, inner product, and Euclidean distance all produce the same ranking and the choice collapses to which one your index supports. Everything else — Rocchio weights, time decay, MMR re-ranking — tunes the profile, while the metric decides what "alike" means.
item representation
The real model. TF-IDF for text, multi-hot for tags and categories, a pretrained embedding for images, audio, or short text; and the similarity must match it
similarity function
Cosine for TF-IDF and dense embeddings, Jaccard for binary sets, dot product only when magnitude is intentional signal. Swapping it without changing the representation changes what is recommended
Rocchio weights (alpha, beta, gamma)
How much the previous profile, the liked centroid, and the disliked centroid contribute. A large gamma reacts fast to a rejection and risks over-correcting on a single dislike (alpha 1, beta 0.75, gamma 0.15 as the classical IR starting point)
feedback weighting
Mean-centre ratings so that a below-average rating becomes a negative weight, or use implicit-feedback strengths. Unweighted means treat a skim and a binge identically
time-decay half-life
Recency weighting on liked items so the profile tracks drifting taste rather than the whole history equally (30 to 180 days, domain-dependent)
TF-IDF variant
Sublinear tf (1 + log count), smoothed idf, and stop-word handling. Smoothing stops a rare term from getting an unbounded weight on one document
diversity re-rank (MMR lambda)
Trades relevance against redundancy among the returned items; the direct countermeasure to over-specialisation (0.5 to 0.9)
No iteration, so no convergence — the centroid is exact and the score is a deterministic scan. What replaces a convergence guarantee is a list of structural failure modes, which are the actual content. Over-specialisation: the objective rewards similarity to what the user already liked and nothing rewards novelty, so recommendations contract into a filter bubble and the profile becomes a self-confirming loop. The single-centroid problem: a user who likes both jazz and cooking gets a profile midway between the two, which resembles neither; the fix is several centroids per user or a max over liked items, at which point this is k-nearest neighbours again. Feature ceiling: it can recommend only along properties it can see, so quality is bounded by how well the item vectors capture what makes a user like something — taste in prose style, say, or in "vibe", is not in a bag of words. And the cold-start asymmetry is real but one-sided: a new item is recommendable immediately, while a new user with no history has no profile and no recommendations.
Item vectors: O(nnz) to build TF-IDF over the catalogue, once. Profile: O(nnz of the liked items) per user. Scoring the full catalogue: one sparse matrix-vector product, O(nnz of the catalogue); for large catalogues use an approximate inner-product index so scoring is sublinear. Memory: the sparse item matrix plus one dense or sparse profile per user. For text, vocabulary size drives dimension — often 10^5 to 10^6 — which is why sparse storage is not optional.
How, where, and why it is used
Time-Series Forecasting
Not applicableIt ranks a catalogue of items against a profile; it predicts nothing about future values of a series and has no notion of temporal dynamics. Time appears only as a decay weight on past feedback, which is recency weighting rather than forecasting.
Anomaly Detection
Not applicableLow maximum similarity to a user’s history or to a reference set can be read as a novelty signal, but that is nearest-neighbour novelty detection under another name, and k-nearest neighbours covers it properly. The model’s own purpose, ordering items by relevance, is not an outlier decision, so no honest fit is claimed here.
Optimization
Not applicableThe centroid is a closed-form least-squares answer and the ranking is a sort; there is no constrained decision problem, no search over a feasible set, and nothing to optimize beyond scoring. A diversity-aware re-rank (MMR) is a greedy selection heuristic layered on top, not a use of the model as an optimizer.
Recommendation & Ranking
Primary fitEach item is described by its own content — text, tags, metadata, or an embedding — and each user by the weighted mean of the items they engaged with. Candidates are scored by similarity to the profile, filtered for already-seen items, and the top k are returned. Used alone it is a complete recommender; used as one retrieval source it supplies exactly the items a collaborative model cannot, namely ones nobody has interacted with yet.
- News and article feeds, where an item is stale before enough interactions accumulate to learn it collaboratively
- Job, real-estate, and marketplace listings, where each listing is new, short-lived, and richly described
- Cold-start items in a hybrid system — a content score takes over until interaction data exists, then hands over to the collaborative signal
- Related-item shelves ("more like this"), which need no user profile at all — the item vector is the query
It is the right first model when items are new faster than interactions arrive, when metadata is rich, and when an explanation is required — "because it shares these properties with what you liked" is the model’s real mechanism. It is the wrong sole model when taste is not captured by content features, when interaction data is plentiful (collaborative methods then find structure no feature set encodes), or when serendipity matters, because the objective rewards similarity to the known and nothing rewards the surprising. In practice it is a candidate generator and a cold-start fallback inside a hybrid, not the whole system.
- Cosine similarity on TF-IDF for text: length normalisation keeps long descriptions from dominating, which a raw dot product or Euclidean distance would not
- Jaccard on binary tag or category sets: intersection over union penalises size imbalance, so a heavily tagged item is not preferred for overlapping a few tags
- Dot product only for embeddings trained so norm means popularity or confidence; otherwise L2-normalise so dot product, cosine, and Euclidean give the same ranking
- Weight liked items by mean-centred rating or implicit strength, with exponential time decay, and subtract a down-weighted centroid of rejected items
- Several profile centroids per user (clustered likes) for users with distinct interests, rather than one blurred average
Recall@k and NDCG@k on a temporal split with the profile built only from interactions before the cut. Report intra-list diversity, catalogue coverage, and novelty beside accuracy, since a content model can post excellent recall by recommending near-duplicates of what the user already has. Cold-item recall is the number that justifies the model: score it separately on items with zero interactions.
- Filter bubble: recommending only near-neighbours of past likes, with no exploration, shrinks the user’s exposure over time and starves the feedback that would correct the profile
- Metric-representation mismatch — Euclidean distance on raw TF-IDF, or a dot product on unnormalised vectors — silently ranks by document length
- A single blended centroid for a multi-interest user, which ranks items resembling none of their interests highest
- Recommending items the user already has: seen items are always the most similar to the profile and must be masked explicitly
- Refitting TF-IDF as the catalogue grows, which changes every stored vector; the idf statistics are part of the model and need versioning
Natural Language
ViableDocuments become TF-IDF or sentence-embedding vectors; a query, a reading history, or a set of relevant documents becomes a profile; ranking is cosine similarity. This is the original home of the method: Rocchio’s 1971 relevance-feedback update for the SMART retrieval system moves a query vector toward documents a searcher marked relevant and away from those marked irrelevant.
- Related-article and "more like this" retrieval over documents and knowledge bases
- Relevance feedback in search: refining a query from a few judged results
- Near-duplicate and plagiarism screening by thresholding cosine similarity between document vectors
- Matching résumés to job descriptions, or support tickets to knowledge-base articles, as a transparent baseline before a learned ranker
For short-horizon, keyword-anchored matching it is hard to beat for cost and transparency: no training, deterministic, inspectable term by term. It loses wherever meaning differs from vocabulary — synonyms, paraphrase, and negation are invisible to a bag of words, which is what dense embeddings (a word2vec-style model, or a modern sentence encoder) repair at the cost of interpretability. The usual upgrade is not to abandon the method but to swap the item representation and keep the profile-and-cosine machinery unchanged.
- Lowercase, strip stop words, and apply sublinear tf with a smoothed idf; consider bigrams for phrases like "machine learning"
- L2-normalise every document vector so cosine and inner-product search are identical and length drops out
- Where vocabulary mismatch dominates, replace TF-IDF with a dense embedding and keep cosine as the similarity
Mean average precision and NDCG against human relevance judgements, plus a vocabulary-mismatch slice (queries whose relevant documents share few terms with them) to expose where bag-of-words fails.
- Vocabulary mismatch: a relevant document using different words scores near zero, with no warning
- Idf computed on a corpus unlike the serving corpus, which mis-weights domain terms
- Stop-word and stemming choices baked into the index; changing them later invalidates every stored vector
What it costs to run
Effectively nothing: TF-IDF statistics are one pass over the catalogue, and a profile is a weighted sum computed per user on demand or on each feedback event. An embedding-based variant pays for the encoder once, offline. There is no training run to schedule.
One sparse matrix-vector product per user, or an approximate inner-product search over L2-normalised item vectors, which is why item vectors are normalised at index time so that online scoring needs no norm computation. Latency scales with catalogue nonzeros for an exact scan and is sublinear with an ANN index. Per-user profiles are small enough to cache and update incrementally on each interaction.
Item vectors are recomputed when new items arrive, which is incremental for TF-IDF only if the idf statistics are frozen between periodic full rebuilds. Profiles update on every feedback event. If the representation changes (a new embedding model, a new vocabulary), every stored item vector and every profile must be invalidated and rebuilt together, because mixing vector spaces gives meaningless similarities.
- Track intra-list diversity and catalogue coverage over time — a falling trend is the filter bubble forming, visible long before engagement metrics move
- Monitor the share of recommendations drawn from the user’s own history neighbourhood against explore slots
- Watch the idf and vocabulary statistics as versioned artefacts; a vocabulary shift silently re-scales every similarity
- Compare cold-item click-through to warm-item click-through to confirm the content signal is earning its place beside a collaborative model
- Alert on profiles whose norm collapses toward zero, which means feedback weights cancelled out and the user has effectively no profile
- Mask already-seen and already-purchased items before ranking; they are the highest-scoring candidates and the least useful
- A new user has no profile at all — decide the fallback (popularity, onboarding picks) explicitly instead of returning an empty or random list
- Normalise item vectors at index time and persist the idf table with them; recomputing either at query time is the commonest latency bug
- Do not mix similarity functions between indexing and serving — an index built for inner product on normalised vectors queried with raw Euclidean distance returns a different ordering
- Explanations are cheap and tempting: the top contributing terms to the dot product are the real reason, so surface those instead of a post-hoc story
Pros and cons, in context
New items are recommendable the instant they are described
Decisive for news, listings, and any catalogue that turns over faster than interactions accumulate; collaborative filtering cannot score an item nobody has touched. Worth little where the catalogue is stable and interaction data is plentiful, where a collaborative signal is strictly richer.
No dependence on other users
It works for a single user in isolation, resists popularity bias and shilling attacks, and needs no cross-user data, which is a privacy advantage. The cost is that it cannot learn from the crowd, so it never discovers that fans of A also like B when A and B share no features.
Transparent and explainable by construction
The score decomposes into per-term or per-tag contributions, so "recommended because it shares these properties with items you liked" is the actual mechanism. Valuable in regulated or review-heavy settings; less so where the features themselves are an opaque embedding.
Closed-form, cheap, and incrementally updatable
A profile is a weighted mean that updates in one vector operation per feedback event, so there is nothing to retrain or schedule. It is the right baseline to beat before introducing a learned ranker, and often the baseline that survives.
Over-specialisation and the filter bubble
The objective rewards similarity to past likes and nothing rewards novelty, so recommendations narrow over time and the feedback needed to correct the profile dries up. Mitigated by MMR diversity re-ranking and explicit exploration slots, never by tuning the similarity.
Bounded by the quality of the item features
It can only recommend along dimensions it can see. If preference depends on style, quality, or social signals absent from the content, accuracy plateaus no matter how the profile is weighted, and a collaborative model will beat it once interaction data exists.
New users have no profile
The cold-start advantage is one-sided: a new item is fine, a new user is not. A hybrid with an onboarding step or a popularity fallback is required, so the model cannot be the whole system for acquisition-heavy products.
A single centroid blurs multi-interest users
Averaging likes from unrelated interests produces a profile resembling neither. The fix — several centroids, or scoring against each liked item and taking the max — moves the model toward k-nearest neighbours and costs more per query.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Content-based filtering - the item vectors, the profile, and the score, transcribed.
There is no fit step in the neural sense. Item vectors are TF-IDF weights, the
user profile is the weighted mean of the liked items (the centroid), and the
score is the cosine between the profile and every unseen item.
"""
import math
def tokenize(text):
return text.lower().split()
def tfidf_vectors(documents):
"""One dict per document mapping term -> (1 + log tf) * idf."""
n_docs = len(documents)
token_lists = [tokenize(document) for document in documents]
document_frequency = {}
for tokens in token_lists:
for term in set(tokens):
document_frequency[term] = document_frequency.get(term, 0) + 1
vectors = []
for tokens in token_lists:
counts = {}
for term in tokens:
counts[term] = counts.get(term, 0) + 1
vector = {}
for term, count in counts.items():
idf = math.log((1 + n_docs) / (1 + document_frequency[term])) + 1.0
vector[term] = (1.0 + math.log(count)) * idf
vectors.append(vector)
return vectors
def build_profile(vectors, liked_items, weights):
"""Weighted mean of the liked item vectors - the closed-form minimizer."""
profile = {}
total_weight = 0.0
for position in range(len(liked_items)):
weight = weights[position]
total_weight += weight
for term, value in vectors[liked_items[position]].items():
profile[term] = profile.get(term, 0.0) + weight * value
if total_weight == 0.0:
raise ValueError("liked items carry no weight")
for term in profile:
profile[term] = profile[term] / total_weight
return profile
def norm(vector):
total = 0.0
for value in vector.values():
total += value * value
return math.sqrt(total)
def cosine(profile, item):
dot = 0.0
for term, value in item.items():
if term in profile:
dot += profile[term] * value
denominator = norm(profile) * norm(item)
if denominator == 0.0:
return 0.0
return dot / denominator
def recommend(vectors, liked_items, weights, k=5):
profile = build_profile(vectors, liked_items, weights)
scored = []
for item in range(len(vectors)):
if item in liked_items:
continue
scored.append((cosine(profile, vectors[item]), item))
# Full sort of every candidate to keep k of them - correct, and wasteful.
scored.sort(reverse=True)
return scored[:k]O(nnz) to vectorize, then per user: O(nnz of liked items) for the profile plus O(nnz of the catalogue) to score, with both norms recomputed for every pair, in interpreter loops.
How this language actually wants it written.
"""Content-based filtering - typed, validated, sparse, rows normalised once."""
from collections.abc import Sequence
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from scipy import sparse
class ColdStartError(ValueError):
"""Raised when a user has no usable feedback, so no profile exists."""
@dataclass(frozen=True)
class ItemIndex:
"""The catalogue as a sparse matrix whose rows are unit vectors.
Rows are L2-normalised once, here, because cosine similarity is then a
plain dot product - length drops out of the ranking and the per-item
denominator never has to be recomputed at query time.
"""
matrix: sparse.csr_matrix # (n_items, n_terms), rows have norm 1
item_ids: tuple[str, ...]
def build_index(counts: sparse.csr_matrix, item_ids: Sequence[str]) -> ItemIndex:
"""TF-IDF with sublinear tf and smoothed idf, then L2 row normalisation."""
n_items, n_terms = counts.shape
if n_items != len(item_ids):
raise ValueError(f"{n_items} rows but {len(item_ids)} item ids")
if n_items == 0:
raise ValueError("the catalogue is empty")
counts = counts.tocsr().astype(np.float64)
counts.sum_duplicates()
document_frequency = np.bincount(counts.indices, minlength=n_terms)
idf = np.log((1.0 + n_items) / (1.0 + document_frequency)) + 1.0
counts.data = 1.0 + np.log(counts.data) # sublinear tf
weighted = sparse.csr_matrix(counts @ sparse.diags(idf))
norms = np.sqrt(np.asarray(weighted.multiply(weighted).sum(axis=1)).ravel())
norms[norms == 0.0] = 1.0 # empty documents stay zero
normalised = sparse.csr_matrix(sparse.diags(1.0 / norms) @ weighted)
return ItemIndex(matrix=normalised, item_ids=tuple(item_ids))
def build_profile(
index: ItemIndex,
liked_rows: NDArray[np.int64],
ratings: NDArray[np.float64],
neutral_rating: float,
) -> NDArray[np.float64]:
"""Mean-centred weighted centroid, returned as a unit vector.
A rating below neutral_rating gets a negative weight, so a dislike pushes
the profile away from an item instead of being ignored (Rocchio's gamma).
"""
if liked_rows.size == 0:
raise ColdStartError("no interactions to build a profile from")
if liked_rows.shape != ratings.shape:
raise ValueError("liked_rows and ratings must align")
weights = ratings - neutral_rating
profile = np.asarray(index.matrix[liked_rows].T @ weights).ravel()
length = np.linalg.norm(profile)
if length == 0.0:
raise ColdStartError("feedback weights cancel out; profile has no direction")
return profile / length
def recommend(
index: ItemIndex,
profile: NDArray[np.float64],
seen_rows: NDArray[np.int64],
k: int,
) -> list[tuple[str, float]]:
"""Top-k unseen items by cosine, which is a dot product on unit rows."""
n_items = index.matrix.shape[0]
if not 1 <= k <= n_items:
raise ValueError(f"k must lie in [1, {n_items}], got {k}")
scores = index.matrix @ profile # one sparse mat-vec
scores[seen_rows] = -np.inf # never re-recommend
# argpartition is O(n) selection; only the k survivors are then ordered.
top = np.argpartition(scores, n_items - k)[n_items - k:]
top = top[np.argsort(scores[top])[::-1]]
return [(index.item_ids[row], float(scores[row])) for row in top]What changed: Three changes beyond moving to sparse arrays. Item vectors are L2-normalised once, at index time, so cosine collapses to a dot product and the per-pair norm computation the previous stage repeated for every item disappears. The full sort becomes argpartition — selecting k of n is a linear-time problem and ordering all of them was the algorithmic mistake. And the profile now takes mean-centred ratings, so a below-neutral rating is a negative weight and a dislike repels instead of being silently dropped. Structurally, the cold-start case is a typed exception rather than a division by zero, the seen-item mask is explicit, and malformed shapes fail at the boundary.
O(nnz) one-time indexing; per user O(nnz of liked rows) for the profile, O(nnz of catalogue) for one sparse mat-vec, and O(n) selection instead of O(n log n) sorting.
Faster, and the justification for each change.
"""Content-based filtering - batched users, one sparse product, float32."""
import numpy as np
from numpy.typing import NDArray
from scipy import sparse
class BatchRecommender:
"""Scores a block of users against the catalogue with sparse products.
Items are unit rows (cosine == dot product). User profiles do not need
normalising either: dividing a user's scores by a constant leaves their
ranking untouched, so that norm is never computed on the hot path.
"""
def __init__(self, unit_items: sparse.csr_matrix, k: int = 10) -> None:
if not 1 <= k <= unit_items.shape[0]:
raise ValueError(f"k must lie in [1, {unit_items.shape[0]}], got {k}")
# One dtype, contiguous CSR: scipy hands the buffers to BLAS-style kernels
# without an internal conversion, and float32 halves memory traffic.
self._items = unit_items.astype(np.float32).tocsr()
self._items_t = self._items.T.tocsr() # (n_terms, n_items), built once
self._k = k
def recommend(
self,
feedback: sparse.csr_matrix, # (n_users, n_items) mean-centred weights
chunk: int = 2048,
) -> NDArray[np.int64]:
"""Top-k item rows for every user, best first."""
n_users = feedback.shape[0]
n_items = self._items.shape[0]
k = self._k
out = np.empty((n_users, k), dtype=np.int64) # allocated once
# Chunking bounds the dense score block at (chunk, n_items) rather than
# (n_users, n_items), which is what makes a million-user batch possible.
for start in range(0, n_users, chunk):
stop = min(start + chunk, n_users)
weights = feedback[start:stop]
# Profiles for the whole block in one sparse-sparse product, then
# scores in a second: (chunk, items) @ (items, terms) @ (terms, items).
profiles = weights @ self._items
scores = (profiles @ self._items_t).toarray()
# Mask seen items in place using the feedback sparsity pattern.
rows, columns = weights.nonzero()
scores[rows, columns] = -np.inf
partitioned = np.argpartition(scores, n_items - k, axis=1)[:, n_items - k:]
chosen = np.take_along_axis(scores, partitioned, axis=1)
order = np.argsort(-chosen, axis=1) # sorts k, not n
out[start:stop] = np.take_along_axis(partitioned, order, axis=1)
return outWhat changed: The loop over users becomes two sparse products per chunk: weights times the item matrix yields every profile in the block at once, and profiles times the transposed item matrix yields every score. That replaces one Python-level mat-vec per user with a handful of calls per few thousand users, and the transpose is built once rather than per query. Two changes remove arithmetic rather than reorganise it: the user-profile norm is dropped entirely, because dividing a row of scores by a positive constant cannot change its ranking, and everything is float32 on contiguous CSR buffers. Chunking bounds the dense score block so peak memory is independent of the number of users. The honest caveat is that this is still an exact scan of the catalogue per user; past a few million items the answer is an approximate inner-product index, which is why the item rows were normalised in the first place.
Vectorize to NumPy/BLAS (@, np.dot, einsum)
Profiles for a whole chunk of users come from one sparse product and their scores from a second, instead of a mat-vec and a norm per user.
Trade-off: The score block is dense (chunk by n_items), so a sparse-friendly formulation temporarily becomes memory-hungry and the chunk size has to be chosen against catalogue size.
Pre-allocate output arrays and use in-place operations
The result array is allocated once for all users, and seen-item masking writes negative infinity directly into the score block instead of building a masked copy.
Trade-off: In-place masking destroys the true scores of seen items, so any caller wanting an explanation or a debug score for them must recompute it.
Ensure contiguous memory layout and a single dtype
Float32 CSR with a prebuilt transposed copy halves memory traffic and avoids dtype conversions inside the product.
Trade-off: Float32 accumulates rounding error across long sparse dot products, and ties near the top-k boundary can be broken differently from the float64 version; the transposed copy doubles item-matrix memory.
Batch work to amortize interpreter overhead
Chunking over users turns thousands of Python-level calls into a few BLAS-style calls and bounds peak memory independently of batch size.
Trade-off: Chunk size is a tuning parameter with no universal default — too small and dispatch dominates, too large and the score block spills out of cache.
O(nnz of the catalogue) per user in sparse kernels, plus O(n) selection per user, with memory bounded by the chunk. Illustrative, not a measured benchmark.