How We Evolved a Cultural Recommendation Feed From a Weighted SQL Ranker to a Narrative Affinity Model
Building a personalization engine for a multi-format content feed, without machine learning, and the testing process that forced us to rebuild it. TL;DR We run a collaborative cultural curation platform (think: user-submitted recommendations for movies, books, games, music, and long-form posts, all mixed into one feed) on a fairly ordinary PHP + MySQL stack. Over about a year we went through two full generations of the feed ranking algorithm. The first version solved the obvious problem (stop being purely chronological) but quietly failed at real personalization. The second version fixed that by rethinking what "user taste" even means, moving scoring out of SQL and into application code, and adding a layer of post-ranking business rules. This post walks through both generations, why the second one had to happen, and how we actually tested and calibrated a feed ranking system without a data science team or an ML pipeline. No exact weights, table names, or formulas below — just the engineering story. The starting problem: one feed, five content shapes Before personalization is even on the table, a multi-format feed has a normalization problem. Movies, books, games, music, and editorial posts live in different tables, with different columns, different publishing cadences, and engagement numbers on completely different scales. "1,000 likes" on a music post and "1,000 likes" on a book review are not the same signal. So the very first architectural decision — before any ranking logic existed — was building a unification layer that maps every content type into a shared shape (type, author, title, cover, category, engagement counters, timestamp) before any scoring happens. Everything downstream depends on that layer being consistent. Generation 1: a weighted ranker living inside a single SQL query The first real version of the algorithm — internally we called it the hybrid model — had a modest goal: get away from a purely chronological feed without building anything resembling heavy ML. The entire ranking logic lived inside one MySQL query, combining three signals: Popularity, log-compressed so that engagement outliers didn't dominate the ranking disproportionately — the same diminishing-returns trick sites like Reddit use to stop one viral post from burying everything else. Recency, decaying over a rolling time window, so the feed felt alive instead of stalling on old content. A first pass at genre affinity, giving a modest boost to content matching a user's most frequent genres. These three signals were combined into a single score with fixed weights, tuned by hand through repeated observation. It was simple, cheap to run, and it solved the most urgent problem. But as the curator base grew, manual testing started exposing structural cracks. What testing actually revealed Calibrating a feed isn't something you do with a spreadsheet in isolation — it's repeated observation of real output. Our process, across several rounds, looked like this: Synthetic profile sampling. We recreated users with deliberately different consumption histories — one locked into a single genre, one spread thin across many, one brand new with no history — and compared the generated feeds side by side, item by item. First-page composition audits. For each test profile, we measured how much of the first screen came from hard-coded priority rules versus how much actually came from the relevance score. This is where we found the biggest issue in generation 1: a "surface new content" mechanism was, unintentionally, eating most of the first page, which meant personalization was barely visible underneath it. Monotony checks. Did the same curator or the same content type dominate consecutive slots? This shows up fast when the popularity weight is too high relative to everything else. Bubble checks. The opposite failure mode — making sure affinity scoring didn't lock a heavy-history user into a single narrow topic. These tests made two things obvious: the affinity weight in generation 1 was too small to have any perceptible effect, and the "surface new content" rule needed to stop being a hard priority and become a lightweight, situational correction instead. Generation 2: from a single favorite genre to a narrative affinity model Generation 2 came directly out of those findings, and it involved two structural shifts. The first shift was conceptual. Instead of treating a user's taste as one label ("favorite genre"), we started modeling it as a distribution of affinities across different narrative groupings, computed separately per content type. In practice, this means acknowledging that taste is rarely monolithic — it's a mixture, with different weights across different thematic axes. To keep this from being noisy for users with thin histories (where one single recommendation could otherwise swing the whole affinity calculation), we applied a statistical smoothing technique on top of that distribution, so affinities never collapse to a hard zero and never get overconfident from a handful of data points. The second shift was architectural. Scoring moved out of the SQL query entirely and into application-layer PHP, running after raw candidates for each content type are pulled from the database. This sounds like a small refactor, but it changed our iteration speed dramatically. Every component of the score — affinity, recency, popularity, plus a small controlled randomness factor to avoid a fully deterministic feed — became an isolated, testable function that could be tuned without touching a single line of complex SQL. Calibration went from "rewrite the query and re-run it" to "change one function and re-run it." With that foundation in place, the relative weight of personal affinity increased significantly compared to generation 1, becoming the dominant signal in the ranking — with recency and popularity mostly acting as tie-breakers between items of similar affinity, rather than competing forces of similar magnitude. Post-ranking business rules The newest part of generation 2 isn't the scoring formula itself — it's a set of rules applied after the feed is already sorted, a fine-tuning layer that a single numeric score can't handle on its own: A freshness rule that replaced the old hard-priority mechanism: it only kicks in when nothing recent of that content type appeared organically near the top, and when it does kick in, it inserts exactly one item in a mid-page position — never forcing the very top. A secondary-narrative diversity rule that guarantees a user's second-strongest interest also shows up at least once per content type on the first page, preventing a single-topic feed even when the primary affinity is very strong. A curator spacing rule — a cooldown that stops the same author from appearing twice within a short window of consecutive positions. A rarity cap that prevents very low-engagement items from monopolizing the top of the feed just because they scored well on affinity. Put together, the pipeline looks like: fetch a raw candidate pool per content type → score each item independently → interleave across content types by relative rank → apply the four adjustment rules in sequence. Each stage is auditable on its own, which made debugging enormously easier — when a test surfaced a problem, we could usually pinpoint exactly which stage of the pipeline it came from. How we make sure two users actually see different feeds Real personalization depends on three things working together, not one clever formula: An individual affinity profile, recomputed from each user's own history — this is what makes two people with different tastes get different orderings of the same candidate pool. A small, controlled randomness factor — just enough that the same user doesn't see the exact same ordering on back-to-back visits when the candidate pool hasn't changed much, without hurting relevance enough to matter. Diversity and spacing rules that stop affinity-driven personalization, taken to its logical extreme, from producing a feed so narrow it feels repetitive even though it's technically "relevant." None of these three alone would solve the problem. An affinity profile with no randomness and no diversity rules tends to converge toward an increasingly narrow feed with every interaction — the classic filter-bubble failure mode. That's exactly why generation 2 was designed with the post-ranking rules baked in from the start, not bolted on afterward. The engineering philosophy that survived both generations Two decisions carried over from generation 1 to generation 2 and still guide how we think about the feed today: Prefer simple, auditable signals over black-box models. Neither generation uses heavy machine learning — the entire ranking is explainable in terms of factors any engineer on the team can reason about and debug item by item. That mattered as much as ranking quality itself, because it's what made fast calibration possible in the first place. Calibrate by inspecting real output, not just aggregate metrics. Averages hide composition problems — a feed can look "diverse" on paper while still delivering a bad experience to specific user segments. Our process always came back to reading individual feeds end to end before trusting any summary metric. Moving from the hybrid model to the narrative affinity model wasn't a rewrite for its own sake — it was a direct response to problems that only became visible once the system was tested against real production behavior. That feedback loop, more than any specific formula, is what keeps the RecomendeMe feed calibrated today. RecomendeMe is a collaborative cultural curation platform built on PHP/MySQL. If you're building recommendation systems on a similarly lean stack, I'd love to hear how you approach calibration without an ML pipeline — drop a comment.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to