Courses / System Design Interview / Chapter 18
Module 2 · Building Blocks · Chapter 18 of 39
Distributed Search
Builds on Databases: Replication, Partitioning and Choosing a Store, Distributed Caches and Message Queues. Next up: Distributed Logging.
Part 1 · The Problem Search Solves
Why a database query is not enough
It is midnight, and a shopping app's Diwali sale has just opened. Four thousand shoppers a second type into the search box: "red running shoes", "phone under 15000". The search box runs WHERE title LIKE '%shoes%' on the product database, and every query reads all 50 million listings. Within two minutes the database's CPUs hit 100%, searches take ten seconds, and checkout, which shares the same database, starts timing out too. The busiest hour of the sale is spent on a spinner. What the team needed was a search index: a separate copy of the data, built only for finding things.
The database failed for a simple reason: a pattern that starts with a wildcard cannot use a normal index, so it scans every row and returns matches in no useful order. A search system solves three problems that a plain query does not. It finds documents containing the words quickly, it matches words written differently (Shoes, shoe, running and run), and it puts the best matches first. This chapter builds one for that shopping app.
Here is what shoppers expect, and what each expectation demands of the system.
| What users expect | What the system must do |
|---|---|
| Results the moment they stop typing | Answer 99% of queries in under 200 ms |
| Words, not exact strings | Match plurals, capital letters, accents and common spellings |
| The best results first | Rank by relevance, popularity and freshness |
| New items show up quickly | Make a new listing searchable within seconds to a minute |
| It keeps working | Keep answering when a machine or a whole rack fails |
Every row is a number or a property you can test, and together they shape the rest of the chapter.
The database stays the source of truth. The search index is a second copy of the data, shaped for one job: finding things fast. If the index is lost or corrupted, you rebuild it from the database.
The words you will need
Search has its own vocabulary. These terms appear throughout the chapter, so fix them now.
| Term | Meaning | Example |
|---|---|---|
| Document | One thing a user can find | A product listing, a job post, a song |
| Field | A named part of a document | title, description, brand, price |
| Term | A cleaned-up word as stored in the index | run, shoe, red |
| Corpus | All documents in the index | 50 million listings |
| Query | What the user typed, turned into terms | red AND run AND shoe |
| Posting | A record saying a term appears in a document | term shoe in doc 17, twice |
Two of these carry the whole design: an index is built from terms, and each term points to its postings.
Users also mix text with exact filters. Someone who types "shoes" and ticks "under ₹2,000" and "size 9" wants word matching plus precise conditions. So the index stores some fields, like price, size and brand, as plain values that can be filtered and sorted, not only as words.
Keep one picture in mind: documents go in at one end, get broken into terms, and are stored so that any term leads straight to the documents that contain it. The next page shows that structure.
Part 2 · How a Search Index Works
The inverted index
The index at the back of a textbook maps a word to the pages it appears on. Search engines do the same. Instead of storing each document's words (document → terms), they store, for each term, the list of documents containing it (term → documents). This is the inverted index, and each list is a postings list.
| Doc | Title |
|---|---|
| 1 | Red running shoes |
| 2 | Blue running shorts |
| 3 | Red trail shoes for men |
| 4 | Leather shoes |
| Term | Postings (doc ids) |
|---|---|
| red | 1, 3 |
| run | 1, 2 |
| shoe | 1, 3, 4 |
| blue | 2 |
| short | 2 |
| trail | 3 |
| men | 3 |
| leather | 4 |
The word for is left out as a stop word; the next page explains why.
To answer red shoes, the engine fetches two lists, red → 1, 3 and shoe → 1, 3, 4, and intersects them. Both lists are sorted by document id, so one pass with two pointers finds the common ids, 1 and 3, without ever touching document 2 or 4.
Real postings carry more than ids. They record how often the term appears in each document, for ranking, and where it appears, for phrase queries like "running shoes" where the words must sit side by side. Lists are kept sorted and compressed, often as gaps between ids (1, 3, 4 is stored as 1, 2, 1), because small numbers take fewer bytes.
Start from the shortest list. If trail appears in 2,000 documents and shoe in 3 million, you walk the 2,000 and use skip pointers, shortcuts stored inside the long list, to jump ahead in it. The work depends mostly on the rare word, not the common one.
Tokenising and normalising text
The index on the previous page stored run and shoe, not "Running-Shoes". Before text enters the index it passes through an analyser: a fixed chain of steps that turns raw text into terms. The same chain must run on every query too, or the query's terms will not match the stored ones.
- 1Raw textMen's Red Running-Shoes, size 9
- 2Tokenisesplit on spaces and most punctuation: Men's | Red | Running | Shoes | size | 9
- 3Lowercase and foldmen's | red | running | shoes | size | 9; accents go too, so café matches cafe
- 4Drop stop words (optional)very common words like for and the say little about meaning
- 5Stemrunning becomes run, shoes becomes shoe, men's becomes men
- 6Add synonyms (optional)sneakers is also stored as shoe, tee as t-shirt
- 7Termsmen | red | run | shoe | size | 9
Each optional step helps some searches and hurts others.
| Choice | Helps | Hurts |
|---|---|---|
| Stemming | "running" finds "run" | Can join unrelated words: "university" and "universe" may share a stem |
| Stop words | A smaller index | Breaks searches like "to be or not to be" |
| Synonyms | "mobile" finds "phone" | A careless list returns strange results |
| Word pieces (n-grams) | Partial words and some typos match: "sho" finds "shoe" | The index grows several times larger |
Every choice trades recall, finding more matches, against precision, keeping them relevant. Languages differ too: Hindi, Tamil and other Indian languages need their own tokenisers, and many users type Hindi in Latin letters, such as "joote" for shoes, so an app used across India may index the same text with more than one analyser.
Ranking: which match comes first
Finding matches is half the job. A query for shoes may match 3 million listings, and users look at the first ten. Ranking gives every match a score and returns the highest. The classic text signals rest on two simple ideas.
- Term frequency (TF): a listing that mentions
trailthree times is probably more about trail running than one that mentions it once. - Inverse document frequency (IDF): rare words matter more, because far fewer documents contain them. In a catalogue of 1 million listings:
| Term | Documents containing it (of 1 M) | IDF = log10(1 M ÷ count) |
|---|---|---|
| the | 900,000 | 0.05 |
| red | 100,000 | 1.0 |
| shoes | 50,000 | 1.3 |
| trail | 2,000 | 2.7 |
A match on trail counts about 50 times more than a match on the: rare words carry the meaning. A document's text score adds up TF × IDF over the query's terms. BM25, the default in many engines, refines this in two ways: each extra mention adds less than the one before, and long documents do not win just because they hold more words. Two settings control it: k1, often about 1.2, sets how quickly extra mentions stop counting, and b, often 0.75, sets how strongly long documents are pulled back.
Text match is rarely enough on its own. Good ranking mixes in freshness, popularity (clicks, sales, ratings), quality (complete listings, trusted sellers) and sometimes the user's history. Large systems rank in two stages: a cheap score picks a few hundred to a few thousand candidates, then a slower machine-learned model re-ranks only those, because running it on 3 million matches would blow the latency budget.
Test a change offline first on a few thousand real queries whose results people have graded, scored by a metric such as NDCG, which rewards putting the best results at the top. Then run an A/B test on a small share of users and compare clicks on top results, purchases per search and searches with zero results. Ship only if those improve and p99 latency stays within budget.
Part 3 · Spreading the Index Across Machines
Splitting the index: by document or by term
One index on one machine works until the sale. Fifty million listings and their index take a couple of hundred gigabytes, and the traffic reaches thousands of queries per second, too much for one machine. So the index is split into shards, and there are two ways to cut it.
| By document (local index) | By term (global index) | |
|---|---|---|
| Each shard holds | Every term, for a slice of the documents | Full postings lists, for a slice of the terms |
| A query goes to | Every shard | Only the shards that own its terms |
| Adding a document | Touches one shard | Touches every shard that owns one of its terms |
| Multi-word queries | Each shard intersects its own lists | Postings lists must travel between shards first |
| Hot spots | Even load: every shard does a little per query | A popular term makes its shard hot |
| Seen in practice | Most search systems | Rarely, for special cases |
Document partitioning is the usual choice. Sending every query to every shard sounds wasteful, but each shard searches only its slice, all of them work in parallel, the load stays even, and a new document touches exactly one shard. Documents are placed by hashing their id, so shards stay close in size and their word statistics, such as IDF, come out nearly the same.
There is one exception. When every query stays inside one tenant, such as a seller searching their own 30,000 orders in a seller dashboard, route documents by tenant id instead, so each query visits one shard rather than all of them. A few huge tenants would break the balance, so give any tenant above a size limit several shards of its own and scatter only across those.
Sizing a search cluster
Splitting decides how the data is cut; sizing decides how many machines hold it. Think of shards as shelves and replicas as shop counters: more stock needs more shelves, more customers need more counters.
A shard should be small enough that its index sits in memory or on fast SSD, often tens of gigabytes, because a query that waits on slow disk misses its budget. Each shard then gets replicas, full copies on other machines, and any copy can answer any query for that shard. For the shopping app, under stated assumptions:
- Data: 50 M listings × 2 KB of text ≈ 100 GB; assume the index is about the same size.
- Shards: at about 33 GB each, 3 shards, the three you will see in the next scene.
- Traffic: a peak of 4,000 queries a second, and every query visits every shard. If one copy handles about 500 a second, each shard needs 8 copies.
- Failures: spread copies over three zones. To lose a zone at peak and keep 8, run 12 copies, 4 per zone: 36 shard copies in all.
Shards set how much data you can hold; replicas set how many queries you can serve. Adding shards adds no query capacity, because every query still visits every shard, and adding replicas adds no room for data. The advice flips at the extremes: hundreds of tiny shards make each query pay per-shard overhead and lengthen the tail, so keep the shard count as low as memory allows. How does one query use all those copies? That is the next page.
Scatter-gather: one query, many shards
A query first reaches a coordinator, a service that knows where every shard lives from a shard map kept in a coordination service (the coordination chapter). It sends the query to one copy of every shard at once: the scatter. Each shard scores its own matches and sends back only its best 10. Collecting those replies is the gather: the coordinator merges the short lists and keeps the overall top 10.
Sending only 10 per shard is safe, because the overall top 10 must be among the shards' top 10s: with 3 shards the coordinator merges 30 candidates, not millions. It is also why deep pages are expensive: page 500 (results 4,991 to 5,000) forces every shard to send its top 5,000.
Predict before you watch: shard 2 is busy and answers in 240 ms, while the other two answer in about 20 ms. How long does the shopper wait, and what could the coordinator do?
- About 240 ms: the coordinator cannot know the overall top 10 until every shard has answered.
- It can send the same query to a second copy of shard 2 and use whichever reply comes first.
Watch for shard 2: the coordinator cannot answer until every shard has
Read it as steps
Scatter, gather, then a busy shard: first the query waits for it, then a hedge to its replica fixes it.
A query is only as fast as its slowest shard, because the overall top 10 is unknown until every shard answers. Replicas do double duty: a hedge to a second copy cuts the slow tail, and the same copy takes over when a shard dies. The next page puts numbers on both.
Tail latency and hedged requests
The scene had three shards. Real clusters have 20 or 100, and waiting for all of them is like a group trip that leaves only when the last friend arrives: the more friends, the more often someone is late. If each shard copy is slow on 1 query in 100, the chance that at least one of n shards is slow is 1 − 0.99ⁿ: about 10% for 10 shards, 18% for 20, 40% for 50 and 63% for 100. Rare slowness on single machines becomes common slowness for users.
A hedged request attacks this directly. The coordinator waits about as long as a shard's p99, say 50 ms, and if a copy has not answered, it sends the same query to another copy and takes the first reply. Suppose each copy is slow 1% of the time, independently. A shard now stays slow only if both copies are: 0.01 × 0.01 = 1 in 10,000. Across 50 shards, 1 − 0.9999⁵⁰ ≈ 0.5% of queries meet a slow shard, down from 40%. The cost is small: only the 1% of requests that pass 50 ms get a second copy, so about 1% more load.
The advice flips when slowness is not independent. A query that is heavy on every copy, or a cluster that is overloaded everywhere, gains nothing from a hedge and gets extra load at the worst moment, so cap hedges at a few percent of traffic. Behind the hedge sits a deadline: after, say, 150 ms, the coordinator returns whatever has arrived and marks the result partial.
For a shopping app, results from 19 of 20 shards after 150 ms beat a spinner. For a court records search, a missing document may be unacceptable. Decide which one your product is, and write it into the API contract.
Building the index: in batches or as you go
A fast cluster is useless if its data is old. There are two ways to get documents into the index, and many real systems use both.
| Offline batch build | Incremental updates | |
|---|---|---|
| How | Read every document, write a fresh index, swap it in | Add each new or changed document as it arrives |
| Freshness | Hours old by the time it goes live | Seconds to a minute |
| Index shape | Compact and fully merged; easy to test before release | Many small pieces that need merging in the background |
| Good for | The first build, analyser changes, rebuilding after a bug | New listings, price and stock changes, deletes |
Batch builds give a clean, tested index; incremental updates give freshness. Incremental indexing usually works in segments. New documents collect in a small in-memory buffer, and every second or so the buffer is written out as a small, immutable segment that searches can see. A query searches every segment of a shard, and a background job merges small segments into bigger ones so the count stays low.
Because segments never change once written, an update is a delete plus a re-add. The old copy is marked deleted in a small bitmap and dropped for good when its segment is merged. This keeps reads simple and makes segments easy to cache and to copy to replicas.
Keeping the index fresh
Users spot stale search quickly: a sold-out phone still listed, a price that changed an hour ago, a deleted post still showing. Freshness comes from a pipeline that follows the database closely.
- Capture every change: put each insert, update and delete on a queue, either from the application or by reading the database's own change log (change data capture). As the message queue chapter showed, the queue absorbs bursts.
- Apply changes in order per document: tag each change with the document's version number, and have the indexer ignore anything older than what the index already holds. A late, out-of-order message then cannot undo a newer one.
- Refresh often, not instantly: making new segments visible every second is cheap. Doing it on every single write would create thousands of tiny segments.
- Separate fast fields from slow ones: price and stock change every few minutes, titles rarely. Some systems keep fast-changing values in a separate store that ranking reads at query time, instead of re-indexing the whole document.
When the analyser or the document shape changes, incremental updates are not enough, because every old document was analysed the old way. Build a new index next to the live one from the database, replay the changes that arrived meanwhile, compare results, then point the search alias (a name that refers to one index) at the new index. Rolling back means pointing it back.
A deleted or banned listing must vanish fast. Give deletes top priority in the pipeline, and for urgent removals also filter results at query time against a small blocklist until the index catches up.
Part 4 · Search by Meaning
Search by meaning: embeddings
Keyword search fails when the words differ but the meaning matches. A shopper who types "shoes for standing all day" wants the listings that say "cushioned sole" and "arch support", yet none of them contains the word "standing". Vector search handles this. A machine-learned model turns each text into an embedding, a list of a few hundred numbers, often 768, placed so that texts with similar meanings get nearby lists.
Every document is embedded once, at index time. Each query is embedded when it arrives, and the engine returns the documents whose vectors sit closest to it. Comparing the query with all 50 million vectors would take about 38 billion multiplications (50 M × 768), far too slow at thousands of queries a second.
So engines build an approximate nearest neighbour (ANN) index. HNSW links each vector to a few dozen neighbours in a layered graph and walks greedily towards the query. IVF groups the vectors into thousands of clusters and searches only the few clusters nearest the query.
Approximate means some true matches are missed. Recall is the share of the true top 10 that the index returns. Every ANN index has a knob that trades speed for recall: how many graph nodes HNSW explores, or how many clusters IVF opens. Turn it up and recall rises towards 100% while each query slows; a common target is about 95%, measured on real queries.
Here is what those vectors cost in memory for the shopping app.
| Vectors for 50 M listings | Memory |
|---|---|
| 768 numbers × 4 bytes each | 50 M × 3 KB ≈ 150 GB |
| 768 numbers × 1 byte each (quantised) | ≈ 38 GB |
| HNSW graph links (32 per vector × 4 bytes) | ≈ 6 GB more |
Vectors are memory-hungry: about 3 KB each at full precision, and ANN indexes are fastest when the vectors sit in RAM. Storing each number in 1 byte cuts memory to a quarter at a small cost in recall; many engines win that back by re-scoring the best few hundred candidates with the full vectors kept on SSD.
Hybrid search: keywords plus meaning
Vectors are weak where keywords are strong. A shopper who pastes a model number or a seller's product code wants that exact item, and an embedding may happily return a similar but wrong one. Brand names, sizes and rare words behave the same way. So most systems run both searches side by side and merge the two lists: hybrid retrieval.
Merging is harder than adding scores, because a BM25 score and a vector similarity live on different scales. Reciprocal rank fusion (RRF) ignores the scores and uses only positions: a document earns 1 ÷ (60 + its rank) from each list it appears in, and the totals decide the order. A listing ranked 1st by keywords and 3rd by meaning scores 1/61 + 1/63 ≈ 0.032, ahead of one ranked 1st in a single list (1/61 ≈ 0.016).
A slower re-ranking model can then reorder the top few hundred, as in two-stage ranking. Three things are harder with vectors than with an inverted index.
- Filters: "under ₹2,000, size 9" is a cheap postings intersection in a keyword index, but an ANN graph has no such shortcut. Filtering after the vector search can leave 2 results out of 10, so engines filter during the walk or fetch extra candidates, such as 200 to return 10.
- Freshness: a new listing must pass through the embedding model before it can be found, and inserting into an HNSW graph costs more than adding postings. Changing the model means re-embedding all 50 M listings and swapping the alias, as with an analyser change.
- Sharding: shard by document, as before. Each shard holds an ANN index over its slice and returns its nearest 10, and the coordinator merges by similarity. Each shard's answer is approximate, so the merged one is too.
Part 5 · Running It
What goes wrong at scale
Slow shards are the best-known failure, but not the only one. These are the others that appear as traffic and data grow.
| Problem | What users see | What to do |
|---|---|---|
| Hot queries | The same query thousands of times a minute | Cache full result pages for popular queries for 30 to 60 seconds (see the distributed cache chapter) |
| Deep pages | Page 500 is slow and loads every shard | Cap the page depth, or page with a cursor: results after the last score and id seen |
| Uneven shards | One shard much bigger or busier than the rest | Place documents by hashing their id; split large shards; avoid sharding a shared catalogue by seller, where one seller can be huge |
| Indexing bursts | A bulk import slows down queries | Give indexing and querying separate machines or limits; throttle bulk re-indexing |
| Replicas out of step | Results change on every refresh | Send one user's queries to the same replica for the session |
| A dead machine | Nothing, if each shard has copies in other zones | Route around it; build a new copy by copying segments from a healthy replica |
Most fixes either keep the load even across shards or keep one heavy job from starving the queries. A machine that dies outright is the easy case, because replicas already cover it.
Lucene is the Java library inside many engines, with the inverted index, segments and BM25. Elasticsearch and OpenSearch are distributed engines built on it, with shards, replicas and aliases, a common default for product and log search; Solr is an older Lucene-based choice. FAISS is a library of ANN indexes for building your own vector service, and pgvector adds vector search to PostgreSQL, a good fit for a few million vectors beside your data.
In the interview
Search turns up inside many designs, and you rarely build the engine: you decide how data reaches it, how it is split and how queries use it.
- Say
- "The database stays the source of truth. Changes reach the index through a queue in about 2 seconds, and a lost index is rebuilt from the database."
- Say
- "I shard by document id: 3 shards of about 33 GB, each with 12 copies across three zones, so 4,000 queries a second survive a lost zone."
- Say
- "Every query waits for its slowest shard, so after 50 ms I hedge to a second copy: about 1% more load for a much shorter tail."
- Avoid
- "We will run LIKE queries on the main database." It scans every row, cannot rank, and drags checkout down with it.
- Avoid
- Sharding by term without explaining multi-word queries and hot terms.
Wrap-up
| Term | Meaning |
|---|---|
| Inverted index | A map from each term to the documents that contain it |
| Analyser | The steps that turn text into terms: split, lowercase, stem |
| BM25 | The usual text-relevance score, built on TF and IDF |
| Scatter-gather | Send a query to every shard, then merge their top results |
| Segment | An immutable piece of a shard's index, merged in the background |
| Alias | A name that points at one index; swap it to switch indexes |
| Embedding | A list of numbers that puts similar meanings close together |
| ANN index | Finds nearby vectors fast by giving up a little recall |
Next comes Distributed Logging: the same inverted index pointed at billions of log lines a day, and the question of what is worth keeping, and for how long.
Part 6 · Check yourself
Quick check
Q1A seller cuts a phone's price from ₹15,999 to ₹13,999. The product page updates at once, but search still shows the old price 20 minutes later. Where do you look?
Think it through, then check.
- The page reads the database; search reads the index, a copy fed by change events, and that pipeline is late or broken.
- High indexer lag means every change is late; normal lag means this change was never sent or an older one overwrote it.
- Fixes: capture changes from the change log, apply only newer versions, alert when lag passes a minute.
Q2Why shard a search index by document rather than by term, when every query must then visit every shard?
Think it through, then check.
- Each shard answers a multi-word query from its own lists, so no postings lists travel between machines.
- Load stays even and a new document touches one shard; by term, a popular term makes one shard hot.
Q3A scraper requests page 1,000 of "shoes", 10 results a page, on a cluster of 20 shards. How many candidates does the coordinator merge, and what do you do?
Think it through, then check.
- Page 1,000 is results 9,991 to 10,000, so each shard sends its top 10,000: 20 × 10,000 = 200,000 candidates.
- Page 1 needs 20 × 10 = 200, so this query costs 1,000 times more.
- Cap the depth, such as 50 pages, and offer a cursor (after the last score and id) beyond it.
Q4Shoppers type "shoes for standing all day" and get poor results. The team wants to replace keyword search with vector search. What do you recommend, and at what cost?
Think it through, then check.
- Keep both: vectors find meaning, but keywords win for brand names, model numbers and sizes.
- Merge the two lists with reciprocal rank fusion, which uses ranks, so the scoring scales never need to match.
- Costs: about 3 KB of memory per vector, an embedding call per listing and query, some lost recall, harder filters.
Q5A seller saves a new listing, searches for it at once, and it is missing; two seconds later it appears. Which idea from the databases chapter explains this, and how do you hide it?
Think it through, then check.
- The index is an asynchronous copy, like a follower with replication lag: here, the queue plus the roughly 1 s refresh.
- Read-your-writes: show the seller's own listings from the database, or add the saved listing to their results in the app.
- Do not refresh on every write: thousands of tiny segments would slow everyone.