Design problems
AI-era systemsHardSDE-2 35 min

Design AI-powered semantic search

Asked at
AmazonFlipkartGoogleMeeshoMyntraSwiggy

The interviewer asks

“Our marketplace's keyword search fails on queries like 'red kurta for haldi under 1500' or 'shoes for flat feet', and on Hinglish spellings. Design AI-powered semantic search for a catalog of 150M products that understands meaning, not just words, at sale-day traffic.”

What a strong answer looks like

A strong answer does not replace keyword search with vectors; it combines them. Queries go through query understanding (spelling, transliteration, extracting filters like colour and price, using an LLM offline for frequent queries and a small fast model online), then two retrievers run in parallel: BM25 for exact terms, brands and model numbers, and an approximate nearest-neighbour search over product embeddings for meaning. Results are merged (Reciprocal Rank Fusion or a learned blend) and re-ranked by a learning-to-rank model that adds business signals such as conversion rate, price and delivery speed. The embedding model is a bi-encoder fine-tuned on the marketplace's own click and purchase logs. Strong candidates size the vector index (memory, quantisation, sharding), handle filtered ANN, keep the index fresh without re-embedding on every price change, version embedding models with a blue/green index, and measure quality with NDCG offline and conversion in A/B tests.

Concepts to mention

hybrid retrieval: BM25 + vector ANNReciprocal Rank Fusionlearning-to-rank with business signalsbi-encoder fine-tuned on click and purchase logsHNSW memory sizing and int8 quantisationfiltered ANN (pre-filter vs post-filter vs filtered traversal)LLM query understanding offline, small model onlineembedding versioning with blue/green indexseparate fast field updates from re-embeddingNDCG offline and conversion A/B online

1 Requirements

Functional

  • Natural-language and keyword queries return relevant products, ranked
  • Understand misspellings, Hinglish and transliterated queries ('jute ki bag', 'joote')
  • Extract structured filters from text: colour, size, price range, occasion, gender
  • Exact matches still win for brands, model numbers and SKUs ('iPhone 15 128GB')
  • Respect hard filters: in stock, deliverable to the user's pincode, category selected in UI
  • New products searchable within 15 minutes; price and stock changes reflected within 1 minute
  • Autocomplete and 'did you mean' suggestions

Non-functional

  • Latency p99 < 250 ms end to end on the server (p50 < 100 ms)
  • Peak 30k queries/s during big sale events, ~5k/s normal
  • Availability 99.95%; degrade to keyword-only search rather than fail
  • Catalog 150M products, growing 20%/year; 22 Indian languages in long tail, Hindi/English/Hinglish primary
  • Measurable quality: NDCG@10 and null-result rate tracked per release

Out of scope (say it out loud)

  • Conversational shopping assistant with multi-turn chat
  • Visual search by photo (mention as an extension)
  • Ads ranking and sponsored products
  • Recommendations on the home page

2 Estimates

Query rate~5k/s normal, 30k/s peakAssumption from traffic: 100M daily users × ~4 searches ≈ 400M/day ÷ 86,400 ≈ 4,600/s; sale-day peaks are 5-6× normal
Raw vector size~460 GB150M products × 768 dimensions × 4 bytes (float32) ≈ 460 GB
Index memory after quantisation~150-160 GBint8 scalar quantisation: 768 B per vector → 115 GB; HNSW graph with M=16 stores ~32 neighbour ids on the base layer × 4 B ≈ 128 B per vector → ~20 GB; plus metadata ≈ 150-160 GB. Keep float32 vectors on disk for exact re-scoring of top candidates
Shards and nodes10 shards; ~70-100 nodes at peak~16 GB of vector index per shard fits in RAM with BM25 postings on a 64 GB node. Every query fans out to all 10 shards, so each shard sees the full QPS. Assume one 16-core node serves ~3k hybrid shard-queries/s → 30k/s needs ~10 copies per shard, ~7 after a ~35% cache hit rate → ~70-100 nodes at peak, ~25 on normal days
Query embedding cost~10 ms, cached for head queriesA small multilingual bi-encoder (~100M parameters) embeds a short query in ~5-10 ms on CPU or batched on GPU. The top 50k queries are ~35-40% of traffic and are cached in Redis
Full re-embed on model change~1 day on 8 GPUsAssume ~1,000-2,000 products/s per GPU for title + attributes text; 150M ÷ (8 × 1,500/s) ≈ 12,500 s ≈ 3.5 h of compute, so a day including index build and validation
Catalog change rate~1,000 text changes/s, ~20k price/stock updates/sSellers edit listings constantly; only text and image changes need re-embedding. Price and stock updates are 20× more frequent and must update filterable fields only

3 API

EndpointWhat it does
GET/v1/search?q=&pincode=&filters=&sort=&page_token=Main search. Returns products, applied filters (including ones extracted from the query, shown as removable chips), and facet counts.
GET/v1/autocomplete?prefix=Query suggestions from a prefix index of popular queries, < 30 ms.
POST/v1/search/eventsImpression, click, add-to-cart and purchase events with query_id and position; feeds training and evaluation.
gRPCIndex.Upsert(product_id, fields, embedding_version)Internal: indexing workers write documents and vectors to the search cluster.
gRPCIndex.UpdateFields(product_id, price, in_stock, ...)Internal: partial update of filterable fields without touching the vector.

4 Data model

product_docsSearch engine with hybrid support (OpenSearch/Elasticsearch kNN or Vespa)
product_idtitlebrandcategory_pathattributes (colour, size, material)description_snippetpricein_stockserviceable_regionsratingpopularityembedding (int8 768-d)embedding_version

One engine serves BM25 and ANN with the same filters, so we avoid joining results from two systems. Sharded by product_id hash; sale-day replicas added ahead of time.

catalogPostgres/MySQL (source of truth, owned by catalog team)
product_idseller_idtitledescriptionattributesimagesupdated_at

Changes flow out through CDC (Debezium) to Kafka; search never queries it on the hot path.

query_rewritesRedis / KV (from offline LLM batch)
normalized_query → {correctedtransliteratedcategoryfilters {colourprice_maxoccasion}synonyms[]expires_at}

Precomputed for the top ~1M queries by an LLM batch job and checked against click data. Online lookup < 1 ms.

query_cacheRedis
key = hash(normalized query, filters, region bucket, model_version) → top 500 candidate ids + scoresTTL 5-15 min

Caches retrieval, not the final page, so personal and real-time features can still re-rank. Region bucket keeps delivery filters correct.

ranking_featuresFeature store (Redis online, Hive/Iceberg offline)
product_id → ctr_7dconversion_7dreturn_rateprice_competitivenessdelivery_days by region; query_category affinity

Same feature definitions offline (training) and online (serving) to avoid training-serving skew.

search_eventsKafka → data lake (Iceberg)
query_iduser_hashqueryproduct_idpositionevent_typetsranker_version

Training data for the embedding model and the ranker, and the source of online metrics.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALGET /searchparse + embed queryrewrite lookupBM25 + kNN, filteredtop 500 candidatesfeaturescandidate cacheCDCtext changesupsert vectorsprice/stock fieldsbatch rewritesload rewritesclick eventsShopper app / webAPI gatewayQuery understandingSearch orchestratorRanker (LTR model)Offline LLM rewritejobEmbedding workers(GPU)Kafka: catalog CDCRedis: rewrites,cacheHybrid index(BM25+HNSW)Feature storeCatalog DBLLM API (batch)
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Search 'red kurta for haldi under 1500'

  1. 1The orchestrator normalises the query (lowercase, unicode, remove extra spaces) and checks the candidate cache; on a hit it skips to ranking.
  2. 2Query understanding looks up the rewrite table: category = women/kurtas, filters = {colour: red, yellow-family allowed, price_max: 1500}, occasion = haldi/festive, synonyms = [ethnic, cotton]. For an unseen tail query, a small fine-tuned model extracts these within a 30 ms timeout, else the raw query is used.
  3. 3The query text (with the price phrase removed) is embedded by the bi-encoder (~8 ms).
  4. 4Two retrievals run in parallel on the hybrid index, both with the same hard filters (in stock, deliverable to pincode bucket, price ≤ 1500): BM25 top 300 over title/brand/attributes and HNSW kNN top 300.
  5. 5Results are fused with Reciprocal Rank Fusion (score = Σ 1/(60 + rank)) into ~500 candidates and written to the candidate cache.
  6. 6The LTR ranker (gradient-boosted trees) scores candidates using text match score, vector similarity, CTR, conversion, rating, price, delivery days and return rate; ~15 ms on CPU.
  7. 7The top 40 are returned with the extracted filters shown as chips the user can remove; impressions are logged with query_id and ranker version.

Seller edits a product title

  1. 1The catalog DB commits the change; Debezium publishes it to Kafka keyed by product_id.
  2. 2An indexing consumer sees the title changed, so it sends the product to the embedding worker pool (batched 256 at a time on GPU).
  3. 3The worker writes the new document and vector with the current embedding_version to the index (an upsert marks the old vector deleted).
  4. 4A price-only change takes a separate fast path: a partial field update with no re-embedding, visible in ~1 s after refresh.

7 Deep dives

Why hybrid retrieval, and how to fuse

Vector search understands meaning ('shoes for flat feet' matches 'arch support sneakers') but is weak on exact tokens: model numbers, brand names, sizes and rare words often embed close to similar but wrong items ('iPhone 15' near 'iPhone 14'). BM25 is the opposite: precise on terms, blind to synonyms and intent. Running both and merging gives clearly better recall than either alone; measure it on your own evaluation set rather than assuming. The simplest robust merge is Reciprocal Rank Fusion: each document scores Σ 1/(k + rank) over the lists it appears in, with k ≈ 60. It needs no score normalisation, which matters because BM25 scores and cosine similarities are on different scales. A learned blend (a ranker that sees both scores as features) does better once you have click data. Fusion only produces candidates; the final order comes from the learning-to-rank model, which adds signals that text similarity cannot know: conversion rate, price competitiveness, delivery speed to this pincode, return rate. Keep both retrievers in one engine so the same filters apply to both lists and you avoid a second network fan-out.

Filtered ANN: the problem everyone forgets

HNSW finds the nearest vectors in the whole index. If you then apply filters (in stock, deliverable to pincode 560034, price ≤ 1500, category kurtas), a restrictive filter may remove 99% of results, and the top 300 neighbours leave only 3 valid products. Three approaches. Post-filtering with a larger k (fetch 3,000, filter to 300) is simple but slow and still fails for very selective filters. Pre-filtering (compute the allowed set, then do exact search inside it) is accurate when the allowed set is small, say under 50k documents, and brute force over that set is fast. Filtered HNSW traversal, supported by modern engines (Lucene-based kNN with filters, Vespa, Qdrant, pgvector iterative scans), skips disallowed nodes during the graph walk and falls back to exact search when the filter is very selective. In practice: route by category so each category's vectors are a separate partition (most queries are category-specific after query understanding), apply the engine's filtered kNN for stock, price and region, and keep delivery serviceability coarse (region buckets) in the index with an exact check on the final 40 results.

Training the embedding model on our own data

An off-the-shelf multilingual embedding model is a fine start, but marketplace relevance is specific: 'haldi outfit' should mean yellow ethnic wear, and 'joote' should mean shoes. Fine-tune a bi-encoder (separate query and product towers sharing weights, or a dual encoder) on the marketplace's logs: positive pairs are (query, product purchased or added to cart after that query); negatives are other products in the same batch plus hard negatives (shown at a high position but not clicked, or same category but wrong attribute). Use clicks carefully: position bias means top results get clicked because they are on top, so weight purchases more than clicks and downweight clicks at position 1. Include product images through a vision encoder or captions for fashion, where text is poor. Distill to a small model so query embedding stays under ~10 ms. Most importantly, query and product embeddings must come from the same model version; mixing versions silently ruins results, so every vector carries embedding_version and the search orchestrator only queries an index whose version matches its query encoder.

LLM where it pays: offline query understanding

Calling an LLM on every search would add 300-1,000 ms and cost more than the margin on many orders, at 30k queries/s. But search traffic is heavily skewed: the top ~1M distinct queries cover most of the volume. So run an LLM in batch every night over new and frequent queries to produce a structured interpretation: corrected spelling, transliteration to the catalog's script, category, attribute filters, price range and a few synonyms. Validate it with data: keep a rewrite only if it improves click-through in a small online test or matches the categories users actually bought from. Store results in a key-value table for sub-millisecond lookup. For unseen tail queries, use a small fine-tuned model (or the same embedding model plus a classifier head) with a strict 30 ms timeout and fall back to the raw query. Show extracted filters as removable chips; if the system wrongly read 'under 1500' as a size, the user can fix it in one tap. This pattern, an expensive model offline and cheap models online, is the most useful idea in AI-era search.

8 Trade-offs

One hybrid engine vs search engine + separate vector database

A separate vector DB (Milvus, Pinecone) may have better ANN features, but you must run two queries, apply filters consistently in two places and keep two indexes in sync. A single engine with both BM25 and HNSW (OpenSearch/Elasticsearch, Vespa) keeps filtering, freshness and fan-out in one place. Choose the separate system only if vector scale or features clearly need it.

Quantisation vs recall

int8 scalar quantisation cuts memory 4× with a small recall loss, recovered by re-scoring the top few hundred with full-precision vectors. Product quantisation cuts memory further (16-32×) with a larger recall loss. At 150M items, int8 plus re-scoring is the usual balance; disk-based indexes (DiskANN-style) are an option if RAM cost dominates.

Cross-encoder re-ranking vs LTR on features

A cross-encoder that reads query and product together is the most accurate text relevance model, but scoring 100 pairs per query at 30k QPS needs a large GPU fleet. A gradient-boosted ranker over cheap features is fast on CPU. Use the cross-encoder offline to label data or online only for the top 20 of tail queries.

Freshness vs cost of re-embedding

Re-embedding on every change is wasteful because most changes are price and stock. Separate fast-changing filterable fields (partial updates) from slow-changing text (re-embed), and batch embeddings every few seconds for GPU efficiency.

9 Bottlenecks

Sale-day traffic spike (30k → 100k QPS)

HNSW search is CPU and memory-bandwidth bound. Pre-scale replicas days before, raise the candidate cache TTL so head queries are served from Redis, reduce ef_search slightly under load (trading a little recall for latency), and protect the cluster with admission control at the orchestrator.

Catalog growth to 500M items

Index RAM grows linearly. Move to more shards, product quantisation or disk-based ANN, and index only active, in-stock-recently products in the hot tier, with a cold tier for the long tail.

10 Failure handling

Embedding service or vector path down

The orchestrator sets a 40 ms deadline on query embedding. On failure it runs BM25 with rewrites only. Users get slightly worse results, not errors. Head queries still hit the candidate cache.

Bad embedding model release

A new model looks good offline but drops conversion. Because indexes are blue/green by embedding_version, rollback is switching the alias back to the old index and the old query encoder together; the old index is kept for a week after cut-over.

Kafka indexing lag after a bulk catalog import

A seller uploads 5M products and lag grows. Separate topics or priorities for price/stock (must stay under 1 minute) and new-product embedding (can lag 15 minutes); scale embedding workers on consumer lag.

11 What impresses

  • Explains why pure vector search fails on SKUs and brands and argues for hybrid
  • Sizes the vector index in GB and picks quantisation with a recall argument
  • Raises filtered ANN before the interviewer does
  • Keeps LLMs off the hot path by precomputing query understanding for head queries
  • Insists query and document embeddings share a model version and plans a blue/green re-embed
  • Defines quality metrics and a degraded keyword-only mode

12 Mistakes

  • Replacing keyword search entirely with embeddings
  • Calling a large LLM synchronously for every query
  • Re-embedding products on every price or stock change
  • Ignoring filters until results come back empty
  • Mixing embedding model versions between queries and documents
  • No evaluation set, so relevance changes are judged by eyeballing

13 Follow-ups

Q1How would you add search by photo?
Q2How do you personalise results without hurting cache hit rates?
Q3How would you build the labelled evaluation set, and can an LLM help label it?
Q4How do you handle queries in Tamil script for products listed only in English?
Q5How would you detect and fix a query category that suddenly returns poor results?
Q6What changes if the catalog is help-centre articles instead of products?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free