The recommender I wrote about last time builds a fixed list of neighbors for every product in the catalog. It runs overnight, scores roughly eight thousand pieces of jewelry against each other with TF-IDF and cosine similarity, and writes the winners to a table. It works, and it produces exactly the same six cards for every person who visits the site.
That last part was the next thing to solve. The ask was recommendations that respond to the shopper, and I had a system that structurally could not tell one shopper from another.
The precomputed table is a map, not an answer
My first instinct was that personalization meant recomputing similarity per visitor, which is expensive and, on reflection, wrong.
"Similar to this ring" is legitimately identical for everybody. The ring doesn't change based on who's looking at it. If two pieces are near neighbors in the catalog, they're near neighbors for you and for me and for someone who has never been to the site before. Recomputing that per visitor would burn a lot of database time to arrive at the same answer.
What I'd actually built wasn't a set of recommendations. It was a map of how the catalog relates to itself. Personalization isn't redrawing the map. It's choosing where to enter it, and which direction to walk.
That reframing made the whole problem tractable, because choosing an entry point is cheap.
Three entry points
Every carousel on the site now seeds differently depending on what the page knows about you.
On a product page, the seed is the product you're looking at. That's the obvious one.
In the cart, the seed is everything in the cart at once. Each item contributes its own neighbor list and the lists get merged, with each candidate scored by the sum of one over its rank across all the seeds. A piece that shows up at rank 4 for two different cart items beats a piece that shows up at rank 2 for only one of them. That's deliberate. If you're buying a bracelet and a pendant, the thing that goes with both is more useful than the thing that goes perfectly with one.
On a listing page there's no seed product at all, so it uses the products you've viewed recently, filtered to the category you're currently browsing. Your history might be scattered across the store, but if you're standing on the bracelet listings, the row still has to be bracelets. That constraint matters more than it sounds like it should. A personalized row that ignores the page you're on reads as broken rather than as clever.
Identity for all of this is either your account, if you're logged in, or a long-lived cookie if you aren't.
What a personalized row actually looks like
Here's a real product's row, before and after, for a visitor who has been browsing three pieces from one particular brand. Brand names replaced with letters:
| rank | source | brand | base | with taste |
|---|---|---|---|---|
| 1 | content | A | 1.000 | 1.000 |
| 2 | content | A | 0.500 | 0.500 |
| 3 | content | A | 0.333 | 0.333 |
| 4 | content | A | 0.250 | 0.250 |
| 5 | content | B | 0.200 | 0.200 |
| 6 | affinity | C | 0.167 | 0.617 |
| 7 | affinity | C | 0.143 | 0.593 |
| 8 | affinity | C | 0.125 | 0.575 |
Brand C's three pieces were sitting off the end of the visible row. After the taste adjustment they occupy slots two, three and four, pushing three of brand A's pieces out of view.
The taste term is 0.15 * min(times you've viewed this brand, 3), so it maxes out at 0.45. Notice what that ceiling buys: the top content match, scoring 1.0, cannot be displaced by brand preference alone. A rank 2 item boosted to its maximum reaches 0.95 and still loses.
That wasn't luck, it's the point of the cap. Personalization should reshuffle the middle of a row. It should not be able to shove aside the single best content match because you glanced at a brand three times. Without a ceiling, a visitor who browsed four rings from one designer would get a row of nothing but that designer, which is the brand-catalog failure from the last post arriving through a different door.
Nobody trained these weights
I want to be plain about this, because it would be easy to imply otherwise.
There is no trained model here. The 0.15, the 0.60, the cap at 3, all of it is hand-tuned. I picked numbers that produced sensible-looking rows and left them there.
That isn't false modesty, it's a description of the constraint. Learning those weights requires knowing which recommendations worked, and knowing that requires click data, and click data is precisely what this site does not have. You cannot fit a model to an empty table. Classical information retrieval plus hand-set weights is the honest tool for this situation, and pretending a model chose them would be a lie that also happens to be less interesting.
The uncomfortable measurement
Here's the number that puts all of the above in perspective.
Of 3,643 sessions that viewed a product page at all, exactly 5 have ever viewed two or more distinct products. The median visitor has seen one product and left.
Personalization needs history. Almost nobody has any. Which means that for something like 99.8% of visitors, every term I just described evaluates to zero and the row falls back to plain content rank.
I think it was still right to build. But the honest framing is that "built and correct" and "currently doing anything" are two different claims, and only the first one is true today. A recommender that personalizes for five sessions is not personalizing. It's ready to.
The reason that's worth the effort is that the alternative is worse. The version of this project where I wait for traffic and then build personalization is a version where personalization ships months after the data that justifies it, and where every visitor in between gets a generic row that nobody is measuring.
Three signals that are built and empty
Underneath the taste terms there are three behavioral tables, rebuilt nightly from the page view log.
Co-view: two products seen in the same session. The classic signal, symmetric, and the weakest of the three.
Next-viewed: product B viewed immediately after product A, in the same session. Directional, and a sharper intent signal than co-occurrence, because the ordering carries meaning. If you looked at A and then went straight to B, A didn't satisfy you and B was the alternative you considered. That's much closer to "these compete for the same purchase" than mere co-occurrence is.
Recommendation click-through: which cards in a carousel actually got clicked, and from where. This is the only one of the three that measures the recommender against itself rather than against the catalog. Everything else tells me what shoppers do. This tells me whether my suggestions were any good.
All three currently hold almost nothing, for the reason in the previous section. Every pair also has to clear a minimum support threshold before it's trusted, deliberately low but never one, because a single visitor should never be able to manufacture a recommendation on their own.
They also have to survive the crawler filter. One session in the log walked 48 distinct products, and before it was excluded that single session was generating 96% of all co-view pairs in the catalog. Any session touching an implausible number of products gets dropped. Without that guard the "users like you" signal would have been describing a robot.
The pipeline is correct and it accumulates. Nothing here needs rewriting when the traffic arrives, which is the whole reason to build it early rather than well.
Two scores from different passes are not comparable
This one nearly shipped as a bug, and it's the kind of trap worth writing down because the numbers look so reasonable.
A product's recommendations can come from more than one pass. Same-category content matches are the good ones. When a category is too small to fill a row, cross-category fallbacks get pulled in. The score formula drops any term where either product lacks the data, then renormalizes over whatever remains.
The fallback pass has no text similarity term at all, since word weights computed inside one category aren't comparable to another's. So it renormalizes over fewer terms, and dividing by a smaller denominator inflates the result.
On one real product, the best cross-category fallback scores 0.88 while the best genuine same-category match scores 0.43. Sorting that row by score would put the weaker recommendation first, confidently, with a number that looks like twice the quality. Across the catalog, fallbacks outscore real content matches on 2,402 of the 3,570 products that have both.
The fix is that rank, assigned per pass at build time, is the display authority and score is never sorted on directly. Content rows are ranked ahead of fallback rows by construction.
The general lesson is that two numbers on the same 0 to 1 scale, produced by the same function, can still be meaningless to compare if they were computed over different inputs. The scale lies. Normalization is not the same as commensurability.
Instrument before there is anything to measure
The click tracking went in before there was a single click to record. Every card in every carousel carries its placement and its position in the row, so a click can be attributed to where it happened and how far along it was.
That felt premature when I built it. It isn't. Instrumentation is the only mechanism that converts "this seems reasonable" into "this measurably works," and it can only ever describe traffic that arrives after you add it. Every day it isn't there is a day of evidence you don't get to have. It's the cheapest thing in the whole system and the only part that can eventually tell me the rest of it was worth building.
Where this leaves it
The recommender personalizes correctly for a number of visitors that rounds to zero. The behavioral tables are accurate and nearly empty. The weights are guesses that look reasonable and that nothing has yet contradicted.
That's not a triumphant place to end, but it's where the project actually is, and I'd rather write that down than describe a system that measures its own success on data it doesn't have. The next post on this will either show the click-through numbers or explain why they never came.