compscai
All topics
· · 0 responses

Six Cards, One Ring: Building a Recommendation Engine With No Click Data

Database Management Machine Learning (ML)

I built a "you might also like" carousel for a jewelry catalog of roughly eight thousand products, on a site that hadn't accumulated real traffic yet. That second part is the whole story. Every recommendation tutorial you find assumes you already have people clicking things. I didn't. Almost no session on the site had ever viewed more than one product. Collaborative filtering, the "customers who bought this also bought" approach everyone reaches for first, needs co-occurrence data to work at all, and I had none of it.

So I built the other kind. Here's what that actually looked like, including the parts that broke.

Content-based, because there was no alternative

If you can't learn from what people did, you have to learn from what the products are. That means turning each piece of jewelry into a feature vector and finding its neighbors.

The catalog gave me almost nothing structured. There was an attribute system in the database, but every row in it stored the same single thing: the designer name. Metal, stone, carat weight, none of it was filled in. What I did have was a title and a description, so I mined the features back out of the text with regex.

Metal family, karat, color, primary stone, carat weight, stone shape, setting style, whether it's lab grown, whether it's a semi mount. Coverage landed around two thirds of the catalog for metal and stone, and about half for shape.

Two details in that extraction turned out to matter more than the rest of it:

Order the stone tests so colored stones win. The word "diamond" shows up in well over half the descriptions in a catalog like this. It barely discriminates anything. But a sapphire piece really does belong next to other sapphire pieces, so sapphire, ruby, emerald and the rest all get tested before diamond gets a chance to swallow them.

Use word boundaries or you will regret it. Without them "pear" matches "pearl" and "round" matches "around." Those two silently poison a shape column, and nothing errors out. You just get worse recommendations forever.

Then TF-IDF cosine similarity over the description text, computed per category rather than across the whole catalog. That distinction is the one I'd defend hardest. Inside engagement rings, "diamond" is in nearly every description and carries no information at all. Inside watches, the same word is highly discriminative. A single global IDF gets both of those wrong at the same time.

The bug that taught me the most

Early on I checked the score distribution and found a pile of pairs sitting at cosine 1.0. Perfect similarity. Two different products, textually identical.

They weren't a bug in the math. One SKU in the catalog had twelve variants. Same halo ring, different stone shape, different carat. The descriptions were close to word for word identical, so of course they scored 1.0. And because they scored highest, they'd take every slot in the row. A shopper looking at that ring would have been shown the same ring six more times.

The fix is a variant family key. Strip the trailing SKU segment, group by what's left, allow one product per family into any row. That worked for most of the catalog and immediately broke on the rest, which is the part worth writing down. Using made up SKUs in the same shape as the real ones:

  • ABCD-10002-E correctly stems to ABCD-10002, grouping it with its siblings.
  • AB-10 stems to AB, which merges a thousand unrelated products into one family.

So the rule needs a guard: only strip a segment when the SKU has enough structure left to survive it.

That still misses families the hyphen can't see, so I ended up with three separate ways of deciding two products are the same piece. The hyphen stem. One SKU being a strict prefix of another from the same designer, with only a character or two of difference. And same designer, same title, same exact price, which in this catalog reliably means one product listed twice.

Three rules, so I applied them in order, each one updating the family key. That was the actual mistake, and it took me a while to see it.

Grouping is not a sequence of passes

Run those rules one after another and each pass overwrites the last one's answer. The third rule would look at a product, find no duplicate sharing its exact price, and write the product's own SKU back as its family key. That silently undid the grouping the first two rules had just done correctly.

The two SKUs it split apart were the same bangle in plain and diamond versions. Same designer, near identical titles, correctly grouped by rule one and confirmed by rule two. But their prices differ, because one has diamonds in it, so rule three saw a group of one and reset it.

The right model isn't a sequence at all. Every rule is really asserting an edge: these two products belong together. Family assignment is finding the connected components of that graph. Two products are in the same family if any chain of edges connects them, no matter which rule contributed each link.

So: collect the edges from all three rules into one set, make it symmetric, then propagate the lowest SKU in each component outward until nothing changes. Bounded to a fixed number of rounds, because an unbounded loop in a nightly job is a hazard and not a feature.

That rewrite dropped the number of distinct families by about a quarter, and the near-duplicate prefix pairs showing up at rank 1 went to zero.

Some near-identical pairs still reach the top of a row, so the system reports them every night rather than pretending they're gone. Anything scoring at or near a perfect 1.0 at rank 1 gets counted and logged for review.

The tempting shortcut is to stop messing with SKUs and just collapse on title instead. That would be a disaster in this catalog: more than a thousand products share the title "Wedding Band," and nearly as many share "Engagement Ring." Collapsing on title would wipe out most of the legitimate recommendations in the store to solve a much smaller problem.

So the count stays on a watch list. That turned out to be the right call for a reason I didn't anticipate when I added it, which I'll come back to at the end.

Scoring, and what to do about missing data

Text similarity is 40% of the score. The rest is structured: metal family and karat, metal color, stone, shape, setting overlap, carat ratio, price ratio, semi mount.

The problem is that about a third of the catalog has no price on it at all. Those are the "inquire for pricing" pieces, which is to say they're the expensive ones, the merchandise the store most wants in front of people.

If a missing price scores zero, that entire cohort gets systematically buried. So instead, every term only contributes when both products define it, and the weights renormalize over whatever was actually defined:

code
score = SUM(weight_i * similarity_i, over defined terms)
      / SUM(weight_i, over defined terms)

Absent data stops being a penalty and becomes a smaller vote. That one decision is what made a third of the catalog safe to include.

For carat and price I used a plain ratio, smaller over larger, rather than bucketing into bands. It's naturally bounded between 0 and 1, it's scale free, and there are no thresholds to tune. A $900 ring next to a $1,000 ring scores 0.90 whether the catalog spans $200 or $200,000.

Two more things the real data broke

The carousel became a brand catalog. One designer accounts for a large share of the products in this store. There's a cap on how many cards a single brand can occupy, and I had applied it separately to each pass of the pipeline. Four from one pass plus four from another is eight, and the six cards a shopper actually sees came back entirely single brand. It affected hundreds of products. The cap has to run once, across the union of every pass, not inside each one.

Small categories cannot fill a row. Some categories in the catalog hold only a handful of products, and one of them contained exactly one item, which had no photo. There is no such thing as six in-category neighbors for those. So there's a category affinity table: pendant relates to necklace, bracelet to bangle, earrings to studs, each edge carrying a score penalty for crossing it. Engagement ring to wedding band gets a deliberately small penalty, and I want to be clear that's a merchandising decision and not something the model discovered. It's the highest value cross sell on a bridal site.

The crawler in the popularity data

The last resort fallback is popularity, and it's also what fills the homepage and cart carousels where there's no product to seed from.

When I first computed it, a single session was responsible for 96% of every co-view pair in the catalog. One visitor from a datacenter IP that had walked the entire product tree. Left alone, that one crawler would have set the popularity ranking for the whole store.

Two filters: drop known bots by user agent, and drop any session that touches an implausible number of distinct products, because that's a spider and not a shopper.

What survived was thin, and being straight about that is the right call. At the time I built this, nearly every visitor had viewed exactly one product, and the most viewed item in the store had been seen by a single digit number of people. Popularity was barely differentiating anything.

Rather than hide that, the tie break is a hash of the product id and the current date, which means a flat signal still produces a fresh looking row each day instead of always showing the same item forever. The moment real traffic exists the ordering becomes genuinely popularity driven, with no code change.

Actually putting it on the page

Everything above happens at night. None of it happens while somebody waits for a page to load.

Serving a row is one index lookup against the precomputed neighbor table. No cosine math in the request path, no vector index, no model call. That was the entire point of precomputing it.

I got that wrong once, in the part I thought was trivial. The popularity ranking started life as a database view, which meant every request that touched it rescanned the page view log and re-ranked the whole catalog from scratch. Around 200 milliseconds, on pages that already had plenty to do. Listing pages were landing near 0.30s against 0.06s for a product page, and almost all of that gap was the one view. Turning it into a real table that the nightly job refreshes put the listing pages back where they belonged. A view is not free just because it reads like a table.

Every placement seeds its row differently. A product page starts from the product you're looking at, the cart starts from everything in it at once, and listing pages start from what you've viewed recently, scoped to the category you're browsing. There's a second layer on top of that which re-ranks the row for the individual visitor, and it turned out to have enough in it to be its own post.

The thing worth taking away here is that all of it reads from the same precomputed table. None of it recomputes similarity at request time.

The carousel itself

The recommender is the interesting half, but what people actually see is a row of cards that slides. It's worth saying how that part is built, because there is no JavaScript in it anywhere.

That wasn't a purity exercise. The site's content security policy sets script-src without unsafe-inline, so a script in the markup gets blocked by the browser rather than politely discouraged. Whatever I built had to work in CSS or not at all.

So the pager is hidden radio inputs, one per page of results, and the arrows are labels pointing at them. Clicking an arrow checks a radio, and a sibling selector slides the track. The dots underneath light up the same way. On a narrow screen all of that is dropped in favor of native horizontal scrolling with scroll snap, which is better than arrows on a touchscreen regardless.

I tried anchor links for the arrows first. They work, but they put a hash in the URL and jump the page vertically on every click, which feels broken even though nothing is.

The bug worth passing on is about ids. The first version used one fixed radio group name and fixed ids, which is completely fine right up until a second carousel appears on the same page. The cart renders one. The listing pages render one under the products. The moment two showed up together they shared a radio group and fought each other, so clicking next on one slid the other.

The fix is a counter that namespaces every instance, so the second carousel on a page gets its own group and its own ids. Small thing, but it's a bug that can only appear once a component is successful enough to get used twice, and it will wait for exactly that moment.

One renderer covers all six placements, and each one passes its own attribution tag so the click data can tell a product page row apart from a cart row.

How I evaluated it

Honestly: I couldn't, not on relevance. There was no click data to check against, and I wasn't going to invent an accuracy number.

What I could do was assert coherence. The nightly job runs a QA gate that fails loudly rather than shipping something broken:

  • No product recommends itself.
  • No product recommends its own variant family.
  • No variant family appears twice in the same row.
  • No card points at a product with no usable image.
  • Content-sourced recommendations never leak across categories.
  • Ranks are contiguous, with no gaps or duplicates.
  • No product with a photo has fewer than four recommendations, because a short row reads as broken.

Alongside those, a set of numbers that get reported but never enforced, because a hard threshold on a merchandising judgment would fail the build for the wrong reason: price coherence between a product and its recommendations, brand diversity, and the score distribution at rank 1. That last one is a tripwire. A spike at 1.00 means the variant de-duplication has stopped working.

Those checks aren't theoretical. Two of them exist because they caught real bugs before launch: a pass that skipped de-duplication and produced over a thousand duplicate rows, and the single-brand carousel problem above.

Then I instrumented click tracking, so every card carries attribution parameters on its link. Click-through rate becomes measurable the moment anyone clicks. "I can't measure relevance yet, so here is exactly what I could measure, and here is the instrumentation that makes the real metric possible" is a more useful answer than a confident number nobody checked.

Why not embeddings

Fair question, and I considered it. A few thousand products with short descriptions is small. TF-IDF converges fine at that scale. Neural embeddings would mean a Python environment, a model dependency, and a vector index on a site that currently has none of that, in exchange for no gain I could actually demonstrate.

The whole pipeline is SQL and rebuilds in about a minute a night, and as covered above, serving it costs one index lookup.

Knowing when not to reach for the heavier tool is part of the job.

Built to get better on its own

The part I'm most pleased with is what happens when the traffic shows up.

Three behavioral tables already exist and already rebuild nightly: products viewed in the same session, products viewed immediately after one another, and which recommendations actually got clicked. Today they hold almost nothing, because the median visitor to this site has viewed exactly one product and left. The ranking already reads all three and falls back cleanly to plain content rank when they come back empty, which right now is nearly every request.

The stored recommendations also carry a source column with values reserved for the collaborative signals that don't have enough support yet.

None of it needs rewriting when the data arrives. That, and what "personalized" actually means when almost nobody has any history, is the subject of the follow-up.

The takeaway

The algorithm was the easy part. TF-IDF and cosine similarity are textbook, and you can read them anywhere.

What actually took the time was the catalog itself. Finding a wall of perfect 1.0 scores and tracing them back to variant SKUs. Catching a single crawler before it defined popularity for an entire store. Working out that missing prices had to be renormalized around instead of scored as zero, or the most valuable inventory in the store would sink to the bottom.

Every one of those was a data problem wearing a modeling problem's clothes. If you're building something like this, budget your time accordingly, and look hard at your score distribution early.

And here's the part I promised to come back to. Those variant family passes each exist to group products together, which means every one of them should reduce the number of distinct families, or leave it alone. It can never increase. That's not a preference, it's arithmetic.

Mine went up by nearly a third, and I had that number in front of me the whole time. I read it as a stale comment rather than a contradiction, which cost me more than writing the check would have.

The build now counts distinct families before and after the grouping step and throws if the number rose. It's about eight lines. A pipeline that merges things should be able to prove it merged them, and if it can't, the first thing it will do is quietly stop merging.

The bugs that hurt aren't the ones that throw errors. They're the ones that quietly produce a plausible looking answer, in a number you already had in front of you.

0 responses