Your semantic search isn't broken — your product descriptions are

We put semantic search into our own commerce platform — Voyage embeddings, pgvector in the same Postgres as the catalog, cosine ranking blended with keyword matching. Then we asked it for something breathable for a hot summer day.
It returned sweatpants.
The instinct is to blame the retrieval
That's usually where people go: wrong model, wrong distance metric, need a reranker, need hybrid search, need to tune the weights. All plausible. All wrong here, and we would have burned a week finding that out.
Instead we isolated the pipeline. We embedded three queries with Voyage and ranked them straight against pgvector — no LLM anywhere in the loop, no blending, no boosting. Just the index answering for itself:
Q: "cosy layer for chilly weather"
1. Classic Sweatshirt cosine=0.5650
2. Essential Sweatpants cosine=0.5220
Q: "breathable for a hot summer day"
1. Essential Sweatpants cosine=0.5081 ← wrong
2. Relaxed Shorts cosine=0.5024
Q: "comfy bottoms for lounging at home"
1. Essential Sweatpants cosine=0.6563
2. Relaxed Shorts cosine=0.6308
Two of three are right, so it's tempting to call this a tuning problem. Look at the second one more closely, though. The gap between the top hit and the runner-up is 0.0057. That isn't a near-miss. That's the index telling you it can't tell these two products apart, and something has to come first.
Every score sits between 0.50 and 0.65 as well — a narrow, undifferentiated band. When your whole catalog scores about the same against any query, the ranking isn't a ranking. It's a coin toss with extra steps.
The catalog had nothing in it
So we looked at what we were actually embedding. Here are the four product descriptions, verbatim:
Classic Sweatshirt "Reimagine the feeling of a classic sweatshirt. With our
cotton sweatshirt, everyday essentials no longer have to
be ordinary…"
Essential Sweatpants "Reimagine the feeling of classic sweatpants. With our
cotton sweatpants, everyday essentials no longer have to
be ordinary…"
Everyday T-Shirt "Reimagine the feeling of a classic T-shirt. With our
cotton T-shirts, everyday essentials no longer have to
be ordinary…"
Relaxed Shorts "Reimagine the feeling of classic shorts. With our cotton
shorts, everyday essentials no longer have to be
ordinary…"
It's the same sentence four times with one noun swapped.
Embeddings can only encode what the text says. Roughly 95% of that text is identical across all four products, so 95% of every vector is identical too. The only distinguishing signal in the entire catalog is the product noun — sweatshirt versus shorts.
Now re-read the query that failed: breathable for a hot summer day. There is no breathable in any description. No hot. No summer. No warm, no season, no fabric weight. The query is asking about properties the catalog never mentions. The index did the only thing it could: it ranked noise.
The fix was four paragraphs of copy
We didn't touch the retrieval code. We rewrote the descriptions with the attributes a shopper actually searches on — fabric weight, warmth, season, what you'd wear it for:
Classic Sweatshirt "A heavyweight 400gsm brushed cotton sweatshirt built for
cold mornings. The soft fleece interior traps warmth
without bulk…"
Everyday T-Shirt "A lightweight 180gsm combed cotton t-shirt that stays
breathable on hot summer days. The airy single-jersey knit
lets heat escape…"
Then we re-ran the identical probe:
| Query | Before | After |
|---|---|---|
| cosy layer for chilly weather | Sweatshirt (0.565) | Sweatshirt (0.638) |
| breathable for a hot summer day | Sweatpants (0.508) | T-Shirt (0.621) |
| comfy bottoms for lounging at home | Sweatpants (0.656) | Sweatpants (0.701) |
| what should I wear to the beach | — | Relaxed Shorts (0.645) |
The wrong answer became the right one. But the score that matters isn't the ranking — it's the separation. The gap between first and second went from 0.006 to between 0.05 and 0.08, roughly ten times wider. The index went from guessing to deciding.
One query is still close: breathable for a hot summer day returns the tee at 0.621 and the shorts at 0.613. That gap is small, and it should be — a tee and a pair of shorts are both reasonable answers for a hot day. That's honest ambiguity, not failure. The difference between an index that's uncertain because the question is genuinely ambiguous and one that's uncertain because it has nothing to read is the whole point.
What to do before you touch your search stack
Print the exact text you're embedding — title, description, whatever attributes you concatenate — for five products, side by side. If they read like the same sentence with the nouns changed, the retrieval layer is not your problem and no amount of reranking will save it. You're asking a model to distinguish between things you never described differently.
Then ask what your shoppers actually type. Ours asked about warmth, breathability and season. Our catalog talked about reimagining the feeling of everyday essentials. Marketing copy that says nothing specific is exactly the copy an embedding cannot use.
Two honest caveats. Our catalog was four products, and the boilerplate came from a demo seed — real catalogs are rarely this uniform, so your gains will differ. And rewriting four descriptions took about ten minutes; rewriting four thousand is a different project, which is where generating attributes from images and specs starts to pay for itself.
The general point holds anyway. Vector search is a mirror. Give it thin copy and it will reflect thin copy back at you, ranked confidently and wrongly.
