Design a Search Engine
A web-scale search engine: a polite distributed crawler feeding an inverted-index build pipeline, ranking that blends TF-IDF/BM25 text relevance with PageRank authority, and a sharded, replicated query-serving tier that answers keyword queries over billions of documents in under 200 ms.
1Requirements
Functional
- • Crawl the public web starting from seed URLs, respecting robots.txt and per-domain politeness limits
- • Parse, deduplicate, and index page text into an inverted index keyed by term
- • Serve keyword queries with AND semantics, returning the top 10 ranked results with title and snippet
- • Rank results by combining text relevance (BM25/TF-IDF) with link-based authority (PageRank)
- • Re-crawl pages on a freshness schedule so popular pages update within days
Non-functional
- • Query latency: p99 under 200 ms end to end at 50k QPS
- • Index scale: 10 billion documents, tens of terabytes of posting lists
- • Crawl politeness: never overwhelm an origin site; hard cap of ~1 request/second/domain
- • Freshness: news-class pages re-indexed within hours, long tail within weeks
- • Availability over consistency: serving a slightly stale index is always preferable to failing queries
2Back-of-envelope estimation
| Corpus | 10 billion pages, ~100 KB HTML each | Raw crawl store ~1 PB; extracted text ~10 KB/page = 100 TB |
| Crawl throughput needed | ~12,000 pages/s | Refresh 10B pages over 10 days: 10B / (10 x 86,400 s). At 100 KB/page that is ~1.2 GB/s ingress |
| Inverted index size | ~50 TB compressed | ~500 terms/page x 10B pages = 5 x 10^12 postings; ~10 bytes each raw, roughly halved by varint/delta compression |
| Index shards | ~250 shards | 50 TB / ~200 GB per shard so each shard's hot postings fit in RAM+NVMe on one box; 3x replicas = 750 serving nodes |
| Query fan-out cost | 50k QPS x 250 shards = 12.5M shard-queries/s | Every query hits every document-partitioned shard; each replica group absorbs ~17k shard-queries/s across 3 replicas |
| PageRank compute | ~50 iterations over a 100B-edge graph | 800 GB of edges (8 B/edge); a few hours per run on a modest Spark cluster, run weekly |
3API design
GET /v1/search?q={query}&page={p}Main search. Tokenizes and normalizes the query, fans out to index shards, merges top-k, hydrates titles and snippets from the document store.
GET /v1/suggest?prefix={text}Typeahead completions from a separate trie/FST built from query logs; entirely decoupled from the main index.
POST /internal/crawl/seedsOperator API to inject seed URLs or force recrawl of a domain.
GET /internal/index/epochsLists index generations (epochs) and their shard manifests; the serving tier uses this to atomically swap to a new index version.
4High-level design
Three loosely coupled subsystems connected by storage, not RPC: the crawler writes raw pages to a document store; the indexing pipeline reads the store and emits immutable index shards; the serving tier loads shards and answers queries. Each subsystem scales and fails independently, and the batch boundary between them (index epochs) is what makes the whole thing operable: serving never depends on the crawler being healthy.
The crawler is a frontier-driven loop: a URL frontier (priority queues partitioned by domain) feeds fetchers, fetched HTML is parsed for text and outlinks, new URLs flow back to the frontier. Politeness is enforced structurally: all URLs for one domain map to one frontier queue with a per-queue rate limit, so no coordination is needed to avoid hammering a site. Content-seen deduplication (SimHash for near-duplicates, exact hash for identical bodies) prunes the 30-plus percent of the web that is duplicated before it wastes index space.
The indexing pipeline is a classic MapReduce shape even if you run it on Spark: map each document to (term, docId, positions, tf) tuples, shuffle by term, reduce into per-term posting lists sorted by docId and delta-compressed. Alongside, a link-graph job extracts (src, dst) edges and iterates PageRank to convergence. The output is a new immutable index epoch: document-partitioned shards, each containing its term dictionary, posting lists, and per-doc static scores (PageRank, spam score, length norms).
Serving is scatter-gather over document-partitioned shards. A query hits a coordinator, which normalizes terms, broadcasts to one replica of each shard, and each shard intersects posting lists, scores its local candidates with BM25 plus static signals, and returns its top 50. The coordinator merges, takes the global top 10, and hydrates snippets. Document partitioning (vs term partitioning) is the industry default because intersection happens locally on one node, network cost per query is bounded, and a dead shard degrades results by 1/250th instead of breaking specific terms.
Freshness is layered rather than solved once: the big batch index rebuilds weekly, a small delta index rebuilds hourly from recently crawled pages, and serving queries both and merges (with the delta winning on doc collisions). This mirrors the Lucene segment model and avoids the false choice between real-time indexing complexity and week-stale results.
5Data model
url_frontier
domain, url, priority, earliest_fetch_at, discovered_at, PRIMARY KEY (domain, url)Partitioned by domain hash; per-domain FIFO with a token-bucket rate gate
documents
doc_id (PK), url, content_hash, simhash, fetched_at, http_status, title, extracted_text_ref, outlinks_refThe crawl store; blob refs point to object storage, metadata stays in a wide-column store
posting_list (index shard file)
term, doc_count, postings: [delta_doc_id (varint), tf, position_offsets]Immutable, memory-mapped; term dictionary is an FST mapping term to file offset
doc_scores
doc_id, pagerank, doc_length, spam_score, epochStatic per-doc signals co-located with each shard for scoring without network hops
6Deep dives
Crawler: frontier design, politeness, and traps
The frontier is the crawler's real data structure problem. It must balance priority (crawl important/fresh URLs first) against politeness (per-domain rate caps), and those goals fight: strict priority order would fetch 10,000 consecutive CNN URLs, which politeness forbids. The Mercator design solves it with two stages: front queues partitioned by priority, and back queues strictly partitioned by domain, each with a next-allowed-fetch timestamp. Fetcher threads pull from whichever back queue is ready, so the crawler naturally interleaves thousands of domains while each domain sees at most one request per politeness interval.
Deduplication has two layers. URL-seen filtering (a Bloom filter or sharded hash set over ~100B URLs) stops re-enqueueing; at 10 bits per key a Bloom filter for 100B URLs is ~125 GB, shardable across frontier nodes, with false positives merely skipping a URL. Content dedup catches mirrors and www/non-www twins: SimHash produces a 64-bit fingerprint where near-duplicate pages differ in few bits, and Hamming-distance lookup tables find near-dupes at crawl rate.
The adversarial web is the part interviewers love: spider traps (calendar pages generating infinite next-month links), URL parameter explosions, and 200-OK error pages. Defenses are budget-based, not clever: max crawl depth, per-domain page budgets proportional to domain PageRank, URL canonicalization (strip session params, sort query strings), and content-hash checks that stop crawling a domain returning identical bodies.
Building the inverted index and computing PageRank
The index build is deliberately batch. Attempting in-place index mutation at 12k docs/s creates unsolvable compaction and consistency problems; instead, every epoch is built from scratch (or from the previous epoch plus a delta) as immutable files. Map phase: parse, tokenize, stem, emit (term, docId, tf, positions). Shuffle by term. Reduce: sort postings by docId, delta-encode docIds (gaps compress far better than absolute ids), varint or PForDelta encode, and write the term dictionary as an FST. Sorting by docId is what later makes multi-term intersection a linear merge of sorted lists.
PageRank models a random surfer: rank(p) = (1-d)/N + d * sum over inlinks q of rank(q)/outdegree(q), with damping d = 0.85. Implementation is iterative sparse matrix-vector multiplication over the link graph; 40-50 iterations converge for web graphs. The two practical wrinkles are dangling nodes (pages with no outlinks leak rank; redistribute their mass uniformly each iteration) and spam farms (link circles inflating each other; mitigated with trust-seeded variants like TrustRank and by discounting intra-domain links).
TF-IDF's modern form is BM25: score(q,d) = sum over terms t of IDF(t) x tf(t,d) x (k1+1) / (tf(t,d) + k1 x (1 - b + b x |d|/avgdl)), with k1 around 1.2 and b around 0.75. The saturation term is why BM25 beats raw TF-IDF: the 50th occurrence of a term adds almost nothing, so keyword-stuffed pages stop winning. Final ranking is typically score = w_text x BM25 + w_auth x log(PageRank) + freshness and quality terms, with weights tuned on click data.
Query serving: scatter-gather, top-k, and latency discipline
A multi-term query on one shard is posting-list intersection: walk the sorted lists in tandem, or better, iterate the rarest list and probe the others with skip pointers (galloping search), which makes intersection cost proportional to the rarest term's list length. Per-document scoring happens during intersection using local doc_scores, and a bounded min-heap keeps the shard's top 50. WAND-family optimizations prune documents whose maximum possible score cannot reach the current heap floor, often skipping 90 percent of scoring work on long lists.
Tail latency is governed by the slowest of 250 shards, so p99 discipline is structural: hedged requests (send to a second replica if the first has not answered in p95 time), per-shard deadlines with partial-result merging (answering from 248 of 250 shards is invisible to users), and replica load balancing aware of GC pauses. This is the canonical tail-at-scale problem and saying so, with hedging as the fix, scores points.
Caching stacks multiply: a result cache for full queries (web queries are extremely head-heavy; 30-plus percent hit rates are normal), a posting-list block cache inside each shard, and OS page cache under the memory-mapped index files. Because index epochs are immutable, every cache layer gets trivially correct invalidation: caches are keyed by epoch and simply cut over when serving swaps epochs.
Sharding the index: document vs term partitioning
Document partitioning assigns each document to one shard, which holds a full mini-index over its documents. Every query fans out to all shards; each shard does local intersection and returns its top-k. Term partitioning assigns each term's full posting list to one shard, so a query touches only its terms' shards. Term partitioning sounds cheaper but loses badly in practice: multi-term intersection now ships giant posting lists across the network, load skews brutally on hot terms, and one shard failure blacks out specific words. Document partitioning keeps intersections local, spreads load uniformly, and degrades gracefully. All major engines (Google, Elasticsearch, Vespa) partition by document.
Within document partitioning, assignment can be random (uniform load, the default) or quality-tiered: put the highest-PageRank documents in a small tier-1 that is searched first, and only fan out to lower tiers if tier-1 yields too few good results. Tiering cuts average query cost several-fold at the price of occasionally missing a long-tail result on the first pass.
Epoch swap is the deployment story: the pipeline publishes a manifest (epoch N: 250 shard files plus checksums), serving nodes for each shard download their new file, warm it (touch pages, prime caches), report ready, and the coordinator flips the epoch pointer atomically. Rollback is flipping the pointer back, which is the operational payoff of immutability.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Python (httpx + selectolax for crawling, pure-dict inverted index), SQLite for the doc store, FastAPI for serving; runs on a laptop
- 01Write a polite async crawler: httpx with a per-domain asyncio semaphore and a 1 req/s per-domain sleep, seeded with 20 URLs from a niche you like
- 02Respect robots.txt via urllib.robotparser and store fetched pages (url, html, text, outlinks) in SQLite
- 03Extract text and links with selectolax; canonicalize URLs (strip fragments and utm params) and dedupe with a seen-set before enqueueing
- 04Crawl ~5,000 pages, then build the inverted index: tokenize, lowercase, stem with nltk, emit term -> sorted list of (doc_id, tf) into a pickle or SQLite table
- 05Compute PageRank over the crawled link graph with 30 power iterations (a 50-line function, no library needed)
- 06Implement BM25 scoring over posting-list intersection for multi-term queries and blend with 0.2 x log PageRank
- 07Serve GET /search?q= with FastAPI: tokenize the query, intersect postings, score, return top 10 with title and a naive snippet (text window around the first match)
- 08Verify: search a term you know appears on exactly 3 crawled pages and confirm those 3 rank above pages that merely link to them
Inverted index build with BM25 stats
pythonimport math, re
from collections import defaultdict
TOKEN = re.compile(r"[a-z0-9]+")
def build_index(docs): # docs: dict[doc_id] = text
index = defaultdict(list) # term -> [(doc_id, tf)] sorted by doc_id
doc_len = {}
for doc_id in sorted(docs):
terms = TOKEN.findall(docs[doc_id].lower())
doc_len[doc_id] = len(terms)
tf = defaultdict(int)
for t in terms:
tf[t] += 1
for t, f in tf.items():
index[t].append((doc_id, f))
avgdl = sum(doc_len.values()) / max(1, len(doc_len))
return index, doc_len, avgdl
def bm25(index, doc_len, avgdl, n_docs, query, k1=1.2, b=0.75):
scores = defaultdict(float)
for term in TOKEN.findall(query.lower()):
postings = index.get(term, [])
if not postings:
continue
idf = math.log(1 + (n_docs - len(postings) + 0.5) / (len(postings) + 0.5))
for doc_id, tf in postings:
norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * doc_len[doc_id] / avgdl))
scores[doc_id] += idf * norm
return sorted(scores.items(), key=lambda x: -x[1])[:10]PageRank power iteration
pythondef pagerank(links, d=0.85, iters=30):
# links: dict[url] = list of outlink urls (within the crawled set)
pages = set(links) | {v for outs in links.values() for v in outs}
n = len(pages)
rank = {p: 1.0 / n for p in pages}
inlinks = {p: [] for p in pages}
for src, outs in links.items():
for dst in outs:
inlinks[dst].append(src)
for _ in range(iters):
dangling = sum(rank[p] for p in pages if not links.get(p))
new = {}
for p in pages:
incoming = sum(rank[q] / len(links[q]) for q in inlinks[p] if links.get(q))
new[p] = (1 - d) / n + d * (incoming + dangling / n)
rank = new
return rankPolite async crawler core
pythonimport asyncio, time
from urllib.parse import urlparse
import httpx
class PoliteCrawler:
def __init__(self, delay_per_domain=1.0, max_pages=5000):
self.next_ok = {} # domain -> earliest next fetch time
self.seen = set()
self.frontier = asyncio.Queue()
self.delay = delay_per_domain
self.budget = max_pages
async def fetch(self, client, url):
domain = urlparse(url).netloc
wait = self.next_ok.get(domain, 0) - time.monotonic()
if wait > 0:
await asyncio.sleep(wait)
self.next_ok[domain] = time.monotonic() + self.delay
r = await client.get(url, timeout=10, follow_redirects=True)
return r.text if r.status_code == 200 else None
async def worker(self, client, on_page):
while self.budget > 0:
url = await self.frontier.get()
if url in self.seen:
continue
self.seen.add(url)
html = await self.fetch(client, url)
if html:
self.budget -= 1
for link in on_page(url, html): # parse, store, return outlinks
if link not in self.seen:
self.frontier.put_nowait(link)Bottlenecks & failure modes
- ⚠Crawl politeness caps throughput per domain, so a few enormous sites dominate crawl calendars; prioritize by PageRank-weighted budgets rather than URL counts
- ⚠Shuffle stage of the index build moves tens of TB; delta builds (index only changed docs) and per-shard local sorting keep rebuild hours, not days
- ⚠Scatter-gather tail latency: one slow shard sets query p99; hedged requests and partial-result deadlines are mandatory, not optional
- ⚠Hot head queries and hot terms (posting lists for 'the' are useless but huge); stopword handling, result caching, and WAND pruning
- ⚠Index epoch swaps can double memory temporarily on serving nodes; stagger shard cutover and size headroom for old+new residency
Key takeaways
- ▸Decouple crawl, index build, and serving through immutable storage; batch epochs make a petabyte system operable
- ▸Politeness is a data-structure property: one domain, one queue, one rate limiter, zero coordination
- ▸Document partitioning beats term partitioning because intersection stays local and failure degrades uniformly
- ▸BM25 plus PageRank is the canonical ranking answer: per-query text relevance times query-independent authority
- ▸Immutable index epochs give free cache correctness, atomic deploys, and one-command rollback