DEV Community

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

Comments

No comments yet. Start the discussion.