Vector search makes recommendations look easy. Embed every product, store the vectors, take a nearest-neighbour query, ship it. The first version took an afternoon and the output was genuinely impressive — semantically coherent, no hand-built taxonomy, new products covered the moment they were embedded.

Then I looked at what it recommended next to a 1 kg bag of rice. Nine other bags of rice and a 5 kg bag of the same rice.

Similar is not the same as useful

The model was working exactly as designed. The bug was in my objective. Embedding similarity answers "what else is like this?", and for a retailer who has already chosen their rice, the answer to that question has no commercial value whatsoever.

Nearest neighbour is a similarity metric, not a recommendation strategy. The most similar item to something in the cart is usually a substitute — and a substitute is the one thing the buyer has already decided against.

What a wholesale buyer actually wants falls into three different shapes, and they need different signals:

  • Cross-sell — what goes with this. Bought rice, needs oil and pulses.
  • Upsell — the same thing, better margin or bigger pack.
  • Similar items — genuine substitutes, for when something is out of stock.

Only the third is a similarity problem. I had built one engine and pointed it at three jobs.

The signal embeddings cannot see

Cross-sell is fundamentally behavioural. Rice and cooking oil are not semantically similar in any meaningful sense — no embedding model will place them close together — but they appear in the same order constantly. That relationship only exists in transaction history.

The classic tool is market-basket analysis, and the metric that matters is lift: how much more often two products are bought together than you would expect if the purchases were independent. Raw co-occurrence counts are a trap, because they just rank your bestsellers — everything co-occurs with the most popular item. Lift normalises that away.

Two guards made this usable:

  1. A lift floor. Below the independence threshold, a pair is noise. Those rows get discarded rather than ranked low, because a weak signal that survives into the blend can still outvote a strong one when other signals are quiet.
  2. A minimum basket count. A pair seen once has infinite apparent lift and no information. Requiring a handful of independent baskets before a pair is eligible removes the long tail of coincidence that otherwise dominates the top of the list.

Co-occurrence is computed offline as a batch job rather than at request time. The queries are aggregate scans over order history — perfectly cheap nightly, completely unacceptable in a request path with a latency budget in the tens of milliseconds.

Blending, and why one number wins

With two signals that disagree, you need a resolution strategy. Serving them as separate carousels is tempting and I'd argue it's usually wrong: it pushes the decision onto the user, and it means two mediocre lists instead of one good one.

So every candidate pair gets a single blended score from four weighted signals:

  • Embedding similarity — the dominant signal, and the only one with coverage for brand-new products.
  • Category affinity — a strong structural prior. Catalogue hierarchy is hand-curated and therefore high quality, and it keeps results plausible when embeddings drift.
  • Shared attributes — brand, pack size, variant. A sharpener rather than a driver.
  • Co-occurrence lift — sparse but, where present, the most commercially predictive signal of the four.

Embedding carries the largest weight because it is the only signal that is never missing. Co-occurrence carries a small one despite being the most predictive, precisely because it is sparse — weighting it heavily would make recommendations lurch between excellent and random depending on whether history happened to exist for that pair.

The weights are not learned. They were set by hand, reviewed against real output, and adjusted. With no click-through data to train on, a transparent hand-tuned blend you can reason about beats a learned one you cannot — and it gives you something to replace later when the data does exist.

The embedding text matters more than the model

The biggest single quality jump did not come from changing embedding model or dimensionality. It came from changing what text I embedded.

Version one embedded the product name. Product names in a wholesale catalogue are terrible: abbreviated, inconsistently capitalised, full of pack codes, sometimes just a brand and a number. The vectors clustered on naming conventions rather than on what the products were.

Version two embedded a composed document — name, plus category path, plus structured attributes, plus the generated tags from our auto-tagging pipeline. Same model, same dimensions, dramatically better neighbourhoods. Garbage in, garbage out applies to embeddings with unusual force, because the failure is silent: you still get a vector, it still has neighbours, and nothing in the system reports that they're meaningless.

Three operational lessons

Pin your dimensionality early. I changed embedding configuration mid-build and ended up with vectors of two different widths in the same column. There is no graceful degradation here — the distance operator simply fails. A migration that re-embeds everything is the only fix, so decide once and write it down.

Index for the operator you actually query with. An approximate vector index has to be built for the distance function you use. Build it for one and query with another and it is silently ignored — you get correct results from a sequential scan and wonder why latency is terrible under load.

Set a score floor and let the shelf be short. It is tempting to always return a full row. Resist it. Below a confidence threshold the suggestions are actively harmful to trust, and four good recommendations beat four good ones followed by six bad ones. Returning fewer is a feature.

What I'd build next

The honest limitation of a hand-tuned blend is that nobody knows whether the weights are right. They are defensible, not optimal. The next step is instrumenting impressions and clicks per recommendation slot, which gives you both an evaluation signal and eventually training data — and lets you replace a reasonable guess with a measurement.

I would also separate the three recommendation types further. They currently share a scoring pipeline with different weights, which is an efficient implementation but slightly dishonest about how different the underlying questions are. Substitutes and complements are nearly opposite objectives, and eventually that deserves two systems rather than one with a parameter.