Design: Web Search Engine¶
Difficulty: Staff | Time: 60–75 minutes
Instructions
Cover each section and work it yourself first. Use "Hint" tabs if stuck. This exercise assumes a corpus already exists — see Web Crawler for how pages get discovered and fetched. Practice explaining out loud — the way you communicate matters as much as the solution.
1. Problem Statement¶
Design the system behind a web search engine: given a corpus of crawled documents, build a structure that answers "which documents contain this term, and in what order should they be shown" in under 200ms, over billions of documents and millions of unique terms.
This is a different problem shape than the other two search-adjacent exercises on this site. Web Crawler covers discovery — how pages get found and fetched into a corpus; it stops at "content is stored." Autocomplete covers prefix search over a bounded set of past queries, ranked by frequency. Neither builds an inverted index or does relevance ranking over free-text queries against a full-web corpus — that's what's new here: index-then-query, at a scale where the index itself doesn't fit on one machine.
Out of scope: crawling/fetching mechanics (link to web-crawler.md), typeahead-as-you-type suggestions (link to autocomplete.md), and the crawler's freshness scheduling. In scope: turning crawled documents into a queryable index, ranking results, and serving queries fast at scale.
2. Clarifying Questions¶
Practice asking these before designing:
What questions should you ask?
- Corpus size: How many documents? Growing at what rate?
- Query volume: Queries/second at peak, and average query length (terms)?
- Latency target: What's the end-to-end budget for a search results page?
- Freshness: Does a page need to be searchable minutes after crawl, or is daily reindexing acceptable?
- Ranking inputs: Term frequency only, or also link authority (PageRank-like), freshness, click-through data?
- Query features: Exact phrase, boolean operators, typo tolerance, personalization?
- Result format: Top-10 with snippets, or paginated deep results?
- Content removal: Do takedown/right-to-be-forgotten requests need to propagate, and how fast?
3. Functional Requirements¶
- Ingest crawled documents and build a searchable inverted index (term → documents containing it)
- Accept a free-text query and return the top-K most relevant documents
- Rank results by relevance (term frequency/rarity at minimum; link authority as a stretch signal)
- Support incremental index updates as new/changed documents arrive from the crawler
- Support content removal (a document must stop appearing in results)
4. Non-Functional Requirements¶
| Property | Requirement | Reasoning |
|---|---|---|
| Query latency | < 200ms p99 end-to-end | Users perceive anything slower as broken |
| Scale | 10B+ documents, 5M+ unique terms | Index cannot fit in one machine's memory |
| Query throughput | 100K+ queries/second at peak | Front page of the internet |
| Freshness | New/changed docs searchable within hours, not real-time | Batch indexing is far cheaper than per-write updates |
| Availability | 99.99% — search is the whole product | A slow shard must not stall the whole query |
| Consistency | Eventual is fine — a few hours of index staleness is invisible to users | Correctness of ranking matters more than millisecond freshness |
Interview Insight 🎯
Say the shape of the problem out loud: this is index-then-query — an expensive offline build (index construction, ranking-signal computation) feeding a cheap, latency-critical online path (scatter-gather over shards). Almost every design decision below is about keeping those two phases from blocking each other.
5. Capacity Estimation¶
Corpus:
10B documents, avg 2 KB extracted text (post-HTML-stripping) → 20 TB raw text
Avg distinct terms per document: ~500 (after stopword removal, stemming)
Unique terms across corpus: ~5M (Zipfian — long tail of rare terms)
Index size (postings):
Total postings = 10B docs × 500 terms/doc = 5 trillion postings
Per posting (doc_id + term_freq + position list, compressed): ~4-8 bytes avg
Raw index size: 5T × 6 bytes ≈ 30 TB (before compression)
With delta-encoding + variable-byte compression: ~8-12 TB
Postings list skew:
Common term ("the", "search"): appears in ~30% of docs → 3B postings, tens of GB for ONE term
Rare term: appears in a handful of docs → a few bytes
This 6-7 order-of-magnitude spread is the central sizing problem — see bottleneck below.
Query load:
100K qps peak, avg query = 3 terms → 300K term lookups/second
Query latency budget: 200ms total → ~50-80ms for index scatter-gather, rest for ranking + network
Index build:
10B docs reindexed on a rolling basis; incremental deltas (new/changed docs) ~50M/day
Full rebuild (e.g. new ranking signal): 10B docs, parallelized across thousands of workers, hours not minutes
Mental Model
You are building a dictionary lookup that returns a list, at a scale where both the dictionary and the lists are too big for one machine, and where the lists are wildly uneven in size. Every version below is about (1) making the lookup cheap, (2) keeping the uneven lists from dominating latency, and (3) refreshing the whole thing without stopping queries.
6. API Design¶
GET /v1/search?q={query}&k=10&offset=0
Response:
{
"results": [
{ "doc_id": "...", "url": "...", "title": "...", "snippet": "...", "score": 8.42 }
],
"total_estimate": 4210000,
"latency_ms": 87
}
# Ingestion (internal, called by the crawler pipeline / indexing job)
POST /internal/index/documents
{ "doc_id": "...", "url": "...", "text": "...", "crawled_at": "...", "links_out": ["..."] }
→ 202 Accepted (queued for batch indexing, not synchronous)
DELETE /internal/index/documents/{doc_id} -- takedown / removal request
→ tombstones the doc; must be honored before the next full rebuild completes
Interview Insight 🎯
Notice the ingestion endpoint returns 202, not 200 — indexing is a batch/async process, not a synchronous write. If you design this as "call POST and the doc is searchable," you've implied a per-write index update at 10B-document scale, which is the wrong default (see Version 2's freshness discussion).
7. Data Model¶
Inverted index (the core structure):
term → postings_list
postings_list = sorted list of (doc_id, term_frequency, [positions])
Example:
"search" → [(doc_42, tf=3, [12, 88, 340]), (doc_901, tf=1, [5]), ...]
"engine" → [(doc_42, tf=1, [13]), (doc_1055, tf=7, [...]), ...]
Document store (separate from the index — parallels pastebin's metadata/blob split):
doc_id → { url, title, raw_text_pointer, links_out, crawled_at, pagerank_score }
Lives in a KV/blob store, keyed by doc_id — the index only ever stores doc_ids, never full text.
-- Document metadata, small and hot — lives near the ranking service
CREATE TABLE documents (
doc_id BIGINT PRIMARY KEY,
url TEXT NOT NULL,
title VARCHAR(512),
content_ptr VARCHAR(256), -- pointer into blob storage for full text/snippet generation
pagerank FLOAT DEFAULT 0,
crawled_at TIMESTAMPTZ NOT NULL,
removed BOOLEAN DEFAULT FALSE -- tombstone for takedowns
);
Why the index doesn't fit on one machine: 8-12 TB of postings alone exceeds any single machine's usable RAM, and disk-only serving blows the 200ms budget once you account for seek time across a structure this large. Beyond raw size, a single popular term's postings list can be tens of GB by itself — one machine holding the full index still has to scan gigabytes for one term in one query. The index must be partitioned — the only question is by what axis (Version 2).
8. Version 1 — simplest thing that works¶
Single machine. Build the entire inverted index in memory (or memory-mapped from disk) from a small corpus, rank with brute-force TF-IDF, no sharding.
graph LR
Docs[Crawled documents] --> Build[Index builder\nsingle process]
Build --> Idx[(In-memory inverted index)]
Client -->|query| Search[Search process]
Search -->|lookup terms| Idx
Search -->|TF-IDF rank| Client from collections import defaultdict
import math
def build_index(documents: dict[str, str]) -> dict[str, list[tuple[str, int]]]:
index = defaultdict(list)
for doc_id, text in documents.items():
counts = defaultdict(int)
for term in tokenize(text):
counts[term] += 1
for term, tf in counts.items():
index[term].append((doc_id, tf))
return index
def search(index, documents, query: str, k: int = 10):
terms = tokenize(query)
n_docs = len(documents)
scores = defaultdict(float)
for term in terms:
postings = index.get(term, [])
idf = math.log(n_docs / (1 + len(postings)))
for doc_id, tf in postings:
scores[doc_id] += tf * idf # brute-force TF-IDF
return sorted(scores.items(), key=lambda x: -x[1])[:k]
This works for a corpus that fits in memory — thousands to low millions of documents. Ship it, then find the actual bottleneck before adding infrastructure.
9. Identify the bottleneck¶
You grow the corpus toward the real target (10B documents). What breaks first?
- Index size exceeds one machine. 8-12 TB of postings does not fit in RAM on any single box, and paging from disk for every query blows the 200ms budget — disk seeks across a structure this large dominate latency.
- A single popular term's postings list is enormous on its own. A query containing "the," "search," or any common word forces a scan of a postings list with billions of entries — even if the rest of the index were somehow small enough to fit, this one list alone can take longer than the entire latency budget to scan and score.
- Brute-force ranking doesn't scale with list length. Scoring every posting in a multi-billion-entry list per query is O(list length), not O(k) — you need either a smarter data structure (skip lists to jump ahead) or a way to avoid touching the full list at all.
- What's not the bottleneck yet: query throughput at V1 scale, or write volume (indexing is batch, not per-request).
10. Version 2 — shard the index¶
Partition the index across many machines. Two axes, and the choice matters:
- Document-based sharding: each shard holds a random/hashed subset of documents, with its own complete mini-index over just those docs. A query fans out to every shard (each might hold a matching doc), each shard scores its local candidates, and a coordinator merges top-K results. Load balances evenly — no shard is disproportionately hot, because "the" is just as common in every shard's subset. Cost: every query touches every shard, so tail latency is dictated by the slowest shard among thousands.
- Term-based sharding: each shard owns a range of terms, holding the complete postings list for those terms across the whole corpus. A query for "python tutorial" only needs to contact the 1-2 shards owning "python" and "tutorial" — far less fan-out per query. Cost: postings-list size is Zipfian, so shards owning common terms are wildly bigger and hotter than shards owning rare terms — a rebalancing and hot-shard problem that document-based sharding doesn't have.
Pick document-based sharding. At this scale, predictable per-query fan-out cost (touch everything, but evenly) is easier to reason about and load-balance than chasing an ever-shifting hot-shard problem on term ranges. It's also what lets index-building parallelize trivially — each shard builder only needs its own document subset, no global coordination on term boundaries.
graph TD
Client -->|query| Coord[Query Coordinator]
Coord -->|scatter| S1[Shard 1: docs 0-999M]
Coord -->|scatter| S2[Shard 2: docs 1B-1.999B]
Coord -->|scatter| S3[Shard N: ...]
S1 -->|top-K local| Coord
S2 -->|top-K local| Coord
S3 -->|top-K local| Coord
Coord -->|gather + merge| Client Ranking also gets smarter here. Raw TF-IDF ignores document length and over-rewards term stuffing; production systems use BM25 (TF-IDF's successor — saturates term-frequency contribution and normalizes for document length) as the base relevance signal, often blended with a link-based authority signal like PageRank (a document linked-to by many other important documents ranks higher, independent of query terms). Both are conceptual here — the exercise is knowing that they exist and why they're additive signals, not deriving the math.
11. Identify the next bottleneck¶
Sharding fixes single-machine limits. What breaks next, at 100K qps and a constantly-changing web?
- Tail latency from fan-out. Every query hits every shard (document-based sharding), so p99 latency is governed by the slowest shard on that query, not the average — see Tail Latency. One GC pause or one overloaded shard degrades every query that happens to land on it, not just its own traffic.
- Index staleness. The web changes constantly, but rebuilding a 10B-document, 8-12 TB index is a multi-hour batch job. If freshness requirements tighten (e.g. "breaking news searchable in 15 minutes"), a full rebuild cadence can't deliver that — you need a way to layer in recent changes without waiting for the next full rebuild.
- Ranking-signal staleness compounds this — PageRank-like link scores are computed from the link graph, which itself needs a batch job over the whole corpus; a newly-created highly-linked page won't get its authority boost until that job reruns.
12. Version 3 — production architecture¶
graph TD
Crawl[Crawler pipeline<br/>see web-crawler.md] --> Raw[(Raw document store)]
Raw --> Batch[Offline index-build pipeline<br/>MapReduce-style, sharded by doc range]
Raw --> LinkGraph[Link-graph / PageRank batch job]
Batch --> ShardIdx[(Sharded index: N document-partitioned shards)]
LinkGraph --> RankSvc[Ranking service]
Raw --> Delta[Incremental delta indexer<br/>recent docs only, small merge]
Delta --> ShardIdx
Client --> Coord[Query Coordinator<br/>scatter-gather]
Coord --> ShardIdx
ShardIdx -->|local top-K per shard| Coord
Coord --> RankSvc
RankSvc -->|blended BM25 + PageRank + freshness| Coord
Coord --> Cache[(Result cache<br/>popular queries)]
Cache --> Client
Coord --> Client Key production decisions:
- Batch index build stays the backbone. Full rebuilds run on a schedule (daily/weekly) across thousands of workers, sharded by the same document ranges the serving layer uses — no reshuffle needed between build and serve.
- A small incremental delta index absorbs freshness. New/changed documents since the last full build go into a much smaller delta structure per shard, merged into query results alongside the main index and folded into the next full rebuild. This is the same pattern as an LSM tree's memtable-plus-SSTables — a cheap, fast-changing layer sitting in front of an expensive, rarely-rebuilt one.
- Scatter-gather coordinator with per-shard timeouts. The coordinator issues parallel requests to all shards and returns as soon as it has enough (e.g. after a fixed deadline or after N-of-M shards respond), rather than waiting on the single slowest one — trading a small amount of recall for a bounded p99.
- Ranking is a separate service, not baked into each shard, so relevance-signal changes (new features, model updates) deploy independently of the index itself.
- Result cache in front of the coordinator absorbs repeat/popular queries — a large fraction of query volume in practice is a small set of head queries, so caching there cuts scatter-gather load dramatically.
13. Failure analysis¶
Scatter-gather degrades to N-1 shards, or the coordinator's deadline cuts off a slow shard's response. Mitigation: bounded per-shard timeout with partial results returned (missing a small slice of the corpus beats a slow full page); replicate each shard (2-3x) so the coordinator can route around one bad replica instead of losing that shard's documents entirely; track per-shard p99 and auto-drain a consistently slow replica from rotation.
The full rebuild cadence slips — a bug, a capacity shortfall, or a burst of crawled volume — and the delta index grows past what it was sized for, slowing every query that has to merge a large delta on top of the stale main index. Mitigation: alert on delta-index size and on rebuild-job age; delta indexes are meant to stay small (hours to a day of documents) — if they're not, prioritize finishing the rebuild over shipping new ranking features.
A term that never got cached (or expired) suddenly gets hit by a burst of identical queries — a breaking-news term, for instance — and every request misses the result cache simultaneously, all landing on the shards at once. Mitigation: single-flight/request-coalescing at the coordinator (one in-flight scatter-gather per unique query, others wait on it rather than triggering duplicates); short TTL with jitter to avoid synchronized expiry; pre-warm cache for known trending terms if a trends signal exists upstream.
The PageRank-like batch job is expensive and runs infrequently; a newly popular, heavily-linked page doesn't get its authority boost for days, so it under-ranks against older, well-established competitors. Mitigation: blend a fast-moving freshness/recency signal alongside the slow authority signal so new content isn't purely dependent on link-graph recompute; treat link-graph staleness as an accepted trade-off and monitor the age of the last successful run rather than trying to make it real-time.
14. Consistency considerations¶
- Eventual consistency is the correct default for search results. A document being searchable a few hours after it's crawled, or a stale ranking score for a day, is invisible to users and far cheaper than trying to keep the index synchronously up to date.
- Freshness vs. latency is the core trade-off, not freshness vs. correctness. Making the index "more real-time" (smaller, more frequent delta merges) costs query latency (more structures to merge per query) and build infrastructure cost; making it batchier costs freshness. Pick a target (e.g. "searchable within a few hours") and design the delta-index size around it rather than chasing "instant."
- Takedowns/removals are the one place strong-ish consistency matters. A tombstone (
removed = true) must be checked at query time regardless of index build state — same pattern as pastebin's TTL check — so a removal is effective immediately even if the document isn't physically purged from the index until the next rebuild.
15. Observability¶
Metrics:
query_latency_p50/p95/p99 (SLO: p99 < 200ms)
shard_response_time_p99 (per shard — find the slow one)
shard_timeout_rate
coordinator_partial_result_rate (how often we return without all shards)
cache_hit_rate{cache=result_cache}
index_build_duration, index_build_age
delta_index_size (alert if it grows past expected bound)
ranking_signal_age (link-graph job last-success timestamp)
Alerts:
p99 query latency > 300ms
any shard p99 > 3x fleet median
delta_index_size > 2x expected
full_rebuild_age > 2x scheduled cadence
cache_hit_rate drop > 20% (possible stampede or cold cache after deploy)
16. Cost analysis¶
Index storage (8-12 TB compressed, replicated 3x for availability): ~30-36 TB
On SSD-backed shard hosts: ~$3,000-5,000/month depending on provider
Serving fleet (thousands of shard replicas + coordinators):
Dominated by compute for scatter-gather fan-out at 100K qps — largest line item
Batch index-build pipeline (periodic, not always-on):
Thousands of worker-hours per full rebuild; amortized cost depends on cadence
Daily rebuild is far more expensive than weekly — tune cadence against freshness requirement, not habit
Result cache (Redis/similar, sized for head-query set):
Small relative to index/serving cost — but the highest-leverage cost lever, since
a small cache absorbing a large fraction of query volume directly cuts shard fan-out cost
Cost lever: term-based sharding would reduce per-query shard fan-out (fewer machines touched
per query) but reintroduces the hot-shard rebalancing cost this design deliberately avoided —
revisit only if serving-fleet cost dominates and hot-shard tooling already exists.
17. Alternative architectures¶
Each shard owns a term range; a query only fans out to the 1-2 shards owning its terms. Lower per-query fan-out and lower average latency, at the cost of severe load imbalance (a shard owning "the" is orders of magnitude hotter than one owning a rare term) and much harder rebalancing as the term distribution shifts. Better fit when queries are short and the term-frequency distribution is well understood and can be actively rebalanced.
Instead of batch rebuild + delta merge, update the index in near-real-time per document (closer to a search database like Elasticsearch's near-real-time refresh). Much fresher, but a per-write index update at 10B-document scale is dramatically more expensive in aggregate than batch, and complicates ranking-signal consistency (a link-graph score can't be recomputed per-write). Reasonable for a smaller, more dynamic corpus (e.g. a product catalog); not the default choice for open-web search.
Keep the whole index logically together but tier by document popularity/freshness — hot documents in memory, cold in cheaper storage — rather than sharding by range. Simplifies query routing (no coordinator fan-out) but reintroduces the single-machine-index scaling wall this design exists to avoid; only viable at a much smaller corpus size than the web.
18. Staff Engineer Extensions¶
Result cache hit rate becomes the dominant lever — a large majority of that volume is repeat head queries, so cache capacity and hit rate matter more than shard count. Beyond that, horizontally add shard replicas (read-only, cheap to scale) rather than more primary shards — replicas don't need to participate in index builds, only serve reads.
Replicate the built index globally (read-only artifact, easy to ship region-to-region once built) so queries stay in-region for latency. Keep the index-build pipeline centralized in one region — it's a batch job reading from a single raw-document store, and running it redundantly per region multiplies build cost without a freshness benefit. Ship the finished index artifact, not the raw corpus, to each region.
A genuinely hard constraint here: removal must propagate everywhere the index is replicated, and "everywhere" includes region copies and any delta indexes in flight. Tombstone at the document store first (checked at query time regardless of index state, same pattern as takedowns above) so the removal is effective immediately, then propagate the physical purge through the next full rebuild in every region. Track propagation lag per region and alert if a tombstoned document is still being served past an SLA window — this is one of the few places in this design where "eventually consistent" needs a hard upper bound, not just a best effort.
Changing the postings-list format (e.g. adding a new ranking feature per posting) or the ranking-signal set can't mean taking shards offline. Build the new-format index alongside the old one from the same raw corpus (dual index-build pipelines, more build cost temporarily), validate relevance metrics against a shadow query stream, then cut the coordinator over shard-by-shard behind a flag. Never change the postings-list binary format and the ranking algorithm in the same rollout — isolate variables so a relevance regression is traceable to one change.
19. Interview follow-ups¶
- "Why document-based sharding and not term-based?" — Predictable, even load per query beats lower average latency with a chronic hot-shard problem. Say this trade-off explicitly; both are valid answers if justified.
- "How would you support phrase queries ('exact phrase' search)?" — Requires position information in postings (already in the data model), and intersecting position lists across terms to confirm adjacency — more expensive per query than a bag-of-words match, so it's often a distinct, more selective query path.
- "How is this different from the autocomplete exercise?" — Autocomplete ranks a bounded, pre-known set of past queries by frequency against a prefix; this ranks an open-ended corpus of documents against free-text content match plus relevance signals. Different data structure (trie/precomputed suggestion lists vs. inverted index), different ranking problem entirely.
- "What happens if two documents have near-identical content (mirrors, syndication)?" — Out of scope for this exercise's core, but flag it: near-duplicate detection belongs in the crawler/ingestion pipeline (see web-crawler.md) before documents ever reach the indexer, not as a query-time dedup step.
Self-Assessment¶
- [ ] I can explain why the index doesn't fit on one machine, with the postings-list size numbers
- [ ] I can justify document-based sharding over term-based sharding, including what it gives up
- [ ] I can describe why the delta-index-plus-batch-rebuild pattern resolves freshness vs. latency
- [ ] I can name a scatter-gather tail-latency mitigation that isn't "make every shard faster"
- [ ] I can explain why search results are eventually consistent but takedowns need a stricter bound