Filter concepts by levelShowing all levels.

System Design · Section 46

Search Systems

Level
intermediate
Read
15 min
Concepts
3

Relational indexes answer "equals this value" or "falls in this range" — they can't rank relevance, tolerate typos, or accelerate a leading-wildcard LIKE query, which is the signal to reach for a dedicated search engine. That engine's core structure is the inverted index: raw text is tokenized into terms, normalized by an analyzer (lowercasing, stemming, stopword removal), and stored as term -> document mappings; the same analysis runs on queries, results are narrowed with exact-match filters and ranked by a relevance score such as BM25. Elastic/OpenSearch-style systems are specialized for that workload, not primary datastores — the standard architecture keeps the authoritative data in a relational or document store and indexes a derived, rebuildable copy into the search engine.

What is true here

  1. A B-tree index can't accelerate a leading-wildcard LIKE query and has no concept of relevance ranking, typo tolerance, or faceted filtering.
  2. The inverted index maps each normalized term to the documents containing it — built and queried through the same tokenize -> analyze pipeline.
  3. Ranking scores matches by relevance (commonly a TF-IDF-family formula such as BM25); filtering narrows candidates by exact-match criteria.
  4. Elastic/OpenSearch are specialized search engines, not primary stores — the authoritative data lives in a relational/document database, and the search index is a derived copy that should be rebuildable from it.

What you will be able to do

  • Recognize when a workload has outgrown what a relational index can do and needs a dedicated search engine
  • Explain how raw text becomes an inverted index via tokenization and analysis, and why query text must go through the same analyzer
  • Distinguish filtering (exact match) from ranking (relevance score) in a search query
  • Design a search architecture that keeps a primary store authoritative and treats the search index as a derived, rebuildable copy

When database search is insufficient

What a relational index can and can't do, and the signal that it's time to reach for a dedicated search engine.

When database search is insufficient

coreintermediate

A relational database's indexes (B-trees, hashes) are built to find rows by exact value or by range on a known column — "id = 5" or "created_at between X and Y." They are not built to answer "which documents contain words similar in meaning to this phrase, ranked by how relevant each one is." A LIKE '%term%' query can't use a B-tree index at all, so it falls back to scanning every row and string-matching inside it — correct, but linearly slow, and it still can't rank results, tolerate typos, understand word forms (run/running/ran), or search across many fields at once with one relevance score. Once a product needs free-text search with relevance ranking, typo tolerance, faceted filtering, or search across millions of documents with sub-second latency, that's the signal to bring in a dedicated search engine instead of asking the primary database to do more.

Think of it as

A B-tree index is like a library's card catalog sorted by exact title or author name — perfect for "find the book titled exactly this," useless for "find every book that discusses something like this topic, ranked by how central the topic is to each one." For the second question you need a completely different tool: an index built from every word inside every book, not just each book's title card.

sql
-- This predicate cannot use a standard B-tree index:
SELECT * FROM articles WHERE body LIKE '%distributed consensus%';
-- Postgres/MySQL will scan every row and string-match inside it.

What we're doing: Show why a leading-wildcard LIKE query degrades as a table grows, and what it still can't do even when it finishes.

search-limits.sqlsql
-- Table: articles(id, title, body, published_at)
-- Index: a plain B-tree on articles(id) and articles(published_at)

-- Fast: uses the B-tree index on published_at
SELECT id, title FROM articles
WHERE published_at > '2026-01-01';

-- Slow: leading wildcard defeats any B-tree index on body
SELECT id, title FROM articles
WHERE body LIKE '%distributed consensus%';
-- Postgres/MySQL scan all rows, string-match each one.

-- Even if it returns in time, it cannot do this:
-- "rank results by how relevant they are to
--  'distributed consensus algorithms', tolerating
--  the misspelling 'consensis', and also match
--  articles that say 'Raft' or 'Paxos' as related terms"
6
The range predicate on published_at can use a normal B-tree index — this is what relational indexes are built for.
10
The leading % in the LIKE pattern means no B-tree can be used; the database must scan and string-match every row.
15
Even a successful full-table scan still cannot rank, fuzzy-match, or understand related terms — that requires a different indexing structure entirely, not just a faster scan.

Why this works: The failure mode isn't just "slow" — it's "the wrong tool," because even an infinitely fast substring scan still can't produce ranked, typo-tolerant, semantically related results the way a search engine's inverted index and scoring model can.

Trying to fix a relevance problem by adding more B-tree indexes

Wrong

sql
-- "Search is slow, let's index every text column"
CREATE INDEX idx_title ON articles(title);
CREATE INDEX idx_body ON articles(body);
-- Still can't accelerate LIKE '%term%', and still
-- has no concept of relevance ranking.

Better

text
Recognize the actual gap is ranking + tokenized
matching, not missing indexes — either use the
database's built-in full-text search (tsvector +
GIN index in Postgres, FULLTEXT in MySQL) for
modest needs, or index a copy of the data into a
dedicated search engine (Elasticsearch/OpenSearch)
once ranking quality or query volume outgrows that.

What you see: Adding indexes to every text column doesn't fix search relevance or leading-wildcard query speed, because a B-tree was never the right structure for the problem in the first place.

Why: B-tree indexes accelerate equality and range lookups on ordered values — they have no mechanism for tokenizing text into searchable terms or scoring how relevant a document is to a query, so no amount of additional B-tree indexing closes that gap.

B-tree index vs. dedicated search engine

B-tree / hash index

  • +Fast exact match / range on a column
  • +Leading-wildcard LIKE forces a full scan
  • +No relevance ranking, typo tolerance, or facets

Dedicated search engine

  • Native substring/leading-wildcard matching
  • Relevance ranking is a core feature
  • Stemming, synonyms, faceted counts built in
  • B-tree / hash index
    • Fast exact match / range on a column
    • Leading-wildcard LIKE forces a full scan
    • No relevance ranking, typo tolerance, or facets
  • Dedicated search engine
    • Native substring/leading-wildcard matching
    • Relevance ranking is a core feature
    • Stemming, synonyms, faceted counts built in

What a B-tree index handles vs. what free-text search needs

What a B-tree index handles vs. what free-text search needs
CapabilityB-tree / hash indexDedicated search engine
Exact match / range on a columnFast, native use caseAlso supported, but not its strength
Substring / leading-wildcard matchFull table scan — no index useNative, indexed
Relevance ranking across fieldsNot supportedCore feature (scoring algorithm)
Typo tolerance, stemming, synonymsNot supportedBuilt into the analysis pipeline
Faceted counts across filtersRequires manual GROUP BY per facetNative aggregation support

Remember: A B-tree finds exact values and ranges; it can't rank relevance, tolerate typos, or match leading wildcards without a full scan. Reach for a dedicated search engine once ranking, fuzzy matching, or faceting outgrows the database's built-in full-text search — not before.

See also: inverted index and ranking pipeline · search engines as specialized not primary

Advertisement

Inverted indexes, tokenization, analyzers and ranking

The pipeline that turns raw text into a searchable, ranked index, and how a query is matched against it.

Inverted indexes, tokenization, analyzers and ranking

coreintermediate

An inverted index flips the natural document-to-words mapping around: instead of "document 5 contains these words," it stores "this word appears in documents 3, 5, 9" for every word. That flip is what makes free-text search fast — finding every document containing a term becomes a direct index lookup instead of scanning every document's text. Getting from raw text to that index takes a pipeline: tokenization splits text into individual terms, an analyzer normalizes those terms (lowercasing, removing punctuation, reducing words to a root form, dropping common words like "the"), and the resulting terms are what actually gets stored in the inverted index. At query time the same analysis runs on the search query, matching documents are found via the index, or narrowed further with exact-match filters, and then ranked by a relevance score — typically based on how often a term appears in a document relative to how common that term is across the whole collection (a TF-IDF-style measure such as Elasticsearch's default BM25).

Think of it as

A book's back-of-the-book index is a real-world inverted index: instead of reading the whole book to find every mention of "consensus," you look up "consensus" in the index and get a list of page numbers directly. Tokenization and the analyzer are the librarian's style rules for building that index — do "Consensus" and "consensus" count as the same entry (lowercasing)? Does "running" get filed under "run" (stemming)? Do filler words like "the" and "and" get an index entry at all (stopword removal)? Ranking is the difference between an index that just lists every page a word appears on, versus one that tells you which of those pages talk about the word the most.

text
Indexing pipeline (build time):
  raw text -> tokenizer -> token filters (lowercase,
  stem, stopwords) -> terms -> written into the
  inverted index as term -> [doc IDs, positions]

Query pipeline (search time):
  raw query -> SAME analyzer -> terms -> look up each
  term in the inverted index -> candidate docs
  -> apply filters (exact match) -> score remaining
  docs (e.g. BM25) -> return ranked results

What we're doing: Trace two short documents from raw text through tokenization and analysis into the inverted index they produce, then show a query hitting that index.

inverted-index-walkthrough.txttext
Doc 1: "Running distributed systems is hard"
Doc 2: "Distributed databases replicate data"

Step 1 — tokenize (split on whitespace/punctuation):
  Doc 1: [Running, distributed, systems, is, hard]
  Doc 2: [Distributed, databases, replicate, data]

Step 2 — analyze (lowercase, drop stopwords, stem):
  Doc 1: [run, distribut, system, hard]
  Doc 2: [distribut, databas, replic, data]

Step 3 — build the inverted index (term -> doc list):
  run       -> [1]
  distribut -> [1, 2]
  system    -> [1]
  hard      -> [1]
  databas   -> [2]
  replic    -> [2]
  data      -> [2]

Step 4 — query "distributed systems", analyzed the
same way -> terms [distribut, system]
  distribut -> docs [1, 2]
  system    -> docs [1]
  Candidates: {1, 2}. Doc 1 matches both terms,
  doc 2 matches only one -> doc 1 ranks higher.
8
Both "Running" and "distributed" are stemmed to their root form (run, distribut) — this is what lets a search for "run" later match a document that only contains "Running."
14
The inverted index stores "distribut" once, pointing at both documents — this is the core structure: term to document list, not document to term list.
21
The query goes through the exact same analyzer as indexing, producing terms that can actually be looked up in the index built in step 3.
25
Ranking uses how many query terms each document matched (and would also weigh term rarity in a real TF-IDF/BM25 score) — doc 1 outranks doc 2 because it matches both query terms, not just one.

Why this works: Showing the same two words ("distributed", "systems") reduced to the same stems in different documents makes concrete why tokenization and analysis have to happen — and be consistent — before the inverted index or ranking can work at all.

Assuming a search engine does exact string matching like a WHERE clause

Wrong

text
"Search for 'running systems' returned a doc that
only says 'run system' — that's a bug, it doesn't
match the exact text."

Better

text
"That's the analyzer working as designed —
'running' and 'run' were both stemmed to the same
root, and stopwords were dropped, so the match is
correct relative to the configured analysis pipeline,
not a bug. If exact-text matching is required for a
field (e.g. a SKU), map that field as 'keyword' /
unanalyzed instead of full-text."

What you see: A search returns documents that don't contain the literal query string, or fails to return a document that does — usually explained by what the analyzer normalized both the indexed text and the query into, not by the raw text itself.

Why: Full-text search never matches on the raw string — it matches on normalized terms produced by the analyzer, so understanding what the analyzer does to both indexed content and queries is required to reason about which documents match.

Indexing pipeline: raw text to the inverted index
splitnormalizestore

Raw text

"Running distributed systems"

Tokenizer

splits into terms

Analyzer

lowercase, stem, drop stopwords

Inverted index

term → [doc IDs]

  • Raw text — "Running distributed systems"
    • leads to Tokenizer (split)
  • Tokenizer — splits into terms
    • leads to Analyzer (normalize)
  • Analyzer — lowercase, stem, drop stopwords
    • leads to Inverted index (store)
  • Inverted index — term → [doc IDs]

One sentence through the analysis pipeline

One sentence through the analysis pipeline
StageInputOutput
Raw text"The Quick foxes leap"
Tokenizer"The Quick foxes leap"[The, Quick, foxes, leap]
Lowercase filter[The, Quick, foxes, leap][the, quick, foxes, leap]
Stopword filter[the, quick, foxes, leap][quick, foxes, leap]
Stemmer[quick, foxes, leap][quick, fox, leap]

Pipeline stage vs. what it decides

Pipeline stage vs. what it decides
StageQuestion it answers
TokenizationWhere does one term end and the next begin?
Analyzer (normalization)Which distinct strings should count as the same searchable term?
IndexingWhich documents contain this term, and where?
FilteringWhich documents pass exact-match criteria, before or alongside scoring?
RankingOf the matching documents, which are most relevant to this query?

Remember: Pipeline: tokenize (split into terms) -> analyze (normalize: lowercase, stem, drop stopwords) -> build the inverted index (term -> document list) -> at query time, the same analysis on the query -> filter (exact match) -> rank (relevance score, e.g. BM25). Index and query must use the same analyzer or terms won't line up.

See also: when database search is insufficient · search engines as specialized not primary

Advertisement

Where search engines fit in an architecture

Why Elastic/OpenSearch-style systems are specialized search engines, not primary relational stores.

Search engines are specialized, not primary stores

coreintermediate

Elasticsearch and OpenSearch are built to answer "find and rank the documents matching this query" extremely well — that's their one job, and their storage model, replication, and query language are all shaped around it. They are not built to be a system's primary, authoritative datastore: they generally lack the transactional guarantees (multi-row ACID transactions, foreign-key constraints) that a relational database provides, and updates are commonly modeled as reindexing a whole document rather than fine-grained partial writes. The standard architecture keeps the real, authoritative data in a primary store (PostgreSQL, MySQL, MongoDB) and indexes a copy of the searchable fields into the search engine — the search engine holds derived data, not the source of truth, and if its index is ever lost, it must be rebuildable by reindexing from the primary store.

Think of it as

A search engine sitting next to a primary database is like a restaurant's printed menu next to its kitchen inventory system. The menu is fast to browse and lets a customer search "spicy vegetarian dishes under $15" instantly — but nobody restocks ingredients by editing the menu. The kitchen's inventory system is the source of truth for what actually exists; the menu is a derived, searchable view of it, regenerated whenever the kitchen's data changes. Losing the menu is an inconvenience you reprint; losing the inventory system is losing the business.

text
Typical architecture:

  writes ---> [ Primary store: Postgres/MySQL/Mongo ]
                        |
                (indexing pipeline: CDC stream,
                 batch job, or dual write)
                        v
              [ Search engine: Elastic/OpenSearch ]
                        ^
  reads (search) -------'

  The primary store is authoritative. The search
  engine is a derived, rebuildable index of it.

What we're doing: Show what breaks when a team treats the search engine as the primary store for order data, and how the standard two-store pattern avoids it.

search-as-primary-anti-pattern.txttext
Anti-pattern: order records written directly and
only into Elasticsearch, no relational store at all.

1. Two requests update the same order's status
   concurrently -> Elasticsearch has no multi-document
   transaction to make this atomic the way a relational
   UPDATE ... WHERE with a transaction would.
2. A mapping change requires reindexing -> since there
   is no other copy of the data, the reindex source
   IS the search engine, and a mistake here has no
   independent backup to recover from.
3. Finance asks for a strongly consistent read of
   "current balance after this order" -> the search
   engine's near-real-time indexing (a short delay
   between write and searchable) makes this the wrong
   tool for a read that must reflect the latest write.

Standard pattern instead:
  Orders table in Postgres (source of truth, ACID
  transactions) -> CDC stream or batch job ->
  Elasticsearch index of orders (searchable copy).
  Balance reads go to Postgres; free-text/faceted
  order search goes to Elasticsearch.
6
Concurrent updates to the same document have no cross-document transaction guarantee in a search engine the way a relational transaction provides — a real gap, not a hypothetical one.
9
With no independent primary store, the search engine's own copy is the only copy — there is nothing to reindex from if something goes wrong.
13
Search engines index in near real time (a short, deliberate delay), which is the wrong consistency model for a query that must reflect the very latest write, like a financial balance.

Why this works: The failure modes are concrete and different in kind (concurrency, recoverability, consistency) — showing all three makes clear why the fix is architectural (keep a primary store) rather than a matter of using Elasticsearch more carefully.

Querying the search engine for data that needs strong consistency

Wrong

text
# Account balance service reads directly from
# the Elasticsearch index after a deposit, to
# show the user their new balance immediately.
POST /accounts/_update/123 { "balance": 150 }
GET  /accounts/123   # read back right after write

Better

text
# Balance reads and writes go to the primary
# relational store, which guarantees the write
# is visible to an immediate read-after-write.
# The search engine only serves search/browse
# queries built from a copy of the data.
UPDATE accounts SET balance = 150 WHERE id = 123;
SELECT balance FROM accounts WHERE id = 123;

What you see: A user who just made a deposit occasionally sees their old balance for a brief window afterward — traceable to reading from the search engine's near-real-time index instead of the primary store that was just written to.

Why: Search engines are built around near-real-time indexing for search workloads, not read-after-write consistency for transactional data — using them for the latter reintroduces a staleness window the primary store wouldn't have.

Primary store is authoritative; the search engine is derived
writecopyindex

Writes

Primary store

Postgres/MySQL — source of truth

Indexing pipeline

CDC or batch job

Search engine

Elastic/OpenSearch — derived, rebuildable

  • Writes
    • leads to Primary store (write)
  • Primary store — Postgres/MySQL — source of truth
    • leads to Indexing pipeline (copy)
  • Indexing pipeline — CDC or batch job
    • leads to Search engine (index)
  • Search engine — Elastic/OpenSearch — derived, rebuildable

Primary store vs. search engine — where each responsibility lives

Primary store vs. search engine — where each responsibility lives
ResponsibilityPrimary store (e.g. PostgreSQL)Search engine (Elastic/OpenSearch)
Source of truthYes — authoritative recordNo — holds a derived, searchable copy
Multi-row transactionsYes (ACID)Not designed for this
Relevance-ranked free-text searchLimited (built-in full-text search)Core strength
Recovery if data is lostBackups / replication are the only path backCan often be rebuilt by reindexing from the primary store

Remember: Elastic/OpenSearch are specialized for ranked, faceted, free-text search — not ACID transactions or being the source of truth. Standard pattern: primary store (Postgres/MySQL/Mongo) holds the authoritative data; an indexing pipeline copies it into the search engine, which should always be rebuildable from the primary store.

See also: when database search is insufficient · inverted index and ranking pipeline

Advertisement