Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Intermediate/I9
16 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDI9 · Part 2 · Intermediate

Search systems (surface)

The database scans; the inverted index looks up. Everything else in search is ranking and staleness.

19 min read2 diagrams9 sections0 exercises
16 of 36

In this chapter

  • Why the obvious query can never work
  • The inverted index, built from three documents
  • Analysis: the pipeline that decides what a term even is
  • Relevance: TF-IDF, then BM25, then reality
  • Textual relevance is not business relevance

and 4 more

about 19 min
09On this page
  1. Why the obvious query can never work
  2. The inverted index, built from three documents
  3. Analysis: the pipeline that decides what a term even is
  4. Relevance: TF-IDF, then BM25, then reality
  5. Textual relevance is not business relevance
  6. Elasticsearch in practice: shards, replicas, near-real-time
  7. Search is a secondary index, fed asynchronously
  8. Autocomplete is a different problem with a different index
  9. Recognizing it in an unseen problem

Why the obvious query can never work

Every search feature starts as SELECT * FROM items WHERE title LIKE '%running shoes%'. It works on 10,000 rows in a laptop demo and fails on every axis at scale, for reasons that are worth being able to state precisely rather than waving at.

  • The leading wildcard defeats the index. A B-tree orders by prefix, so it can answer "starts with run" but has no way to jump to rows containing run somewhere in the middle, so with a plain B-tree every such query becomes a full table scan. (Postgres's pg_trgm extension can index infix LIKE with a trigram GIN index, which fixes this one point, but not ranking or linguistic matching.)
  • Scans do not fit the latency budget. 50 million rows at 2 KB each is 100 GB. Even reading purely from page cache at several GB/s that is tens of seconds, and it burns the CPU and I/O of the database your writes depend on.
  • It is literal. "running shoes" does not match "shoes for running", "Running Shoe", or "sneakers". No stemming, no reordering, no synonyms.
  • There is no ranking at all. LIKE returns a set, not an order. With 40,000 matches you have no principled way to pick the 10 to show, and the ordering is the entire product.

Postgres full-text search (tsvector plus a GIN index) fixes the first three and part of the fourth (its ts_rank ranks from per-document information; the Postgres docs note the ranking functions use no global information, so it is not corpus-aware BM25), and is genuinely the right answer up to a few million documents with modest query volume. Say so — reaching for Elasticsearch on day one is over-engineering and a good interviewer will say as much. But both roads lead to the same data structure, so understand it properly.

The inverted index, built from three documents

A normal index maps document → contents. An inverted index maps term → the documents containing it, which is the direction a query actually needs. Each entry in the map is a postings list: the sorted document IDs for that term, usually with positions and term frequencies alongside.

D1  red running shoes D2  red shoes for kids D3  running shoes review analyze red  →  [D1, D2] run  →  [D1, D3] shoe  →  [D1, D2, D3] kid  →  [D2] review  →  [D3] query "red running" → red[D1,D2] ∩ run[D1,D3] = D1 shoe matches everything, so it carries almost no signal — that is IDF
Both postings lists are sorted, so the intersection is a linear merge over two short lists — never a scan of the corpus.

The performance property is the whole point: query cost scales with the length of the postings lists for the query's terms, not with corpus size. Adding fifty million documents that contain neither red nor run costs those queries nothing. And because the lists are sorted integer sequences, they compress viciously well (delta encoding plus variable-byte or bitpacking), so the index is often a fraction of the raw corpus size and can largely live in page cache (how much smaller depends on what you store: positions, stored fields and doc values change it a lot, so treat any ratio as a planning figure and measure).

Postings usually store more than IDs. Term frequency per document powers scoring; term positions power phrase queries, so "red shoes" as an exact phrase can check that red at position i is followed by shoe at position i+1 rather than merely co-occurring in the document.

Dry run: scoring the query "red running" with TF-IDF over the three documents above

DocumentTerms (after analysis)tf-idf(red)tf-idf(run)Score
D1 — "red running shoes"red, run, shoe0.4050.4050.811 — ranks first
D2 — "red shoes for kids"red, shoe, kid0.4050 (absent)0.405
D3 — "running shoes review"run, shoe, review0 (absent)0.4050.405

idf(red) = idf(run) = ln(3/2) ≈ 0.405, since each appears in 2 of the 3 documents. idf(shoe) = ln(3/3) = 0 — "shoe" appears in every document, so it contributes exactly nothing to any score, which is the arithmetic behind the figure's claim that it "carries almost no signal." Boolean intersection returns only D1; TF-IDF instead ranks D1 highest but still surfaces D2 and D3 as partial matches, which is the entire reason a ranking stage exists on top of retrieval.

Analysis: the pipeline that decides what a term even is

Notice that "running" became "run" and "for" disappeared (this example uses a classic stop-word list). That transform is the analyzer, and it must produce compatible terms at index time and at query time — the non-negotiable rule of search, because a query term that was analyzed incompatibly with the document term will simply never match. Usually that means the same analyzer; Elasticsearch also lets a field have a separate search_analyzer, which is exactly how edge n-gram autocomplete works: the index side emits n-grams, the query side does not.

StageDoes"Red Running Shoes, for Kids!" becomes
Character filterStrip HTML, normalize punctuation and accentsRed Running Shoes for Kids
TokenizerSplit into terms on whitespace/punctuation rules[Red] [Running] [Shoes] [for] [Kids]
LowercaseCase-fold[red] [running] [shoes] [for] [kids]
Stop words (classic pipelines)Drop extremely common, low-information terms[red] [running] [shoes] [kids]
StemmingReduce inflections to a common root[red] [run] [shoe] [kid]
Synonyms (optional)Expand or normalize equivalents[red] [run] [shoe] [sneaker] [kid]

Three things to know about these stages, because each has a real failure mode. Tokenization is language-specific: whitespace splitting is useless for Chinese and Japanese, which need dictionary-based segmentation, and it mangles identifiers like C++ or wi-fi. Stemming is a lossy heuristic — Porter stemming maps "university" and "universe" both to "univers", and "operating" to "oper"; lemmatization is the dictionary-based, slower, more accurate alternative. Stop words are no longer a clear win: they were a compression trick from when index size dominated, and dropping them breaks phrase queries like "to be or not to be" and "The Who". Modern setups usually keep them and let IDF discount them automatically: Elasticsearch's standard analyzer, for instance, has stop words off by default.

⚠ Changing index-time analysis means reindexing everything Whatever the analyzer emitted at index time is baked into the index. Switch stemmers or tokenizers, or add synonyms at index time, and existing documents keep terms produced by the old rules while queries use the new ones — matches silently disappear until you reindex. The exception is worth knowing: search-time-only synonyms (a synonym_graph filter marked updateable in a search analyzer) can be reloaded with the _reload_search_analyzers API without reindexing. For everything else, production search runs index aliases: build the new index in parallel, backfill it, then atomically swing the alias. Volunteering that operational detail is a strong signal, because it is the thing that hurts in real life.

Relevance: TF-IDF, then BM25, then reality

Once you have the matching set you must order it. The classical intuition is TF-IDF, built from two opposing forces:

  • Term frequency. A document mentioning "kayak" eight times is more about kayaks than one mentioning it once.
  • Inverse document frequency. A term appearing in nearly every document (shoe in our three-doc corpus, or the in any corpus) discriminates nothing, so its weight collapses toward zero. Rare terms carry the signal.

A document's score for a query is the sum, over the query's terms, of term-frequency times inverse-document-frequency. BM25 is the modern default and fixes two concrete defects in that formula, both worth naming:

  • Term frequency saturates. Under raw TF-IDF a page repeating "kayak" 500 times scores 500× a page mentioning it once, which is both wrong and trivially game-able. BM25 applies diminishing returns via a tunable parameter (k1, typically ~1.2), so going from 1 to 5 occurrences matters a lot and 50 to 500 matters almost nothing.
  • Length normalization. A long document naturally contains more terms and would otherwise win everything. BM25 divides by document length relative to the corpus average, with a parameter b (typically 0.75) controlling how hard that penalty bites.

You do not need the closed form on a whiteboard. Saying "BM25 — TF-IDF with saturating term frequency and length normalization, and it's the default in Lucene" demonstrates exactly the right depth for a system design round. If someone pushes further, the honest frontier is that BM25 is a lexical score, and modern stacks add a vector/embedding retrieval arm to catch semantic matches BM25 misses, then fuse the two result sets. The vector side is approximate nearest-neighbour search (HNSW graphs are the common index), and a simple, robust fusion is reciprocal rank fusion: each document scores the sum of 1 / (k + rank) across the lists, with k = 60 by default in Elasticsearch, so no score normalisation is needed.

Two more terms that come up: spell correction ("did you mean", usually edit distance against the vocabulary, or a model trained on query logs) and learning to rank (a trained model, often gradient-boosted trees, that reorders the few hundred candidates using many features). And how do you know any of it is good? Offline, humans or click logs label results and you report NDCG@10 or MRR; online, you A/B test click-through and reformulation rate.

Textual relevance is not business relevance

BM25 answers "which document is most about these words." Nobody actually wants that. A news search wants recent things; a marketplace wants items that are in stock and convert; a music app wants the song people are streaming this week. Pure lexical relevance would happily rank a perfectly matching, sold-out, three-year-old listing first.

Real systems therefore run two stages, and the separation is the architectural point:

StageInput sizeCost per docSignals used
Retrieval (candidate generation)Millions → a few hundredMust be microsecondsBM25 over the inverted index, plus hard filters (in stock, region, permissions)
Ranking (rerank)A few hundred → 10Can afford milliseconds eachRecency decay, popularity, click-through history, personalization, a learned model

The reason to split is pure economics: you cannot run a gradient-boosted model or a cross-encoder over ten million documents inside a 200 ms budget, but you can absolutely run it over 300. Blending is usually multiplicative or a weighted sum over normalized signals — a common shape being final = bm25_norm × recency_decay × log(1 + popularity), where recency decay is an exponential with a half-life chosen per vertical (hours for news, months for products).

⚠ Hard filters must happen in retrieval, not rerank If "only show items I'm permitted to see" or "only in-stock" is applied after fetching the top 100, a user whose permitted set is rare gets an empty page while millions of matching documents exist. Permissions and availability belong in the index as filterable fields so the engine intersects them with the postings lists. Filtering after ranking is a correctness bug that presents as a relevance bug.

Elasticsearch in practice: shards, replicas, near-real-time

Elasticsearch and OpenSearch are distributed wrappers around Lucene, and the three operational concepts you need are all visible in how a document becomes searchable.

  • Shards partition the index. Each shard is an independent Lucene index; a query fans out to every shard, each returns its local top-k, and a coordinating node merges them. Elastic's sizing guidance is 10-50 GB per shard and under about 200 million documents. Over-sharding is the common mistake — each shard's search runs on a CPU thread, every query pays coordination overhead on every shard, and a 20 GB index split into 100 shards is dramatically slower than the same data in 2. Fan-out also amplifies tail latency: the query finishes when the slowest shard does, so if each of 100 shards is slow on 1% of requests, about 63% of queries (1 − 0.99^100) hit at least one slow shard. Note the shape: this is a document-partitioned (local) secondary index, where each shard indexes whole documents and every query scatters to all shards; a term-partitioned index would send a query only to the shards owning its terms but makes writes touch many shards (the trade-off is laid out in Designing Data-Intensive Applications). Because each shard scores with its own statistics, tiny or skewed shards can distort IDF; dfs_query_then_fetch collects global term statistics first, at the cost of an extra round trip.
  • Replicas are full copies of a shard. They provide both availability (a lost node loses no data) and read throughput (queries are spread across copies). Writes go to the primary and replicate; read scaling is therefore cheap, write scaling requires more primaries, which requires more shards. The primary-shard count of an existing index cannot be edited in place, but the split API creates a new index with a multiple of the shard count from a read-only source without a full reindex, and time-based data uses rollover to start each new index with a fresh shard count.
  • Near-real-time. Lucene segments are immutable. A new document goes to an in-memory buffer and is not searchable until a refresh creates a new segment — by default every 1 second (with one wrinkle: if you have not set refresh_interval explicitly, a shard that has had no search for 30 seconds, index.search.idle.after, stops refreshing in the background until the next search arrives, and that search triggers the refresh). That one second is not a bug you can configure away for free: refreshing more often produces more tiny segments and more merge pressure. For bulk loads the standard move is to disable refresh entirely, index, then refresh once.

Immutable segments also explain deletes and updates. A delete just marks a tombstone; an update is a delete plus an insert. Space is only reclaimed when background merges rewrite segments together, so a heavily-updated index carries dead weight and periodic merge I/O spikes. This is why Elasticsearch is a poor primary datastore for frequently-mutating rows and a great one for append-heavy documents. (Segments that are written immutably and merged in the background are the same idea as a log-structured merge tree.) One more practical limit: from + size paging stops at 10,000 hits by default; deep pagination uses search_after with a point in time, so "jump to page 5,000" is not a feature you promise.

Search is a secondary index, fed asynchronously

This is the architectural claim that matters most in an interview, and the one candidates most often get backwards. Your search cluster is not your source of truth. It is a derived, denormalized, rebuildable projection of data that lives authoritatively somewhere else.

app Postgres source of truth CDC / outbox Kafka indexer bulk, map, retry search cluster secondary index write change publish typical lag: about 1 s query a write committed in Postgres is not yet findable in search — design the UX for that
The search cluster is downstream of the truth, always. Anything you can only learn by querying it is data you are prepared to lose and rebuild.

Three consequences follow, and each is an interview point on its own:

  • No read-your-writes. A user edits a listing and immediately searches for it; the change is committed but not yet indexed. Fixes, in increasing order of effort: tell the UI it may take a moment; read the just-written record from the primary and splice it into the results; or make the write path wait for an index acknowledgement, which trades latency for consistency and is rarely worth it.
  • Never dual-write. The tempting implementation is "write to Postgres, then write to Elasticsearch" in the same request handler. It fails the instant the second write errors or the process dies between them, and it leaves permanent, silent divergence. Use the transactional outbox (write the row and an outbox event in one transaction, a relay publishes the event) or change data capture from the database's replication log. Either way there is exactly one atomic commit.
  • It is disposable, and that is the point. Because the index is derived, you can always rebuild it from source. This is what makes the alias-swap reindex safe, and it means a corrupted search cluster is an availability incident, not a data-loss incident. Recovery is a choice: restoring a snapshot is usually the faster way back for a large index, and a reindex from source is the fallback (and the only way to apply a mapping or analyzer change). It also means search should degrade gracefully — if the cluster is down, fall back to a database prefix lookup or a "search is temporarily unavailable" state rather than failing the whole page.
Say it like this → "Search is a secondary index. The relational store stays the source of truth, and I'll propagate changes with CDC or a transactional outbox into a stream, with an indexer doing bulk writes into Elasticsearch. That gives me eventual consistency on the order of a second, so I'd handle read-your-writes at the API layer by merging the freshly-written record into results. If the cluster dies I restore the latest snapshot, or rebuild from the source if that is faster or the mapping changed."

Autocomplete is a different problem with a different index

Autocomplete fires on every keystroke, so its budget is tight (roughly 50 ms server-side is a common target, a planning figure rather than a standard), and it must handle a 2-character prefix matching millions of entries. Running a full BM25 query per keystroke is both too slow and semantically wrong — "shoe" typed as "sho" is not a term.

ApproachHowReach for this when…
Edge n-gramsIndex "shoes" as sh, sho, shoe, shoes — a prefix becomes an exact term lookupYou want it inside your existing search cluster with no new infrastructure; costs index size
Trie / FSTPrefix tree in memory; walk the prefix, then collect the top-k completions stored at that nodeYou need the lowest possible latency and control over the ranked suggestions
Precomputed top-k per prefixKey-value store: prefix → the 10 best completions, refreshed offline from query logsQuery volume is enormous and the suggestion set changes slowly — a single cache hit per keystroke
Fuzzy / typo-tolerantLevenshtein-bounded automaton over the FST, usually edit distance 1 for short termsMobile users; be careful, it multiplies the candidate set

The crucial insight is that suggestions should be ranked by what people search for, not by what exists in the catalogue. Build the suggestion index from query logs weighted by frequency and click-through, which also means it is small, offline-rebuildable, and can be pushed entirely into memory. And debounce on the client (wait 100-150 ms after the last keystroke, a typical starting value) — typically the cheapest large traffic reduction in the whole design; measure your own savings. At very large scale the suggestion service is usually sharded by prefix, with the top-k completions per prefix cached.

Recognizing it in an unseen problem

  • The prompt says "search", "find", "filter by keyword", "typeahead", or describes users looking for something by name or description rather than fetching it by ID.
  • A naive design puts LIKE '%term%' on the primary database and never mentions ranking — the two failures to call out immediately are the full scan and the absence of any ordering.
  • Distinguish it from a database indexing question: a B-tree answers "give me the rows where x = y" exactly; search answers "give me the best rows for this fuzzy human intent, in order." Ranking is the tell.
  • Distinguish it from a recommendation question: search has an explicit query string, recommendations do not. They share the two-stage retrieve-then-rank architecture, which is a good connection to draw.
  • Any time you introduce a search cluster, immediately say how it gets fed and what the staleness window means for the user — the async secondary-index point is worth more than any detail about BM25.
  • Pitfall: forgetting permissions and filters belong in the index. Post-filtering ranked results silently returns empty pages to exactly the users with the narrowest access.
Before you move on
  • Explain why a leading-wildcard LIKE query can't use a B-tree index, and why an inverted index sidesteps that problem.
  • Compute a TF-IDF score by hand for a small corpus, and explain why a term appearing in every document gets an IDF of zero.
  • Name the two defects BM25 fixes relative to raw TF-IDF, and why both matter for a real corpus.
  • Explain why hard filters (permissions, in-stock) must be applied during retrieval rather than after ranking, and what goes wrong if they aren't.
  • Describe the write path from a database commit to a document becoming searchable, including where staleness enters and how you'd handle read-your-writes.
  • Say which analyzer changes need a reindex and which can be reloaded, and why a separate search analyzer exists.
Sources and further reading
  • Elastic: size your shards — the 10-50 GB guidance, document limits and oversharding effects
  • Elastic: index settings (refresh and search idle) — the 1 second refresh and the 30 second idle behaviour
  • Elastic: synonym graph token filter — updateable search-time synonyms
  • Elastic: standard analyzer — stop words off by default
  • Elastic: split index API — adding primary shards without a full reindex
  • Elastic: paginate search results — the 10,000 hit window and search_after
  • Elastic: reciprocal rank fusion — fusing lexical and vector rankings
  • PostgreSQL documentation: pg_trgm — trigram indexes for infix LIKE
  • PostgreSQL documentation: ranking search results — ts_rank uses no global information

Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

I9

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. I8Previous · I8Storage systems
  2. I9you are here
  3. Next · I10Microservices and service discoveryWhen splitting a system pays for itself, what it costs, and how one service finds another once the addresses keep changing.14 min read

01On this page

9 sections · 0%

  1. 01Why the obvious query can never work
  2. 02The inverted index, built from three documents
  3. 03Analysis: the pipeline that decides what a term even is
  4. 04Relevance: TF-IDF, then BM25, then reality
  5. 05Textual relevance is not business relevance
  6. 06Elasticsearch in practice: shards, replicas, near-real-time
  7. 07Search is a secondary index, fed asynchronously
  8. 08Autocomplete is a different problem with a different index
  9. 09Recognizing it in an unseen problem

[] chapters · t top