System designDeep dive12 min
Search & inverted indexes
A B-tree finds a row by its key, but it cannot find every listing that mentions "rust" somewhere in its text. Search engines flip the problem around: for every word, they keep the list of documents that contain it. That inverted index, plus the rules for turning text into words and ranking the matches, is what sits behind every search box.
1 · The problem
A search box cannot be answered by scanning rows
A job board holds 5 million open listings, each with a title and a few hundred words of description. A visitor types senior rust developer remote. The first attempt is the obvious query:
The first attempt
SELECT id, title FROM listings
WHERE description LIKE '%rust%'
AND description LIKE '%senior%'
AND description LIKE '%remote%';A B-tree index can't help here. It can serve a pattern anchored at the start, like 'rust%', but not one with a wildcard in front, because the B-tree is sorted by the beginning of the value (Database indexing). So the database reads all 5 million descriptions on every search. Even then the answer is wrong in ways a visitor notices immediately:
What the visitor expects | What LIKE does |
|---|---|
"rust" matches the word Rust | Also matches trust and crusty |
"developer" matches Developers | Misses the plural and the capital letter |
Typos still find something | "remtoe" matches nothing |
The best matches come first | Rows come back in no useful order |
Search needs three things a table doesn't provide: a structure that goes from words to documents, rules for deciding which strings count as the same word, and a way to rank the matches. The rest of this page covers each one, then how the index is kept fresh and spread over machines.
2 · The structure
An inverted index lists, for each word, the documents that contain it
A normal table goes from a document to its words. An inverted index goes the other way. It has two parts:
- the term dictionary: every distinct word (term) in the collection, sorted, with the number of documents that contain it (its document frequency);
- a postings list for each term: the IDs of the documents that contain it, in ascending order, usually with how often the term appears in each and at which positions.
Three tiny listings show the shape:
- listing 1: "Senior Rust developer, remote"
- listing 2: "Junior Python developer"
- listing 3: "Remote senior Python engineer"
Term | Document frequency | Postings (listing: positions) |
|---|---|---|
developer | 2 | 1: 3 · 2: 3 |
engineer | 1 | 3: 4 |
junior | 1 | 2: 1 |
python | 2 | 2: 2 · 3: 3 |
remote | 2 | 1: 4 · 3: 1 |
rust | 1 | 1: 2 |
senior | 2 | 1: 1 · 3: 2 |
Building it is a pipeline. Each listing is broken into terms, each term is appended to its postings list, and the dictionary is kept sorted so a term can be found quickly:
A search for one word becomes one dictionary lookup and one read of a list, whatever the size of the collection. The work moves from search time to indexing time.
Postings are stored sorted by document ID because that makes lists cheap to combine, as the next sections show. Positions cost space, but they are what lets the engine answer a phrase like "python engineer" by checking that the two words sit next to each other.
3 · Analysis
Analysis decides which strings count as the same word
Before text reaches the index, an analyzer turns it into terms. In Elasticsearch and Lucene, an analyzer is a fixed pipeline:
- Character filters
Clean the raw text first, for example by stripping HTML tags from a pasted job description.
- Tokenizer
Split the text into tokens, usually words: "Senior Rust Developers (Remote)" becomes Senior, Rust, Developers, Remote.
- Token filters
Change, remove or add tokens: lowercase everything, drop stop words such as "the", reduce words to a common root (stemming: developers → develop), and add synonyms (js → javascript).
The same analyzer must run on the query. "Developers" in a listing is stored as develop; if the visitor's "developer" were not stemmed the same way, it would look up a term that isn't in the dictionary and find nothing. Analysis is a contract between index time and search time.
Every choice in the pipeline trades precision for recall:
- Stemming finds "developers" for "developer", but can merge words that differ in meaning.
- Stop words shrink the index, but a search for the band "The The" becomes empty.
- Synonyms widen matches, and need a list someone maintains.
- Typos are handled at query time instead: a fuzzy query also accepts terms within an edit or two (a changed, missing, extra or swapped letter), so "remtoe" still finds "remote".
Changing the analyzer changes the stored terms, so the whole index must be rebuilt.
4 · Queries
A query combines sorted lists, starting with the shortest
A search for senior rust remote asks for listings that contain all three terms: the intersection of three postings lists. Because each list is sorted by document ID, two lists combine like a zipper. Walk a pointer down each list; when the IDs match, keep the listing and advance both, otherwise advance the pointer on the smaller ID. Two lists of lengths x and y take at most x + y steps.
The order matters. Suppose that across the 5 million listings the three terms appear in these numbers of listings, and that the terms are independent of each other:
Order | First intersection | Result after the first step |
|---|---|---|
senior ∩ remote, then rust | 900,000 and 1,200,000 listings | About 216,000 listings (900,000 × 1,200,000 ÷ 5,000,000) |
rust ∩ senior, then remote | 8,000 and 900,000 listings | About 1,440 listings (8,000 × 900,000 ÷ 5,000,000) |
Starting with the rarest term keeps every intermediate result no larger than its list, so engines read the document frequencies from the dictionary and order the terms before touching any postings. Long lists also carry skip pointers, shortcuts that jump over stretches of IDs that can't match, so combining 8,000 IDs with 900,000 costs far fewer than 908,000 steps.
Other query types build on the same lists:
- OR merges lists instead of intersecting them; skip pointers don't help, so a common word in an OR query is expensive.
- Phrases intersect the lists, then check that the positions are adjacent.
- Filters such as "location = Berlin" or "salary ≥ 80,000" are terms or ranges too, and are combined the same way before ranking.
5 · Ranking
Rare words count more and repeated words count less
Thousands of listings may match, and the visitor reads the first ten. Relevance ranking gives each match a score. Elasticsearch and Lucene use BM25 by default, which adds up a score for each query term from three ideas:
- Rare terms matter more (inverse document frequency).
- More occurrences help, with diminishing returns (term-frequency saturation).
- A match in a short field counts more than in a long one (length normalization).
Rarity is measured with the term's document frequency n in a collection of N documents: IDF = ln(1 + (N − n + 0.5) ÷ (n + 0.5)). With N = 5,000,000:
Term | Listings containing it | IDF |
|---|---|---|
rust | 8,000 | 6.44 |
senior | 900,000 | 1.71 |
remote | 1,200,000 | 1.43 |
the (if it were indexed) | 4,900,000 | 0.02 |
A listing that mentions Rust is worth almost four times as much as one that mentions "senior", which is what a visitor wants. Repetition is then capped. For a listing of average length, the term-frequency part is tf × (k1 + 1) ÷ (tf + k1), with the default k1 = 1.2:
- Raw count
- BM25, k1 = 1.2
tf × 2.2 ÷ (tf + 1.2): the weight can never reach 2.2. A listing that says "rust" ten times scores under twice one that says it once, so keyword stuffing barely pays.
Length normalization, controlled by b (default 0.75), lowers the weight in longer listings: three mentions of "rust" weigh 1.57 in a listing of average length but 1.29 in one twice as long. Real search adds business signals on top, such as freshness, location or paid placement, but text relevance is usually the base.
6 · Freshness
The search index is a copy that lags the database
A recruiter posts a listing. It is saved in the job board's database, the source of truth, and must then reach the search engine. The search index is derived data: it can always be rebuilt from the database, and it is always slightly behind it.
Inside the engine, the index is written like an LSM tree (Storage engines: B-trees, LSM trees & WAL). New documents are buffered and written out as small immutable segments, each a complete inverted index of its own. A search reads every segment and merges the results. A deleted or edited listing is only marked as deleted in its old segment; background merging combines segments and finally drops those entries.
The listing is searchable only after its change event reaches the indexer and the next refresh runs. Each step adds delay, and a backlog in the indexer adds more.
Elasticsearch makes new documents searchable with a refresh, every second by default, but only for indexes searched in the last 30 seconds. That is why it is called near real-time. A recruiter who posts a listing and searches for it immediately may not see it, so show them their own listing straight from the database.
Never update the database and the search engine as two separate writes from the app. If the second write fails, the two disagree forever. Publish the change from the database instead, with an outbox table or change data capture (CDC), which reads the database's own change log (Change data capture), make indexing idempotent so a replayed event is harmless, and keep a way to rebuild the whole index from scratch for when the analyzer changes.
Where the index lives | Good for | Limits |
|---|---|---|
The database's own full-text index (PostgreSQL GIN) | Modest data, no extra system, always consistent with the rows | Fewer analyzers, less ranking control, competes with the main workload |
A separate search engine (Elasticsearch, OpenSearch, Solr) | Large collections, typo tolerance, tuned ranking, facets | Another cluster to run, and results lag the database |
7 · At scale
Shard by document and ask every shard
One machine can't hold or serve the whole index forever, so it is split into shards. There are two ways to split an inverted index:
- By document (a local index): each shard holds every term, but only for its own share of the listings.
- By term (a global index): each shard holds whole postings lists, but only for its share of the terms.
Splitting by term sounds efficient, since a query touches only the shards that own its terms. But a multi-word query must then ship long postings lists between machines to intersect them, and popular terms overload their shards. Splitting by document is what search engines do in practice:
Scatter and gather: every shard searches its own listings and returns its best 10 IDs and scores; the coordinator merges them into the global top 10 and fetches only those 10 documents.
Consequence | Why | What helps |
|---|---|---|
Every query touches every shard | Any shard may hold a match, so the slowest one sets the response time (Latency) | Replicas of each shard to spread the load; fewer, larger shards |
Scores use each shard's own statistics | A shard knows how common "rust" is among its listings, not across all 5 million | Large, evenly spread shards; or collecting global statistics first, at the cost of a round trip |
Deep pages are expensive | Page 100 of 10 results needs the top 1,000 from every shard | A cap (10,000 results by default in Elasticsearch) and cursor-style paging with |
The number of primary shards in Elasticsearch is fixed when an index is created. Growing past it means splitting the index into a multiple of its shard count with the _split API, which needs the index read-only while it runs, or creating a new index with more shards and reindexing into it. Either way, keep the index rebuildable from the database.
Check yourself
5 questions
Takeaways
Remember this
- An inverted index maps each term to a sorted list of the documents that contain it, so a word search is a lookup instead of a scan.
- The same analyzer must run at index time and query time; changing it means rebuilding the index.
- AND queries intersect sorted postings lists, rarest term first; BM25 ranks rare terms higher and caps the value of repetition.
- A search index is derived data that lags the database: feed it from the database's changes, never from a second write in the app.
- Search clusters shard by document, so every query asks every shard and the slowest shard sets the latency.