How an inverted index works: an interactive search engine
An inverted index is how search engines find documents containing words without reading every document. For each term it keeps a posting list, the documents that contain it. A query looks up the lists for its terms, intersects or merges them, and ranks the matches, typically by how often each term appears in a document and how rare the term is overall. Elasticsearch, Lucene, Solr and every web search engine are built on one.
The playground indexes sixteen restaurant reviews. Type a query, switch between requiring all terms and any term, and see the posting lists, the matches and their scores.
“cheap” is in 5 reviews, “pizza” is in 5 reviews, “delivery” is in 4 reviews. The index reads just those 14 postings instead of scanning all 176 words of the 16 reviews, and intersects the lists: 1 review has every term. “delivery”, the rarest term, counts most: its weight is log(16/4) = 1.39.
cheap1.16pizza1.16delivery1.39- #15Pizza and wings delivery, cheap and fast, open late every night3.71
How an inverted index is built
- Tokenize each document into terms: lowercase it, split it on punctuation and spaces, and often reduce words to a stem, so “deliveries” and “delivery” match.
- Record postings: for each term, the documents containing it, usually with the count and the positions, which phrase queries need.
- Sort and compress each posting list by document id; the gaps between ids are small numbers that compress well.
- Store a dictionary from terms to where their lists start, small enough to keep in memory.
Answering a query
For “cheap pizza delivery” the engine reads three posting lists, 5, 5 and 4 entries long, and intersects them: only one review contains all three. That is 14 postings read instead of all 176 words of all 16 reviews, and at web scale the gap is the difference between milliseconds and hours. Intersection is cheapest when it starts from the shortest list and skips through the longer ones, which is why rare terms make queries fast.
Ranking: TF-IDF and BM25
Matching is not enough; results must be ordered. TF-IDF scores a document by how often each query term appears in it (term frequency) times how rare the term is across all documents (inverse document frequency, log of N over the number of documents containing it). In “quiet coffee”, “coffee” appears in 2 of 16 reviews and “quiet” in 3, so the review with both comes first and coffee counts for more than quiet. BM25, the default in Lucene and Elasticsearch, refines this so that repeating a word has diminishing returns and long documents do not win just for being long. Production search adds many more signals: freshness, popularity, location, personalisation and learned ranking models.
Common words
A word like “and” is in 15 of the 16 reviews. Its posting list is the longest to read and it barely narrows anything, and its IDF is close to zero. Classic engines dropped such stopwords; modern ones keep them, since phrases like “to be or not to be” need them, but rely on IDF to discount them and on skipping to avoid reading their lists in full.
Indexing at scale
- Shard by document: each shard indexes a slice of the documents and every query goes to every shard, which return their top results to be merged. This is what Elasticsearch does; it keeps writes simple and spreads load evenly.
- Shard by term: each shard holds the full lists for some terms, so a query touches only the shards for its terms, but popular terms make hot shards and lists must be shipped to be intersected.
- Segments: new documents go into small immutable index segments that are merged in the background, much like an LSM tree, which is why new documents become searchable after a short refresh interval rather than instantly.
An inverted index in code
import math, re
from collections import defaultdict
def tokenize(text):
return re.findall(r"[a-z]+", text.lower())
def build(docs):
index = defaultdict(dict) # term -> {doc id: count}
for doc_id, text in enumerate(docs):
for term in tokenize(text):
index[term][doc_id] = index[term].get(doc_id, 0) + 1
return index
def search(index, n_docs, query, k=10):
scores = defaultdict(float)
for term in set(tokenize(query)):
postings = index.get(term, {})
if not postings:
continue
idf = math.log(n_docs / len(postings)) # rare terms count for more
for doc_id, tf in postings.items():
scores[doc_id] += tf * idf
return sorted(scores.items(), key=lambda kv: -kv[1])[:k]In a system design interview
For anything with a search box, say what is indexed and how it is kept up to date: “Listings are written to Postgres and streamed through change data capture into Elasticsearch, sharded by document, with about a second of indexing delay. Queries are matched with BM25 on title and description, filtered by location and price, and re-ranked by popularity.” Expect the follow-ups: typos and synonyms, how fresh results need to be, and how a query fans out to every shard.
Read how Elasticsearch works or the guide to search ranking and relevance, or watch a strong candidate design Yelp.
Questions people ask
What is an inverted index?
A data structure that maps each term to the list of documents containing it, called its posting list, often with how many times and where it appears. Search engines use it to find documents matching a query by reading only the posting lists of the query’s terms instead of scanning every document.
How does a search engine answer a query with an inverted index?
It tokenizes the query into terms, looks up each term’s posting list, intersects the lists for queries that require all terms or merges them for any term, scores each matching document, and returns the highest-scoring ones. Intersections start from the shortest list and skip through longer ones.
What is TF-IDF?
A relevance score: term frequency, how often a query term appears in a document, multiplied by inverse document frequency, the log of the total number of documents divided by the number containing the term. Terms that appear often in a document but rarely elsewhere contribute most, and very common words contribute almost nothing.
What is BM25?
The ranking function used by default in Lucene and Elasticsearch, a refinement of TF-IDF. It makes term frequency saturate so repeating a word has diminishing returns, and normalizes for document length so long documents are not favoured just for containing more words. Its parameters k1 and b control those two effects.
Why are stopwords a problem for search?
Words like “the” and “and” appear in nearly every document, so their posting lists are the longest to read while barely narrowing the results, and their IDF weight is near zero. Older engines removed them; modern ones keep them for phrase queries and rely on IDF and skipping to limit their cost.
How is a search index sharded?
Usually by document: each shard indexes a subset of documents, every query is sent to all shards, and their top results are merged. Sharding by term sends a query only to the shards holding its terms but creates hot shards for common terms and requires shipping posting lists to intersect them, so it is rarer.
Why are new documents not searchable immediately in Elasticsearch?
New documents are buffered and written into small immutable index segments, which become visible to search at each refresh, by default every second. Segments are merged in the background. This near-real-time design trades a short delay for much higher indexing throughput than updating one large index in place.