Courses › System Design › Core Concepts

Search Systems

Inverted indexes, analyzers, BM25 and hybrid ranking, sharded clusters, and syncing with CDC.

Search boxes are everywhere: products, posts, messages, places, people. A relational database can't do this well at scale — WHERE body LIKE '%term%' scans every row and can't rank results — so most designs add a dedicated search system such as Elasticsearch or OpenSearch, fed from the primary database. This page covers what that system is and how it fits in; Building Full-Text Search goes deeper.

The inverted index

The core data structure. Instead of mapping a document to its words, it maps each term to the documents that contain it:

"distributed" -> [doc 3, doc 8, doc 21]
"systems"     -> [doc 3, doc 21, doc 40]

A query for "distributed systems" intersects the two lists: doc 3 and doc 21. Lookups take milliseconds even across billions of documents.

Analysis: turning text into terms

Before indexing, text goes through an analyzer, and queries go through the same one so they match:

  • Tokenize: split into words.
  • Normalize: lowercase, strip accents.
  • Remove stop words such as "the" and "a" (optional).
  • Stem or lemmatize: "running", "runs" → "run".

N-gram or edge n-gram tokens support partial matches and prefix search, at the cost of a bigger index. For instant autocomplete, a dedicated structure is usually better — see Design Typeahead.

Ranking

Matching is half the job; ordering the results is the other half.

  • BM25: the standard text relevance score. Rare terms count more than common ones, and repeated matches have diminishing returns.
  • Business signals: recency, popularity, ratings, distance, personalization. Usually combined with BM25 in a scoring function.
  • Semantic search: embed documents and queries as vectors and find nearest neighbors (HNSW indexes). Hybrid search combines keyword and vector results, and is the usual setup behind retrieval for LLM apps.

How a search cluster scales

  • The index is split into shards, each a self-contained index, spread across nodes. A query goes to every shard (scatter), and the results are merged (gather).
  • Each shard has replicas, which add read capacity and survive node failures.
  • Indexing is near real time: new documents become searchable after a refresh, typically about a second.

Keeping it in sync

The search index is a derived copy, not the source of truth. The database is. Ways to keep them aligned:

  • Dual writes from the app: simple, but a crash between the two writes leaves them out of sync.
  • Change data capture (CDC): stream the database's change log (for example, via Debezium into Kafka) to an indexer. The standard answer: reliable, ordered, and replayable.
  • Periodic rebuild from the database to repair drift and apply mapping changes.
app = service "App Servers" @0,1
db = store "Postgres\n(source of truth)" @1,0
cdc = queue "CDC → Kafka" @2,0
idx = service "Indexer" @3,0
es = store "Elasticsearch" @3,1
search = service "Search Service" @1,1
app -> db : writes
db -> cdc : change log
cdc -> idx
idx -> es : upsert docs
app -> search : queries
search -> es

[!WARNING] Don't make search the source of truth Search engines are tuned for queries, not for durable, transactional writes. Keep the canonical data in a database and treat the index as something you can always rebuild.

[!INTERVIEW] Cover the pipeline, not just the box "Posts live in Postgres. A CDC stream feeds Kafka, and an indexer writes to Elasticsearch, sharded by post ID. Search queries hit Elasticsearch, ranked by BM25 plus recency, and results are typically visible within a couple of seconds of posting."