System Design
Intermediate 13 min read Data at scale

Search: inverted indexes and Elasticsearch

In one line

Search engines answer 'which documents contain these words' by keeping an inverted index from each term to the documents that contain it, ranking matches with BM25, and scaling with shards and replicas; the index is a derived copy fed from your source-of-truth database.

What it is

A normal database index maps a row to its values. An inverted index flips this: it maps each term (word) to a postings list, the sorted list of document IDs that contain the term, often with positions and counts. To find 'red cotton kurta', the engine looks up three postings lists and intersects them, which is fast even over hundreds of millions of documents.

Before indexing, text goes through an analyzer: a tokenizer splits it into terms, and filters lowercase them, remove stop words, apply stemming ('running' -> 'run'), handle synonyms ('tv' = 'television') and, for Indian products, transliteration ('kurta', 'kurtha', the Devanagari word). The same analyzer runs on the query, so query terms match indexed terms.

Results are ranked by relevance. The default in Lucene, Elasticsearch and OpenSearch is BM25: a term scores higher if it appears often in the document (with diminishing returns), rarely across all documents (rare words matter more), and in a shorter field. Real products add business signals such as popularity, rating, stock, distance and personalisation on top.

Elasticsearch and OpenSearch wrap Lucene into a distributed system: an index is split into primary shards (each a Lucene index), each with replicas on other nodes. Lucene writes immutable segments and merges them in the background; new documents become searchable after a refresh, every 1 second by default, so search is near-real-time, not instantly consistent.

Why interviewers ask

Search appears in e-commerce, food delivery, job portals, chat history and log analysis designs. Interviewers check that you do not run LIKE '%term%' on the primary database, that you know the search index is a derived store kept in sync by CDC or events, and that you can talk about relevance, typo tolerance, sharding and the lag between a write and when it is searchable.

How it works

  1. 1Model the document: denormalise what search needs into one flat document per item (product name, brand, category path, price, rating, in_stock, seller city). Search engines do not do joins well.
  2. 2Define mappings: 'text' fields are analysed for full-text search; 'keyword' fields are stored exactly for filters, sorting and aggregations (brand, category, size).
  3. 3Index: each document is analysed into terms, added to an in-memory buffer and the translog (for durability), then written as a new immutable segment on refresh (default 1 s). Background merges combine small segments into larger ones.
  4. 4Query: the coordinating node sends the query to one copy of every shard (query phase); each shard returns its top-k IDs and scores; the coordinator merges them and fetches the full documents for the final page (fetch phase).
  5. 5Filter before scoring: structured conditions (in_stock = true, price < 1000, city = Pune) are filters, which are cached as bitsets and do not affect the score; free text goes in the scored 'match' part.
  6. 6Rank in stages: BM25 retrieves the top few hundred candidates cheaply, then a function score or a learning-to-rank model re-ranks them using popularity, conversion rate and personalisation.
  7. 7Handle typos and prefixes: fuzzy matching (edit distance 1 or 2), edge n-grams or the completion suggester for autocomplete, and synonym lists curated from search logs.
  8. 8Keep it in sync: the source of truth (Postgres, MySQL) publishes changes via an outbox or CDC (Debezium) to Kafka; an indexer consumes and upserts documents by ID, so replays are idempotent. Rebuild a full index by reindexing into a new index and switching an alias.

Worked example · Product search for a fashion marketplace

  1. Catalogue: 50M products, average document 2 KB, so about 100 GB of source; with index structures and 1 replica, plan for about 300 to 400 GB on disk.
  2. Sharding: aim for 10 to 50 GB per shard, so 10 primary shards of about 15 to 20 GB each, plus 1 replica each = 20 shards over 6 data nodes.
  3. Query 'red kurta under 800': analyzer produces terms 'red' and 'kurta'; the filter price <= 800 and in_stock = true is applied as a cached bitset.
  4. Each of 10 shards scores matches with BM25 and returns its top 100; the coordinator merges 1,000 candidates and keeps the top 100.
  5. A re-ranker boosts items with high click-through and fast delivery to the user's pincode; the top 20 are fetched and returned. Typical p99: 50 to 150 ms.
  6. A seller changes the price at t = 0. CDC delivers the change to Kafka in about 1 s, the indexer writes it, and it becomes searchable after the next refresh: about 2 to 3 s end to end. The product page itself reads the price from the database, so checkout never uses a stale search price.
  7. Peak: a festive sale brings 20,000 searches per second. Add replicas (reads scale with replica count) and cache the top 1,000 queries for 30 s; do not add primary shards, which would need a reindex.

Picture it

An inverted index is the index at the back of a textbook. Instead of reading every page to find 'photosynthesis', you look it up and get page numbers 45, 112 and 230. Search for two words, and you take the pages that appear in both lists.

Reference code

// Elasticsearch query: filters are cached and unscored, text is scored with BM25
POST /products/_search
{
  "query": {
    "bool": {
      "must":   [{ "multi_match": { "query": "red kurta",
                    "fields": ["title^3", "brand", "description"],
                    "fuzziness": "AUTO" } }],
      "filter": [{ "term":  { "in_stock": true } },
                 { "range": { "price": { "lte": 800 } } }]
    }
  },
  "size": 20
}

Key patterns

Search index as a derived store

When: Any search on top of transactional data

How: Source of truth in the database; changes flow via outbox or CDC to an indexer; the index can be rebuilt from scratch at any time.

Reindex with alias swap

When: Changing mappings, analyzers or shard count

How: Build products_v2 in the background while dual-writing new changes, then atomically point the products alias from v1 to v2.

Retrieve then re-rank

When: Relevance needs business or ML signals

How: Cheap BM25 (or hybrid BM25 + vector) retrieval for top few hundred, then an expensive model re-ranks only those.

Time-based indices for logs

When: Append-only data with retention, such as logs and events

How: One index per day or use data streams with lifecycle policies; drop whole old indices instead of deleting documents.

Real-world use

Elasticsearch and OpenSearch

Both are built on Apache Lucene. OpenSearch is the AWS-led fork created in 2021 after Elastic changed its licence. They power product search, log analytics (the ELK stack) and, with vector fields, hybrid semantic search.

Indian e-commerce search (Flipkart, Myntra)

Indian e-commerce search must handle spelling variants, Hinglish and transliterated queries ('kurti', 'chudidar', 'jhumka') and vernacular voice queries. Flipkart's engineering blog has described spell correction and vernacular query understanding that rewrite the query before it reaches the retrieval engine.

LinkedIn Galene

LinkedIn built its own Lucene-based search stack (Galene) for people and job search, with offline-built indexes and live updates, and multi-stage ranking with machine-learned models.

Postgres full-text search

tsvector columns with a GIN index and ts_rank give decent keyword search inside the database for small to medium catalogues, avoiding a second system until scale or relevance needs justify one.

Algolia and Typesense

Hosted or lightweight search engines optimised for instant search-as-you-type with built-in typo tolerance, popular for smaller catalogues where running an Elasticsearch cluster is not worth it.

In a project

A job portal serving Tier-2 cities runs search with SQL LIKE queries on MySQL. With 8M job posts, searches take 3 to 8 seconds, the primary database CPU hits 95% every morning, and 'data entry' does not match 'data-entry operator'.

  1. Move search to OpenSearch: one flat document per job with title, skills, company, city, salary range, posted_at; title and skills as text, city and job type as keyword.
  2. Use an analyzer with lowercase, word-delimiter (so 'data-entry' becomes 'data' and 'entry'), English stemming, and a synonym file built from search logs ('BPO' = 'call centre', 'fresher' = '0 years').
  3. Size it: 8M jobs x 3 KB = 24 GB, so 2 primary shards with 2 replicas each on 3 nodes; replicas also absorb the morning peak.
  4. Sync with Debezium CDC from MySQL to Kafka to an indexer, upserting by job_id; expired jobs are deleted from the index by the same pipeline.
  5. Rank with BM25 plus boosts for recency (decay function over posted_at), distance from the user's city and employer response rate.
  6. Keep MySQL as the source of truth: the job detail page and applications always read from MySQL.

Impact: p99 search latency falls from 8 s to 120 ms, MySQL CPU drops to 40% because search traffic is gone, and click-through on the first page rises because synonyms and stemming match how users actually type.

Trade-offs

Use it when

  • Full-text search with relevance ranking, typo tolerance, synonyms and facets
  • Large catalogues or document sets where SQL LIKE or full scans are too slow
  • Log and event search with time-based retention (ELK or OpenSearch)
  • Hybrid keyword + vector retrieval for RAG and semantic search

Avoid it when

  • As the source of truth: indexes can lag, lose acknowledged writes in rare failure cases and are rebuilt, not restored
  • Strongly consistent reads right after a write, such as showing a just-placed order
  • Small datasets where Postgres full-text search or a simple index is enough
  • Heavy joins or transactional updates; denormalise or keep that logic in the database

Common mistakes

  • Using SQL LIKE '%term%' at scale; a leading wildcard cannot use a B-tree index and scans the whole table.
  • Dual-writing to the database and Elasticsearch from the application, so the two silently diverge when one write fails.
  • Choosing too many small shards (thousands of 1 GB shards) or too few huge ones; each shard has fixed overhead and the primary shard count is hard to change.
  • Forgetting the refresh interval and promising that a new item is searchable instantly.
  • Putting structured conditions in the scored query instead of filters, which wastes CPU and loses filter caching.
  • Ignoring relevance: returning BM25 results without popularity, stock or location signals, so users see out-of-stock or far-away items first.

Interview questions

Quick revision

  • Inverted index: term -> sorted postings list of doc IDs (+ positions).
  • Analyzer: tokenize, lowercase, stop words, stemming, synonyms; same on query and index.
  • BM25: term frequency (saturating) x rarity (IDF), normalised by field length.
  • text fields for full-text; keyword fields for filter, sort, facets.
  • Shard size 10-50 GB; replicas scale reads and give failover.
  • Near-real-time: refresh every 1 s by default; segments are immutable and merged.
  • Query then fetch: each shard returns top-k, coordinator merges.
  • Index = derived store; sync via outbox/CDC; rebuild via reindex + alias swap.
  • Retrieve cheaply, re-rank expensively.

Check yourself

1. What does an inverted index map?

2. In BM25, which term contributes most to a document's score?

3. A product was added 200 ms ago and does not appear in search. What is the most likely reason?

4. What is the safest way to change an index's analyzer in production?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free