Design AI-powered semantic search
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
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
3 API
| Endpoint | What 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/events | Impression, 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_versionOne 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_atChanges 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_cacheRediskey = hash(normalized query, filters, region bucket, model_version) → top 500 candidate ids + scoresTTL 5-15 minCaches 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 affinitySame feature definitions offline (training) and online (serving) to avoid training-serving skew.
search_eventsKafka → data lake (Iceberg)query_iduser_hashqueryproduct_idpositionevent_typetsranker_versionTraining data for the embedding model and the ranker, and the source of online metrics.
5 Architecture
Hover a box to highlight its connections.
6 Request flows
Search 'red kurta for haldi under 1500'
- 1The orchestrator normalises the query (lowercase, unicode, remove extra spaces) and checks the candidate cache; on a hit it skips to ranking.
- 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.
- 3The query text (with the price phrase removed) is embedded by the bi-encoder (~8 ms).
- 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.
- 5Results are fused with Reciprocal Rank Fusion (score = Σ 1/(60 + rank)) into ~500 candidates and written to the candidate cache.
- 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.
- 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
- 1The catalog DB commits the change; Debezium publishes it to Kafka keyed by product_id.
- 2An indexing consumer sees the title changed, so it sends the product to the embedding worker pool (batched 256 at a time on GPU).
- 3The worker writes the new document and vector with the current embedding_version to the index (an upsert marks the old vector deleted).
- 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
Lessons this design uses
Practice questions
- Your RAG system uses only vector search. Users who search for exact things like 'ERR_PAY_5021' or a product SKU get irrelevant results. How do you fix retrieval?
- Your help-centre search uses keyword search. Users type questions like 'money got deducted but recharge failed' and get poor results. Would you add vector search, and how would you combine it with keyword search?
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.