Type “red running shoes under 2000” into a shopping app and you are not asking for pages that contain those five words. You are filling in a form you cannot see: category sports shoes, colour red, price below ₹2,000, best bets first. Product search borrows the machinery of web search — an inverted index, BM25, shards — but almost everything around it changes. Documents are structured records with dozens of attributes; every results page carries facet counts; ranking weighs sales, ratings, delivery speed and sponsored slots, not just text. Prices and stock change thousands of times a second during a sale, and a card that says ₹1,799 must still say ₹1,799 at checkout. And when a Big Billion Days–style sale opens at midnight, traffic jumps tenfold in minutes. Here you design search for a marketplace with 100 million listings from many sellers.
Understand the problem
Open with what is different from web search; that is what the interviewer is listening for. A product is a record with structure — brand, category, size, price — and shoppers filter on it as much as they type. The corpus is small by web standards, but it changes far faster: on sale day a phone’s price and stock change every few seconds. And the goal is not “the most relevant document” but “the product this person will buy and we can deliver”.
Functional requirements
- Users should be able to search the catalogue with free text — “iphone 15 128gb”, “kurti for women cotton”, typos and Hinglish included — and get one card per product, showing its best offer.
- Users should be able to narrow results with filters and facets — brand, price, size, rating, “delivery by tomorrow” to their pincode — and see a count for each facet value.
- Users should be able to sort and page through results by relevance, price, rating or newest, deep into a category, without slowing down.
- Results should reflect the catalogue as it is now. A price change or a sell-out shows within seconds, a new listing within minutes, and a listing that must come down disappears almost at once.
Non-functional requirements
- Latency. p99 under 200 ms for results plus facets, measured at the search service; p50 under 80 ms.
- Throughput. About 20,000 searches/s on a normal day. In the first hour of a big sale, plan for 10×: 200,000 searches/s.
- Freshness. Price and stock on result cards within 2 s of a change; price filters, price sorts and in-stock filters within 15 s; new listings within 5 minutes; takedowns within a few seconds.
- Write rate. About 5,000 price and stock events/s normally and 50,000/s during a sale.
- Availability. 99.95%, about 22 minutes of downtime a month (43,200 minutes × 0.0005). Search is the shop’s front door: it should degrade — fewer facets, cached results — long before it fails.
- Scale. 100 million active offers; delivery promises to roughly 20,000 pincodes.
- Measurable relevance. Track conversion per search and the zero-result rate; every ranking change ships behind an A/B test.
In the interview
Ask three questions early: “Is this a marketplace with many sellers per product, or a single-seller shop?”, “Does what we can deliver depend on the shopper’s location?” and “On sale day, which matters more — exact facet counts or staying up?” The answers decide your document model, whether you need a pincode filter, and what you degrade first.
Out of scope
- Typeahead. The suggestion dropdown above the results is its own system; see design typeahead.
- Recommendations such as “customers also bought” (recommendation feed).
- The ads auction. We reserve sponsored slots and blend in what the ads system returns, behind a relevance floor.
- The catalogue system of record. Sellers edit listings in a catalogue service with its own database; search is a read-optimised copy. Cart and stock reservation belong to checkout and cart.
- Web-scale index internals such as postings compression; see design a search engine like Google. A 100-million-listing catalogue fits an off-the-shelf engine.
The set-up
Plan the approach
Say the shape early: “an indexing pipeline that turns the catalogue into a read-optimised index; an online path that turns a query into a structured query and then a ranked page; and a fast lane for price and stock that bypasses the index.” Then spend most of your time in the deep dives.
Pick a known engine and say so. A Lucene-based engine such as Elasticsearch or OpenSearch gives you an inverted index for text, columnar doc values for facets and sorting, near-real-time refresh and replication. Interviewers want to see you use one well, and know where it hurts.
Core entities
- Product. What a shopper thinks of as one thing: “Apple iPhone 15”. Brand, category, shared description, reviews.
- Variant (SKU). One buyable configuration: “iPhone 15, 128 GB, black”, with attributes such as colour, storage or size.
- Offer (listing). One seller selling one SKU: price, MRP, stock per warehouse, delivery promise, seller rating.
- Search document. One per variant, denormalised from all three (deep dive 1). This is what the index stores, filters and scores.
- Live offer state. Per SKU: best price, in-stock flag, warehouses holding stock, version. Small, changes constantly, lives outside the index.
- Parsed query. What the user typed, structured: remaining text, predicted category, filters, boosts, sort, cursor, pincode.
A search document for one shoe variant looks like this:
{
"sku_id": "P77-RED",
"product_id": "P77",
"title": "Stride Pro Men's Running Shoes",
"brand": "Stride",
"category_path": ["footwear", "footwear>sports-shoes", "footwear>sports-shoes>running"],
"colour": "red",
"sizes_in_stock": [8, 9, 10],
"attrs": ["gender=men", "sole=eva", "closure=lace-up"],
"price": 1799,
"price_band": "1500-2000",
"in_stock": true,
"stock_wh": ["BLR3", "DEL2"],
"seller_count": 4,
"rating": 4.3,
"rating_count": 12840,
"sales_7d": 5210,
"listed_at": "2026-09-14"
}
Title and description are text fields, analysed for BM25. Brand, colour, category, attributes and warehouse IDs are keyword fields for exact filters and counts. Everything you filter, sort or count on keeps doc values, the columnar copy built for that.
API
One public endpoint does nearly all the work:
GET /v1/search?q=red+running+shoes+under+2000&pincode=560001&brand=nike,puma&size=9&sort=relevance&limit=40
Accept-Language: en-IN
{
"interpreted": {
"text": "running shoes",
"category": "footwear>sports-shoes",
"filters": { "price_max": 2000 },
"boosts": { "colour": "red" },
"spelling": null
},
"total": { "value": 1000, "relation": "gte" },
"results": [
{
"product_id": "P51",
"sku_id": "P51-RED",
"title": "Nike Zoom Men's Running Shoes",
"price": 1799,
"mrp": 2999,
"rating": 4.3,
"delivery": "Tomorrow",
"other_colours": 3,
"sponsored": false
}
],
"facets": {
"brand": [
{ "value": "Nike", "count": 412, "selected": true },
{ "value": "Puma", "count": 268, "selected": true },
{ "value": "Asics", "count": 97 }
],
"size": [ { "value": "9", "count": 530, "selected": true } ],
"price": [ { "value": "1000-1500", "count": 220 }, { "value": "1500-2000", "count": 310 } ]
},
"next_cursor": "eyJzIjpbMC44MSwiUDc3LVJFRCJdfQ"
}
Three details matter. The response echoes how the query was interpreted, so the app can show “Running shoes · under ₹2,000” as chips the shopper can remove when a guess is wrong. The total is a lower bound; counting every match of a broad query is wasted work. And next_cursor is opaque: it encodes the last hit’s sort values for keyset paging.
Catalogue and offer changes arrive as events, not API calls. A small admin API serves the trust and catalogue teams:
POST /v1/search/suppress body: sku_ids, reason hide listings everywhere within seconds
POST /v1/search/synonyms body: rule-set version publish a reviewed synonym set
POST /v1/search/reindex body: mapping version build a new index, then swap the alias
Back-of-envelope estimates
Show the arithmetic; these numbers decide the shard count, the fleet size and why price updates need their own path. (For the method, see back-of-envelope estimation.)
Documents. 100M offers ÷ about 2 offers per SKU = 50M variant documents, grouped into about 20M products.
Index size. Assume 5 KB on disk per document, all-in: analysed title and a trimmed description, about 30 attributes, doc values and the compressed source. 50M × 5 KB = 250 GB per copy.
Shards. Elasticsearch’s shard-sizing guidance is 10–50 GB and under 200 million documents per shard. 250 GB ÷ 25 GB = 10 primary shards of 5M documents each. Each shard searches on one thread, so moderate shards keep per-thread work and recovery time small without adding fan-out to every query.
Query cost. Assume one shard-level search — filters, BM25 over a few fields, about eight facets and a rescore — costs 4 ms of CPU on average (far more for “shoes”, far less for “iphone 15 128gb black”). Every query touches all 10 shards: 40 ms of CPU per search. Benchmark this on real data; everything below hangs off it.
Normal fleet. 20k searches/s × 40 ms = 800 cores busy. At 50% target utilisation that is 1,600 cores: 50 nodes of 32 cores, 5 copies of each shard. Each node holds one 25 GB shard copy, which fits in the filesystem cache of a 64 GB machine — the cache Elasticsearch’s tuning guide calls probably the engine’s number one performance factor.
Sale peak. 200k searches/s. A result cache for head queries absorbs, say, 40%, so 120k/s reach the index. 120k × 40 ms = 4,800 cores; at 60% utilisation, 8,000 cores: 250 nodes, 25 copies of each shard. The tuning guide’s replica rule agrees: max(failures, ceil(nodes ÷ primaries) − 1) = ceil(250 ÷ 10) − 1 = 24 replicas.
Write amplification. In Elasticsearch a partial update still reindexes the whole document, and every replica repeats the work. Writing 50k price and stock events/s naively means 50k × 25 copies = 1.25M document re-indexes a second — 5,000 per node, in the hour each node is busiest searching. Deep dive 2 exists because of this number.
Text changes. About 2M listings created or edited a day: 2M ÷ 86,400 ≈ 23/s. Trivial.
Live offer store. 50M SKUs × about 200 bytes (best price, MRP, stock flag, warehouse IDs, delivery promise, version) = 10 GB. Each search hydrates about 60 SKUs (40 shown plus spares): 200k × 60 = 12M key reads/s at peak, in batched multi-key reads. At 500k keys/s per primary, that is 24 primaries — a modest Redis cluster.
Tip
Product search is small in bytes and big in operations. The whole index would fit on one large server; what forces 250 nodes is CPU per query at peak, and what threatens those nodes is write amplification from fields that change every second.
High-level design
Build the design one requirement at a time. By the end of this section it works end to end for one region at moderate traffic.
Users should be able to search the catalogue with free text
Start with the simplest thing that works: a search service in front of a search index, and an indexer that copies the catalogue into it.
- The app calls
GET /v1/searchthrough the API gateway, which authenticates and rate-limits. - The search service turns the text into a match across title, brand, category names and attributes, title weighted highest.
- The index scores matching documents with BM25 per field — the search engine design walks through the formula — and returns the top 40.
- The search service builds the product cards.
The indexer reads the catalogue database every night, joins products, variants and offers into documents and bulk-loads a fresh index. Many small shops start here. But it fails the fourth requirement badly, and it has no answer yet for facets or paging.
Users should be able to narrow results with filters and facets
Facets turn search into browsing. When a shopper ticks Nike, Puma and size 9, one request must filter the hits, recount the other facets, and keep counting the brands they did not tick, so they can add Asics.
- Filters run in filter context. Category, price and in-stock are yes-or-no conditions. They do not affect the score, and the engine caches their results as bitsets per segment, so repeating them is nearly free.
- Facets are aggregations over the matched set. A
termsaggregation onbrandcounts matches per brand by reading doc values. Price buckets come from arangeaggregation or, faster, a pre-computedprice_bandkeyword. - Ticked facets go in
post_filter. Elasticsearch applies a post filter to the hits only, after the aggregations are calculated, so the brand facet still counts Asics while the hits show only Nike and Puma. Every other facet gets its own filtered aggregation that does apply the brand choice — size counts must reflect “Nike or Puma”. This is multi-select faceting. - The facet list depends on the category. Phones get RAM, storage and battery; kurtas get fabric, sleeve and occasion. A facet configuration per category, chosen from the predicted category, keeps responses small and relevant.
Users should be able to sort and page through results
Sorting by price or rating reads doc values, so ordering 40,000 matches is cheap. Paging is where naive designs fall over. With from and size, page 200 at 40 results a page means from = 7,960: every shard must collect its own top 8,000 hits and send them to the coordinating node, which merges 80,000 entries to keep 40. Elasticsearch refuses this beyond 10,000 hits by default (index.max_result_window) — at 40 a page, page 250 is the last one you can reach.
Use keyset pagination instead (see offset versus keyset pagination). Sort by the requested key plus a unique tiebreaker such as sku_id, and send the last hit’s sort values back as search_after; each shard starts just past the cursor. The app only sees the opaque next_cursor. Two catches: results can shift between pages if the index refreshes in between (a point-in-time view freezes them, at the cost of holding resources open on every shard), and you cannot jump to page 137. Nobody shops on page 137; offer infinite scroll and better filters instead.
Results should reflect the catalogue as it is now
Replace the nightly build with streams. The catalogue database publishes every change through change data capture into a Kafka topic; an indexer enriches each change — normalised attributes, predicted category, synonyms — and bulk-loads it into the index, searchable at the next refresh.
Prices and stock take a different route: from the offer and inventory services into a live offer store, keyed by SKU, which the search service reads when it builds the final page. Only changes that alter a filter or sort — a SKU selling out, a big price move — reach the index.
Every requirement now has an answer for one region at moderate traffic. The deep dives cover what breaks at 100 million listings, 200,000 searches and 50,000 price changes a second.
Deep dives
How do we model products, variants and offers as search documents?
The catalogue is a three-level tree — product, variants, offers — and the index wants flat documents. Where you cut the tree decides whether attribute filters are correct, whether pages fill with duplicates, what facet counts mean and what a stock change costs.
Bad: one document per offer. Index every seller listing: 100M documents. Easy to keep in sync, but the black iPhone 15 appears 30 times, once per seller, and pushes everything else off page 1. Facet counts become listing counts, and you deduplicate on every request.
Bad: one document per product, with variant values in arrays. Index 20M products with colour: [red, blue] and sizes_in_stock: [9, 10]. Results are clean, but arrays forget which values belong together: if the shoe exists only in red size 10 and blue size 9, a search for red in size 9 matches a product nobody can buy.
Good: one document per product, with variants as nested objects. A nested field keeps each variant’s values together, so “red AND size 9” must match inside one variant. Correct, but costly: Elasticsearch indexes each nested object as a separate hidden document, so a product with 40 variants is 41 documents, and the search-speed guide warns that nested queries can be several times slower and parent-child joins hundreds of times slower. One variant’s stock change rewrites the whole block.
Great: one document per visual variant, sizes folded in, collapse at query time. Make the document the variant that looks different in a photo: colour for shoes and clothes, colour and storage for phones. Fold the other dimensions into a multi-valued field of what is in stock (sizes_in_stock: [8, 9, 10]), so “red in size 9” is a plain conjunction on one document. Summarise the offers on it: best price, seller count, fastest delivery, warehouses holding stock. At query time, collapse on product_id, so each product appears once with its best-ranked variant and a “+3 colours” hint.
You gain correct attribute matching, fast flat queries and one card per product. You pay three ways. Collapse applies to the top hits only, not to aggregations, so facet counts count variants rather than products — one reason they are shown as rough numbers. Product-level text is copied into every variant, which is why the description is trimmed. And you hold 2.5× more documents than products.
| Per offer | Per product, arrays | Per product, nested | Per visual variant | |
|---|---|---|---|---|
| Lucene documents | 100M | 20M | 20M parents + 50M hidden | 50M |
| “Red in size 9” correct | Yes | No | Yes | Yes |
| One card per product | Only after deduplicating sellers | Yes | Yes | Yes, with collapse |
| Facet counts count | Listings | Products | Products | Variants |
| Cost of one stock change | One small document | Whole product | Whole product block | One variant |
| Query speed | Fast | Fast | Several times slower | Fast |
A few mapping rules finish the job:
- Text and keyword. Brand is usually both:
text, so “nike shoes” scores on it, andkeywordfor its facet. Model numbers are keywords, matched exactly. - Category as a path. Store every ancestor (
footwear,footwear>sports-shoes, …), so a filter at any level is one term. - No mapping explosion. There are thousands of attribute names across categories, and every mapped field costs heap in every segment. Index them as
name=valuekeywords in one field (ram=8gb,fabric=cotton) and promote only the most-used dozen to real fields. - Normalise at index time. “Maroon”, “crimson” and “red” all get
colour_family: red; “128 GB” and “128gb” both becomestorage=128gb. The query side applies the same rules.
How do we keep price and stock fresh without melting the index?
On sale day a popular phone sells several units a second and sellers reprice against each other. The index needs some of this — you cannot filter on “in stock” or sort by price without it — but not all of it, and not every time.
Three facts make naive updates expensive. Lucene segments are immutable: the update API gets the document, applies your change and indexes the result, so changing one integer rewrites the whole 5 KB document. Each in-sync replica repeats the operation. And changes appear only at the next refresh — by default every second on a searched index — which also empties the shard’s request cache.
Bad: write every event into the search document. It scales with the worst available number: 50k events/s × 25 copies = 1.25M re-indexes a second, competing with search for CPU and disk at the peak, piling up tiny segments to merge, and resetting caches every second. Instacart lived a version of this: in its Elasticsearch-based search, frequent partial writes for price and availability created so much indexing load that fixing bad data could take days.
Good: coalesce, filter and batch. Put a coalescer between Kafka and the indexer:
- Latest wins per SKU. Buffer a second or two and keep only each SKU’s newest state; a phone selling ten units a second becomes one update.
- Forward only what search uses. Stock going from 37 to 36 changes nothing searchable. Forward a change only if it flips
in_stock, changessizes_in_stockorstock_wh, or moves the price by more than, say, 1%. - Bulk, with versions. Send batches of a few thousand through the bulk API, with the event’s version as an external version so a late, older event never overwrites a newer one. Updates that change nothing come back as
noop.
With these assumptions — 60% of events superseded within the same second, and three quarters of the rest invisible to search — 50k events/s become 5k index updates/s: 125k re-indexes a second across the cluster, 500 per node. You gain a 10× cut in write load. You pay a couple of seconds of lag, and an index price that can be up to 1% off.
Great: two speeds — a coarse index plus a live overlay. Stop asking the index to be exact. It keeps the search-relevant versions of the fast fields, fed by the coalesced path, and refreshes every 10 seconds instead of every second. A live offer store, keyed by SKU and fed from the same Kafka topic, holds the exact price, stock and delivery promise within about a second. The search service takes candidates from the index, hydrates the best 60 from the live store, drops anything that just sold out, re-checks the price filter and shows the exact price.
You gain exact prices on every card within a second, a tenth of the naive writes, and caches that live 10 seconds instead of one. You pay in overfetching and edge cases: an item that sold out five seconds ago still comes back from the index, so you fetch spares (60 for a page of 40) and a page can occasionally come up short; facet counts can trail the cards by a few seconds; and two copies of the truth must agree. Instacart’s post names the same costs — overfetching and post-filtering when availability lived outside the index — and it eventually moved retrieval to sharded Postgres with a normalised model, reporting a 10× cut in write workload. That is a valid answer too, when queries are simpler and writes extreme.
| Write every event | Coalesce and filter | Coarse index + live overlay | |
|---|---|---|---|
| Index re-indexes per second | 1.25M | 125k | 125k |
| Price on result cards | About 1 s, if the cluster copes | Within 1%, seconds late | Exact, about 1 s |
| Price filters and sorts | About 1 s | A few seconds | Up to about 12 s |
| Request cache lifetime | 1 s | 1 s | 10 s |
| What you pay | An index that melts at peak | A threshold to tune | Overfetching, two truths |
Two more pieces complete the pipeline:
- A priority lane for takedowns. A counterfeit, a legal request or a ₹1 pricing bug must not queue behind a million price updates. Takedowns go on their own topic to a suppression list that every search pod holds in memory and checks on every response; the listing vanishes within a second or two.
- Full rebuilds behind an alias. A new mapping, analyser or synonym model needs a full reindex. Build a new index in the background, catch it up from Kafka, compare it with shadow queries, then move the read alias in one atomic step, keeping the old index for rollback — blue-green deployment for data (see deployment strategies). At 25k documents/s, 50M documents take 2,000 s, about 33 minutes; with replicas and warm-up, plan for two hours.
Do not refresh per write
Adding ?refresh=true to every price update looks like a freshness fix. It forces a new tiny segment on every call, empties the request cache each time and leaves segment merging to clean up after you. Anything that must be visible at once belongs in the live store or the suppression list, not the index.
How do we understand what the shopper meant?
Shoppers type short, messy queries: “iphone 15 128gb”, “red runing shoes under 2000”, “jooti for men”, “साड़ी”. The Amazon team behind Semantic Product Search lists why pure lexical matching falls short in shopping: no grasp of hypernyms, synonyms and antonyms, fragility to word forms such as “woman” versus “women”, and sensitivity to spelling errors. A query understanding step before retrieval fixes most of that.
Bad: pass the raw text to the engine. A text match on “red runing shoes under 2000” looks for the word “runing” and the token “2000”. It finds products with “2000” in the model name, misses most running shoes and ignores the budget.
Good: normalise, correct and expand, with guardrails.
- Normalise identically at index and query time. Lower-case, Unicode-normalise, join units (“128gb” and “128 GB” both become
storage=128gb), and stem plurals so “women” matches “woman”. - Tolerate typos, but not everywhere. Algolia, a hosted search engine, by default allows one typo in words of 4 or more characters and two from 8, ranking exact matches first; Elasticsearch’s
fuzziness: AUTOallows no edits for 1–2 characters, one for 3–5 and two beyond. But in shopping, one edit matters most in numbers and model codes: “iphone 14” and “iphone 15”, or “s23” and “s24”, are different products. Algolia’s docs suggest turning typo tolerance off for numbers in fields like postal codes and for SKU codes; do the same for model codes and brands, and prefer corrections mined from query logs — when thousands of people type “runing” and immediately retype “running”, the fix is safe. - Synonyms, one-way where needed. “tee” and “t-shirt”, or “mobile” and “phone”, work both ways. But “phone” must not expand to “phone case”, “apple” must not mean “iphone” in groceries, and “sugar free” must not match “sugar”. Keep synonym sets reviewed, versioned and per category.
- Hinglish and transliteration. Map “jooti” and “joota” to footwear, “chappal” to slippers and “ladki” to women; transliterate “साड़ी” to “saree”, and treat “sari” and “saree” as one word. Most of these come from query and click logs, not a dictionary.
Great: learn the structure of the query. Two models do most of the work:
- A category classifier. Trained on which categories shoppers bought from after each query, it returns a distribution: “iphone 15” → Mobiles 0.92, Cases and covers 0.06. Without it, thousands of cases with “iPhone 15” in their titles outscore the phone on text. Use a confident prediction as a filter, a weaker one as a boost, and pick the facet set from it.
- Attribute extraction. A sequence tagger labels spans: “red” → colour, “running shoes” → product type, “under 2000” → price at most 2,000, “128gb” → storage. Precise constraints such as price become filters; messy ones such as colour, where seller data is noisy, become boosts.
Then plan for failure. Null-result recovery relaxes the query step by step: apply the spelling fix, turn the least confident filter into a boost, require three of four words, widen the category, and finally fall back to semantic retrieval — and say so (“No exact matches; showing similar items”). The zero-result rate per query is a search team’s best to-do list.
You gain queries that behave like the form the shopper meant to fill in. You pay in models to train and monitor, about 10–15 ms of latency (cache the parsed form of head queries), and a new failure mode: a confident wrong guess — “apple” → Mobiles for someone buying fruit — silently hides the right results. Log every interpretation, and let the shopper undo it.
In the interview
When you add a filter the shopper did not ask for, show it: “Showing Mobiles · under ₹2,000 ✕”. That one sentence tells the interviewer you know query understanding makes mistakes, and it gives you a metric — how often shoppers remove the guessed filter.
How do we rank for relevance and for the business?
Text match decides what can be shown; ranking decides what is shown — balancing what this shopper will buy, what can be delivered, fairness to sellers and room for sponsored products.
Bad: BM25 alone. Text scoring with Elasticsearch’s defaults — BM25 with k1 = 1.2 and b = 0.75, configurable per field — ranks a ₹199 cover titled “iPhone 15 case cover for iPhone 15 and iPhone 15 Plus” above the iPhone, cannot tell a 4.5-star bestseller from an unrated copy, and happily shows things that are out of stock.
Good: field boosts, a category prior and quality signals. Weight title matches about 3× description matches, and brand and product-type matches about 2×. Multiply by the category classifier’s probability, so a phone beats a case for “iphone 15”. Add static quality through a function score: log of 7-day sales, a smoothed rating, availability and delivery speed. Smooth ratings first: 5.0 stars from 2 reviews should not beat 4.4 from 12,000. A Bayesian average with a prior of 4.0 worth 50 reviews gives (50 × 4.0 + 2 × 5.0) ÷ 52 ≈ 4.04 for the first and (50 × 4.0 + 12,000 × 4.4) ÷ 12,050 ≈ 4.40 for the second. Hand-tuned weights go a long way and are easy to explain, but they fight each other, and every new signal means another round of tuning.
Great: retrieve, re-rank with a learned model, then blend.
- Retrieve two ways. Lexical retrieval with the boosts above returns the top 100 per shard. For long-tail queries that share few words with listings (“gift for a 10 year old who loves science”), add an embedding retriever. Semantic Product Search trained one on purchases, treating products shown but not bought as a separate class from random negatives, and reported at least 4.7% better Recall@100 and 14.5% better MAP than earlier semantic baselines. Instacart’s post puts the split well: “pesto pasta sauce 8oz” is best served by keywords, “healthy foods” by semantics.
- Learning to rank. A gradient-boosted model re-scores the top 1,000 using query features (predicted category), document features (price, rating, sales, return rate, seller quality, delivery days to this pincode) and query-document features (BM25 per field, past click and add-to-cart rates for this pair). Elasticsearch can run it as a second-stage rescorer inside each shard. Train on graded labels — purchase above add-to-cart above click above shown — corrected for position bias, because people click whatever is on top.
- Blend with business rules, last and in the open. Collapse colour variants; cap any one brand or seller at, say, 3 of the top 10; demote out-of-stock items; put sponsored products only in fixed slots (positions 2, 7 and 12, say) and only above a relevance floor; and add light personalisation — brand affinity, usual size, price band — as model features, not filters.
Evaluate offline with graded metrics such as NDCG on judged queries, and online with A/B tests on conversion and revenue per search, with guardrails on zero-result rate and latency. You gain a ranking that learns from every search instead of a tuning meeting. You pay for a training pipeline, a feature store that serves identical values online and offline, rescoring CPU (which is why the window is 1,000, not 40,000), and a model nobody fully explains — so log each result’s top contributing features.
How do we compute facets and the pincode filter fast?
Facets are the expensive part of product search: retrieval needs only the best hits, but a facet visits every match. “shoes” might match 3M documents — 300,000 per shard — and each of ten facets reads a doc value for every one.
Bad: exact counts for every facet on every request. Simple, but the cost grows with how vague the query is — and vague queries (“mobile”, “tv”) dominate sale day.
Good: compute less, and accept approximate counts.
- Only the facets that matter. Ask for the 6–10 facets configured for the predicted category, not all 40 the catalogue knows.
- Pre-index the buckets. The search-speed guide suggests pre-indexing fixed price ranges and counting them with a
termsaggregation instead of arangeaggregation;price_bandandrating_bandkeywords do that. - Approximate top values. A terms aggregation asks each shard for its top
shard_sizebuckets — by default size × 1.5 + 10 — and merges them, so counts near the cut-off can come out slightly low. Fine for a brand facet; show “1,000+ results”, not an exact total. - Cache the filters.
category = sports-shoesandin_stock = trueappear on most queries. The node query cache keeps filter results per segment (for segments of at least 10,000 documents), so reapplying them is almost free.
Great: separate, cache and sample.
- Split hits from facets. Facet counts do not depend on the shopper, page or sort, so send them as a separate
size: 0request. The shard request cache caches onlysize: 0requests by default and drops entries at each refresh — another reason to refresh every 10 seconds. At 2,000 identical “iphone 15” searches a second, a 10-second cache life turns 20,000 facet computations into one per shard. - Sample the broadest queries. For a query matching millions, count facets over the best few thousand matches per shard (the
sampleraggregation does this); the brands a shopper cares about are in the relevant head anyway. - Degrade facets first. They are the most expensive and least essential part of the response; under load, serve cached facets or only the category facet before results slow down.
The pincode filter. “Delivery by tomorrow to 560001” breaks naive designs. India has roughly 20,000 pincodes. Storing, per SKU, every pincode it can reach by tomorrow means 50M SKUs × a few thousand pincodes — hundreds of billions of postings, rewritten whenever a warehouse sells out.
Flip it around. Index the warehouses holding stock for each SKU — about three on average — as stock_wh: [BLR3, DEL2], 150M postings in all. Keep a serviceability map in memory in every search pod: for each pincode and promise (“tomorrow”, “in 2 days”), the warehouses that can deliver in time — 20,000 pincodes × 3 promises × ~10 bytes ≈ 600 KB. The pincode becomes a terms filter on stock_wh; seller-fulfilled offers get virtual warehouse IDs for their pickup zones. Pincodes in one city share a few warehouse sets, so a few hundred distinct filters stay warm in the filter cache.
The map changes when order cut-offs pass — after 2 p.m., “tomorrow” needs a closer warehouse — so recompute and push it on a schedule. You gain a delivery filter that costs one terms lookup. You pay with a serviceability dependency (cache its map, so the last version keeps working if it is down) and promises only as good as the warehouse-to-pincode data. The exact delivery date on each card still comes from the live store.
How do we survive a 10× sale-day spike?
At midnight on sale day, traffic goes from about 20,000 to 200,000 searches a second in minutes, and queries narrow to a few hundred advertised deals — the same rush as a flash sale. The index cannot grow that fast on demand: every new node must copy a 25 GB shard and warm its caches before it helps.
Bad: autoscale on CPU when the spike arrives. By the time alarms fire, new nodes are still copying shards, and the copying steals disk and network from nodes already at their limit. The first ten minutes — the most valuable of the year — are served by a fleet sized for a Tuesday.
Good: pre-scale, warm and freeze.
- Book the fleet ahead. Grow from 50 to 250 nodes the day before: 25 copies of every shard across three zones. Replicas add throughput; they do not make any single query faster.
- Warm it. Replay the top queries from the last sale against every replica, so the filesystem, filter and request caches are hot at midnight.
- Freeze the slow lane. No mapping changes, full reindexes or new synonym models during the event; only the price and stock lane flows.
- Cache head queries. Cache the ranked SKU IDs and facet counts for a query’s first pages, keyed by parsed query, filters, sort and pincode cluster, for 30–60 seconds. Price and stock are hydrated after the cache, so a cached ranking never shows a stale price. With a fat sale-day head, a 40% hit rate is cautious.
Great: a degrade ladder, decided in advance. When load passes the plan, the search service steps down one level at a time instead of failing:
- Shrink the rescoring window from 1,000 to 300 and skip optional features.
- Serve facets from cache only, computing fresh counts for the category facet alone.
- Cap pagination at 20 pages and serve head queries from cache with a longer time to live.
- Shed traffic by priority — scrapers and bots first (see design a rate limiter), then deep pages — and return a smaller result set rather than an error (backpressure and load shedding).
Each level is a flag you can flip by hand and an automatic trigger on p99 latency and search queue depth. You gain a search box that gets simpler under pressure instead of falling over. You pay for a fleet five times larger for one night and code paths used once a year — so rehearse them with full-size load tests the week before.
One engine setting helps every day: Elasticsearch searches have no timeout by default, so set one, and a pathological query returns partial results instead of holding a thread.
The final design
Put the pieces together. The read path is the gateway, the search service, the index and the live store; every write arrives asynchronously through Kafka.
Follow one search, then the two kinds of change, through the system:
- Edge. The API gateway authenticates the shopper, applies per-client limits that stop scrapers, and forwards to the search service.
- Understand. Query understanding — normalisation, spelling, Hinglish, category and attributes — returns a structured query; parsed head queries are cached.
- Cache. The result cache is checked with the parsed query, filters, sort and pincode cluster. A hit returns ranked SKU IDs and facet counts and skips to step 5.
- Retrieve and rank. On a miss, the index gets one request for hits — boosted text match, filters including the pincode’s warehouses, collapse on product, learning-to-rank rescoring of the top 100 per shard — and a cacheable
size: 0request for facets. Ten shards answer in parallel, each from one of 25 copies. - Hydrate and blend. Exact price, stock and delivery date for the top 60 SKUs come from the live store; sold-out and suppressed listings are dropped, sponsored products from the ads service fill their fixed slots, diversity caps apply, and 40 cards go back with a
next_cursor. - Catalogue changes. Seller edits land in the catalogue database; CDC publishes them to Kafka; the indexer enriches and bulk-indexes them within about three minutes.
- Price and stock. Offer and inventory events update the live store within a second; the coalescer forwards only search-relevant changes to the indexer, visible in filters within about 15 seconds. Takedowns take their own lane to the suppression list.
- Learn. Impressions, clicks, add-to-carts and orders feed a daily loop that retrains the ranker, mines spelling fixes and synonyms and refreshes the category classifier, each shipped behind an A/B test.
Check it against the requirements. Latency: about 15 ms to parse, 60–100 ms at p99 for the index, 10 ms to hydrate — headroom under 200 ms. Throughput: 50 nodes normally, 250 pre-scaled for the sale, a 40% cache hit rate and a degrade ladder. Freshness: cards in a second, filters in 15 seconds, new listings in minutes. Availability: 25 copies of every shard across three zones, a second region with the same stack, and fallbacks — if the live store is slow, show index prices and re-check on the product page; if ads are slow, skip sponsored slots.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | Search box, filters, sort | Separates search from the catalogue; freshness targets per field | Defines success as conversion and zero-result rate; treats sale day as a requirement |
| Document model | One document per product | Variant documents; text versus keyword; doc values | Visual-variant documents, collapse and what it does to counts, attributes without a mapping explosion |
| Freshness | Reindex on every change | CDC, Kafka and a bulk indexer; refresh interval | Write amplification maths, coalescing, a live overlay, a takedown lane, alias swaps |
| Query understanding | Fuzzy matching | Synonyms, log-mined spelling, attribute extraction | Category prior, guardrails on model numbers, null-result recovery, Hinglish |
| Ranking | BM25 with boosts | Quality priors, smoothed ratings, learning to rank | Multi-stage funnel, hybrid retrieval, position bias, sponsored slots with a relevance floor, A/B evaluation |
| Facets and filters | Aggregations | Multi-select with post_filter, approximate counts, caching | Sampling and degrading facets, the pincode-to-warehouse filter |
| Scale | Add replicas | Shard sizing from data; keyset pagination | Pre-scaling and warm-up, caching rankings before price hydration, a degrade ladder |
Follow-up questions
What if sellers stuff keywords into their titles?
BM25 already limits the reward — with k1 = 1.2, a term repeated ten times scores less than twice one mention, and length normalisation penalises long titles — but stuffed titles still match queries they should not (“iphone samsung oneplus vivo case”). Defend in layers. At listing time, check titles against catalogue data and reject repeated brands and long model lists. At index time, weight the structured fields you control — brand, product type, compatible models — above the free-text title. At ranking time, behaviour wins: a listing that matches “samsung s24” but is never bought for it sinks in the learned model, and a spam classifier adds a penalty feature. Repeat offenders get demoted at the seller level.
What if a price bug lists a TV at ₹1?
Speed and blast radius both matter: it will be on social media in minutes. First stop the orders: checkout re-validates the price against the offer service and a sanity rule (say, more than 80% below the 30-day median needs manual approval), so this is not search’s job alone. In search, the SKU goes onto the suppression list through the priority lane; every pod checks it on every response, so the listing vanishes within a couple of seconds, even from cached pages, because suppression runs after the cache. Then fix the price at the source. Finally, put the same sanity rule in the coalescer, so absurd price changes are quarantined before search ever sees them.
What if out-of-stock items must still appear, ranked lower?
For backorders or “notify me” products, keep sold-out variants in the index with in_stock: false and an expected restock date, and make availability a ranking feature rather than a filter: a strong demotion, not removal. Show them when the query is specific (“iphone 15 pink 256gb”) or the category is thin, never above an in-stock equivalent, and make “in stock only” a facet the shopper can untick. The cost is a bigger index and a blend that must stop pages filling with things you cannot sell today.
What if we expand to ten Indian languages?
Keep one catalogue and one index, and translate the query rather than the catalogue. Add analysers for scripts such as Devanagari, Tamil and Bengali, transliteration for romanised input, and query translation trained on query and click pairs. The classifier and attribute tagger become multilingual, and a multilingual embedding retriever catches what translation misses. Translate display fields rather than indexing ten copies of every title, and track zero-result rates per language; gaps show up there first.
What if we need search within a category over 1 billion listings?
At 1B variant documents the index is 1B × 5 KB = 5 TB per copy, about 200 shards of 25 GB, and fanning every query out to 200 shards is wasteful when most queries have a confident category. Partition by top-level category: one index per category group (electronics, fashion, home, grocery), each sharded on its own, and route a query classified as Mobiles only to the electronics index. Each group can have its own mapping, refresh interval and replica count — fashion changes faster than books. Ambiguous queries fan out to the top two or three groups and merge; keep a slower all-category path for low-confidence queries, so a misclassified query still finds its products.
How to say it in the interview
“I’ll design product search for a marketplace with 100 million listings: about 20,000 searches a second normally, ten times that when a big sale opens, p99 under 200 milliseconds, and prices that are right within seconds.
The search document is a product variant — a colour of a shoe, a phone with a given storage — with the sizes in stock folded in, denormalised with brand, category path, attributes, rating, sales and a summary of the offers. That keeps attribute filters correct without nested documents, and I collapse on product ID so each product shows once. Fifty million documents is about 250 gigabytes, so 10 shards of 25 gigabytes.
A query first goes through query understanding: log-mined spelling with guardrails on model numbers, synonyms, Hinglish, a category classifier, and attribute extraction that turns ‘under 2000’ into a price filter. The index filters, scores with field-boosted BM25, computes the facets for that category, and a learning-to-rank model re-scores the top thousand. The search service then reads exact price and stock for the top sixty from a live store, drops anything sold out or suppressed, blends sponsored slots and caps duplicates.
For freshness, catalogue edits flow through CDC and Kafka to the indexer in minutes. Price and stock reach the live store within a second, and only changes that affect filters reach the index, coalesced per SKU. That cuts index writes tenfold, and the index refreshes every 10 seconds so its caches survive.
Delivery by tomorrow is a filter on warehouses with stock, from a pincode-to-warehouse map. For sale day I pre-scale from 50 to 250 nodes, warm the caches, cache head-query rankings before price hydration, and keep a degrade ladder: smaller rescoring, cached facets, then load shedding.”
Concepts used in this design
- Design a search engine like Google — inverted indexes, BM25 and sharding at web scale.
- Design typeahead — the suggestion box in front of this search.
- Design post search — an append-only firehose ranked by recency and live engagement.
- Elasticsearch — refresh, doc values, aggregations, replicas.
- How search engines work — analysing, indexing and scoring text.
- Search data structures — inverted indexes next to columnar doc values.
- Embeddings and semantic search — the retriever for long-tail queries.
- Change data capture — streaming catalogue changes out of the database.
- Kafka — the log feeding the indexer and the live store.
- Redis — a natural home for the live offer store.
- Data modeling and denormalization — why variants copy product data.
- Database sharding — sizing and routing shards.
- Offset vs keyset pagination — why
search_afterbeats deepfrom. - Hot keys, cache stampede and invalidation — head queries and hot products.
- Backpressure and load shedding — the degrade ladder.
- Design a flash sale — the traffic pattern this search must survive.
References and further reading
- Near real-time search (Elasticsearch documentation)elastic.coA refresh writes and opens a new searchable segment; by default every second, and only on indices searched in the last 30 seconds.
- Update a document API (Elasticsearch documentation)elastic.coA partial update gets the document, applies the change and indexes the result, so the whole document is still reindexed.
- Shard request cache (Elasticsearch documentation)elastic.coCaches only size-0 requests (totals and aggregations) by default, and drops entries whenever the shard refreshes.
- Size your shards (Elasticsearch documentation)elastic.coShards of 10–50 GB and under 200 million documents, one search thread per shard, and the heap cost of every mapped field.
- Tune for search speed (Elasticsearch documentation)elastic.coWhy nested and parent-child joins are slow, pre-indexed price ranges, a formula for replica counts, preference routing and the missing default timeout.
- Paginate search results (Elasticsearch documentation)elastic.coThe cost of deep from/size paging, the 10,000-hit max_result_window, and search_after with a tiebreaker or a point in time.
- Terms aggregation (Elasticsearch documentation)elastic.coPer-shard top buckets with shard_size = size × 1.5 + 10, and why facet counts can be approximate.
- Semantic Product Search (Nigam et al., KDD 2019)arxiv.orgWhy lexical matching falls short for shopping queries, and an embedding model trained on purchases versus shown-but-not-bought products.
- Configure typo tolerance (Algolia documentation)algolia.comOne typo from 4 characters, two from 8, typo-count-first ranking, and switching typo tolerance off for numbers and codes.
- How Instacart Built a Modern Search Infrastructure on Postgrestech.instacart.comA grocery marketplace's account of partial price and availability writes overloading Elasticsearch, overfetching, and hybrid retrieval.