update date, reduce slop

This commit is contained in:
Dylan Couzon
2026-07-24 23:07:33 -04:00
parent 3958d494ec
commit 93ee9d6e8c
@@ -6,7 +6,7 @@ short_description: "We built e-commerce search over 5.8M real products on Qdrant
description: "Build e-commerce search on Qdrant: lessons on hybrid ranking, in-query filtering, embedding recipes, personalization as re-rank, and merchandising formulas."
preview_image: /blog/ecommerce-search-qdrant/hero.jpg
social_preview_image: /blog/ecommerce-search-qdrant/hero.jpg
date: 2026-07-17
date: 2026-07-24
author: Dylan Couzon
featured: true
tags:
@@ -17,27 +17,27 @@ tags:
- recommendations
---
E-commerce search is rarely one system. Relevance, filtering, personalization, merchandising, and recommendations each tend to arrive as a separate service, and the final ranking gets stitched across all of them. What a shopper sees is the sum of systems that were never built to agree.
Relevance, filtering, personalization, merchandising, and recommendations usually arrive as five separate services, and the final ranking gets stitched across all of them. Each service ranks by its own rules, and none of them owns the order a shopper ends up seeing.
We built [Qdrant Shopping](https://demo-ecommerce-search.vercel.app/), a storefront over 5.8 million real Amazon fashion products, to find out how many of those pieces collapse into one. Every text search is a single request to Qdrant's [Query API](/documentation/search/hybrid-queries/) that returns a ranked shelf in about 40 milliseconds, and the [code is on GitHub](https://github.com/qdrant-labs/demo-ecommerce-search). The decisions below apply to almost any product catalog, and we got several of them wrong before we got them right.
![Qdrant Shopping storefront: a search for running shoes, faceted category counts, and a per-product score breakdown, all from one Query API request](/blog/ecommerce-search-qdrant/storefront.png)
## Make Hybrid the Default, Not an Upgrade
## Start With Hybrid Retrieval
A product query is two queries at once. Part of what a shopper types is an exact token: a brand, a size, a model number, a SKU, where the right result literally contains the string. The rest is intent, "something warm for hiking," where the right result may never use those words. Dense vectors handle the intent and fumble the exact tokens; keyword search nails the tokens and misses the intent.
A product query is two queries at once. Part of what a shopper types is an exact token: a brand, a size, a model number, a SKU, where the right result literally contains the string. The rest is intent, "something warm for hiking," where the right result may never use those words. Dense vectors match the intent and drift on the exact tokens. BM25 matches those tokens and has no way to reach "warm for hiking."
So the storefront retrieves with both: a dense vector built from the product title and category, a BM25 sparse vector built from title, brand, and category, fused with reciprocal rank fusion in one Query API request. For a product catalog, hybrid is the floor, not the upgrade you reach for once relevance complaints pile up.
So the storefront retrieves with both: a dense vector built from the product title and category, a BM25 sparse vector built from title, brand, and category, fused with reciprocal rank fusion in one Query API request. Both branches belong in the first version of a product search, before any relevance complaints arrive.
## Filter Inside the Query
Every category page, price bracket, size, and in-stock toggle is a filter, and where you apply it decides whether the page survives. Retrieve the top vector matches first and filter after, and a strict filter empties the page: you fetched 100 candidates, 90 were out of stock, and the shopper sees 10 results while better matches sit barely outside the window you pulled. Qdrant applies the filter during the search instead, walking its filterable HNSW graph so only matching products are ever scored, and the [facet counts](/documentation/search/filtering/) down the side of the page come from the same payload.
Every category page, price bracket, size, and in-stock toggle is a filter, and the placement of that filter decides whether the page comes back full. Retrieve the top vector matches first and filter after, and a strict filter empties the page: you fetched 100 candidates, 90 were out of stock, and the shopper sees 10 results while better matches sit barely outside the window you pulled. Qdrant applies the filter during the search instead, walking its filterable HNSW graph so only matching products are ever scored, and the [facet counts](/documentation/search/filtering/) down the side of the page come from the same payload.
One rule bites everyone exactly once: index every payload field you filter, sort, group, or reference in a formula. On Qdrant Cloud an unindexed field in any of those positions returns an error instead of a silent slow scan, which is the engine telling you to declare the field up front rather than discover the cost in production.
Index every payload field you filter, sort, group, or reference in a formula. On Qdrant Cloud an unindexed field in any of those positions returns an error instead of a silent slow scan, so the schema has to declare the field before the first query uses it. Every team hits this once, and the error is cheaper than finding the missing index through production latency.
## What You Embed Matters More Than Which Model
A quality bug looked like a job for a bigger model: a search for a shirt was ranking on the brand rather than the garment. It was not the model. The title alone gave the embedding too little to anchor on, so brand tokens dominated the vector. Adding the product category to the embedded text fixed the drift, and it fixed it on every model we tried.
A quality bug looked like a job for a bigger model: a search for a shirt was ranking on the brand rather than the garment. The fix turned out to be in the input text. The title alone gave the embedding too little to anchor on, so brand tokens dominated the vector. Adding the product category to the embedded text fixed the drift on every model we tried.
We benchmarked the bigger model anyway. The table compares precision@10 on a 200,000-product subset with the recipe applied to both (scored by the LLM judge described below), then the latency and memory each model costs after re-ingesting the full 5.8 million products:
@@ -52,13 +52,13 @@ Two points of precision@10 cost 4.5 times the query latency, 3 times the RAM, an
The dense vectors live in RAM as int8, with the float32 originals on disk. Qdrant can [rescore](/documentation/manage-data/quantization/) the top candidates against those originals to undo quantization error, and we assumed a catalog this size would need it. Measured, the rescore bought 2 points of recall@10 at best while multiplying query time by 4.5 to 8, from 41 ms to as much as 334 ms, so we shipped without it.
The rescore reads originals from disk, and reciprocal rank fusion damps the small ordering errors quantization introduces. Quantization noise matters when one score decides the final order directly; when that score only feeds a rank fusion, it mostly washes out, and paying disk reads to correct it buys nothing a shopper can see.
The rescore reads originals from disk, and reciprocal rank fusion damps the small ordering errors quantization introduces. Quantization noise matters when one score decides the final order. Here that score only feeds a rank fusion, where the small errors wash out, and the disk reads that correct them change nothing the shopper sees.
## Personalize in Ranking, Not Retrieval
Our first pass added the shopper's taste vector, built from their purchase history, as a third retrieval branch fused with the dense and keyword branches. A search for "jeans" could then return a flannel shirt, because the taste branch contributed candidates the query never asked for.
So we moved taste out of retrieval and into ranking. The text query decides what qualifies; taste only reorders the page it returns, blended into the final rank at a weight of 0.45. Against the heavier 0.6 we started with, 0.45 held recall (0.82 versus 0.79) while cutting off-query items in the top 10 from 1.7 per query to 0.03. Below 0.45 the personas start converging on the same results with no further gain, so the shipped weight is a chosen point, not just the lowest one we tried.
So we moved taste out of retrieval and into ranking. The text query decides what qualifies; taste only reorders the page it returns, blended into the final rank at a weight of 0.45. Against the heavier 0.6 we started with, 0.45 held recall (0.82 versus 0.79) while cutting off-query items in the top 10 from 1.7 per query to 0.03. Below 0.45 the personas start converging on the same results with no further gain, so 0.45 is where both numbers hold and that is what we shipped.
Two edge cases need a decision up front. A query with no text, a landing page, has no intent to protect, so taste drives the whole ranking there. A shopper with no history gets the plain query ranking, which is the right cold-start default.
@@ -66,19 +66,19 @@ Two edge cases need a decision up front. A query with no text, a landing page, h
## Merchandising Is a Set of Weights
Margin, popularity, freshness, price, rating, and stock are business signals, and the instinct is to build a service that reorders search results according to them. We put them in the ranking instead. One [Formula Query](/documentation/search/hybrid-queries/) rescore runs after fusion and composes the final score from those payload fields, and a campaign like "clearance" or "new arrivals" is a set of weights on that formula rather than a separate code path.
Margin, popularity, freshness, price, rating, and stock are business signals, and the usual answer is a service that reorders search results according to them. We put them in the ranking instead. One [Formula Query](/documentation/search/hybrid-queries/) rescore runs after fusion and composes the final score from those payload fields, and a campaign like "clearance" or "new arrivals" is a set of weights on that formula rather than a separate code path.
Presets and sliders change the weights, and the server clamps each to a fixed range. A merchandiser can retune a campaign without a deploy, and a bad slider value can't reshape the pipeline, only change how much a signal counts. That hands a merchandising desk to non-engineers without a second ranking service to keep in sync with search. Model the rules that change often as weighted terms in one formula, and keep the pipeline shape in code.
Presets and sliders change the weights, and the server clamps each to a fixed range. A merchandiser can retune a campaign without a deploy, and a bad slider value can't reshape the pipeline, only change how much a signal counts. That hands a merchandising desk to non-engineers without a second ranking service to keep in sync with search.
![Qdrant Shopping merchandiser desk: campaign presets and weight sliders for relevance, margin, popularity, freshness, price, and rating](/blog/ecommerce-search-qdrant/merchandiser.png)
## Every Eval Has a Blind Spot
Our first relevance eval was a golden set: fixed expected product IDs per query. One recipe change took a 60-query fixture to 27% strict match overnight, not because relevance dropped but because the expected IDs were the old model's own output. The eval wasn't measuring relevance; it was measuring distance from the model that built it, so every change scored as a regression.
Our first relevance eval was a golden set: fixed expected product IDs per query. One recipe change took a 60-query fixture to 27% strict match overnight, and relevance had not dropped: the expected IDs were the old model's own output. The fixture measured distance from the model that built it, so every change to the pipeline scored as a regression.
We replaced it with an LLM judge that asks, for each returned result, whether it is relevant to the query. It reads the results rather than an answer key, so it never anchors to any model, which is what made the model comparison above possible; the shipped pipeline scores 96% by this judge on the full catalog. But the judge is blind too: it only sees what came back, so it measures precision and says nothing about what retrieval missed.
That is why the numbers in this post come from a small battery rather than one score. The judge scores precision, recall@10 catches what a cheaper setting drops, intrusion counts off-query items personalization sneaks into the top 10, and persona overlap checks that personalization still tells shoppers apart. Each metric exists because the one before it was blind exactly there. Even the golden set keeps a job: it is cheap, deterministic, and reliable as long as the model is frozen; ours only broke when we started iterating. Whatever mix you can run, the useful exercise is naming what each eval cannot see, then covering that spot with the next one.
That is why the numbers in this post come from a small battery rather than one score. The judge scores precision, recall@10 catches what a cheaper setting drops, intrusion counts off-query items personalization sneaks into the top 10, and persona overlap checks that personalization still tells shoppers apart. Each one was added after the metric before it missed something. Even the golden set keeps a job, since it is cheap, deterministic, and reliable while the model stays frozen, and ours only broke once we started iterating. For any eval you run, name what it cannot see, then add the metric that covers it.
## One Gotcha: Fusion and Shards