Tier 3 · Patterns & Assembly

Search & Inverted Indexes

Why the index is inverted, why BM25 replaced TF-IDF, and why page 1000 is expensive

⏱ 18 min patternssearchindexing

The index, inverted

A B-tree maps a value to its rows, sorted by the whole value: title = 'System Design' is fast, body LIKE '%quorum%' is a scan. It seeks to a prefix, never to a word inside a 4 KB text field — ten million documents, ten million rows read.

An inverted index reverses the natural direction, document → its terms:

quorum → [17, 402, 9931, 20114, ...]   (12,000 doc ids)
merkle → [402, 9931]

Each list is a posting list: sorted document ids with term frequencies and, for phrase queries, the positions of each occurrence — "read repair" keeps only documents where a repair position is one past a read. Positions cost 2–3× the index size.

quorum AND merkle is a merge of two sorted lists with skip pointers: work proportional to the shorter list, not the corpus. The index is keyed by what the user typed, so the query is a lookup, not a scan.

The analysis pipeline

Text becomes terms through an ordered pipeline; every stage is a lossy decision:

Stage Does Costs you
Tokenisation Splits on word boundaries Breaks C++, ex-wife, CJK text
Lowercase / fold Zürichzurich Loses US vs us
Stop words Drops the, of, a Kills The Who, to be or not to be
Stemming universitiesunivers (Porter) Over-stems: universe meets university
Lemmatisation bettergood, dictionary + grammar Slower, a model per language
Synonyms laptopnotebook Index-time needs a reindex; query-time widens every query

Stop-word removal is a megabyte-era legacy; BM25 discounts common terms anyway, so keep them.

Ranking: TF-IDF, then BM25

TF-IDF scores tf × log(N/df): reward terms frequent here, discount terms common everywhere. Two errors. Term frequency is linear — a page mentioning coffee 50 times is not 50× more relevant than one mentioning it once. Long documents win by accident: more words, more of everything.

BM25 fixes both. Term frequency contributes tf × (k1+1) / (tf + k1), which saturates: at k1 = 1.2 the first occurrence buys ~45% of all a term can contribute, the tenth ~89%, the fiftieth ~98%. Length is normalised against the corpus average with b = 0.75, so a 5,000-word page works harder than a 300-word one.

Relevance is a product decision, not a formula. BM25 only knows words. Real ranking layers business signals on top: recency, popularity, personalisation, title boosts — none of it tunable by eye. Mine click logs, measure NDCG@10, A/B it.

Segments are an LSM-tree

Lucene buffers new documents in memory, then flushes them to an immutable segment: a miniature index with its own dictionary and posting lists. Segments are never modified — deletes flip a bit in a tombstone bitmap, updates are delete plus reinsert, and background merges combine small segments into large ones, dropping deletions.

That is storage-engines's LSM-tree exactly: immutable runs, tombstone deletes, background compaction, reads that consult every run.

Merges are IO amplification: rewriting a 5 GB segment reads and writes 5 GB while queries want the same disk. And there is no read-your-writes — a screen that saves then searches must read the primary store.

Shards, replicas, and scatter-gather

An index is split into shards (partitioning), each a complete Lucene index over its slice of documents, plus replicas. This is the local secondary index made concrete: partitioning is by document, so no shard knows the global answer.

Every query is a scatter-gather: the coordinator asks one copy of every shard, each returns its top size ids and scores, and the merged global top 10 is fetched.

p99 is the slowest shard, not the average one. If a shard is fast 99% of the time, a 10-shard query is fast 0.99¹⁰ ≈ 90% of the time: fan-out amplifies the tail tenfold. Shards are extra chances to be slow, not free parallelism — hence over-sharding, the most common Elasticsearch misconfiguration. Aim for 10–50 GB per shard.

Deep pagination

Ask for results 10,000–10,010, ranked globally, across 10 shards. No shard knows which of its documents fall in that window without seeing everyone else's scores, so every shard must return its own top 10,010. The coordinator sorts 100,100 hits and discards 100,090.

Cost is (from + size) × shards, growing with both offset and shard count. Elasticsearch caps it at index.max_result_window: 10000, and the cap is a feature. Use a cursor instead: search_after passes the sort values of the last hit, so each shard returns only size rows beyond it — constant cost per page.

Operating a derived index

Search is never the source of truth. It is a derived view — the polyglot-persistence case: a second store bought for one access pattern, paid for with a sync pipeline.

Postgres or a dedicated engine?

Need Postgres full-text Elasticsearch / OpenSearch / Solr
A few million docs, tens of QPS Fine Overkill
Consistent with your data Yes, same commit No, always lagging
Ranking quality ts_rank, no saturation BM25 default, tunable
Typos, fuzzy match pg_trgm goes far Native fuzzy, n-gram, phonetic
Facets over 100M docs Painful Core feature
Scale-out across machines Not really Built in
Operational cost Zero new systems A cluster, a pipeline, an on-call rota

Postgres full-text goes further than most teams assume: tsvector with a GIN index, websearch_to_tsquery, unaccent, weighted fields, pg_trgm for typos, ParadeDB for real BM25. Move to a dedicated engine for relevance tuning, faceting at scale, or a corpus that outgrows one machine.

What to take away

Check yourself

  1. A term is definitely present in the corpus, but querying for it returns zero hits and no error. What is the most likely cause?

  2. Why is BM25 the default rather than TF-IDF?

  3. An index has 10 shards and a client asks for results 10,000 to 10,010 ranked globally. How much work reaches the coordinator?

  4. Your service must write a record and make it searchable. Which approach is correct?