Index/Search and Typeahead

SponsorGitHub
Design14 min

Search and Typeahead

A billion documents, a hundred thousand queries a second, and autocomplete firing on every keystroke.

Primary concepts and the hard part

Concepts
inverted indextokenization and stemmingTF-IDF/BM25 rankingscatter-gather shardingtail latencynear-real-time indexingtriesCDC pipelinescaching
The hard part they’re probing
Two things. (1) Search is a scatter-gather — your latency is bounded by your slowest shard, so tail latency dominates. (2) The search index is not your source of truth; you must explain how it stays in sync and that it lags.

Requirements

Functionalwhat it must do
Full-text search over documents (products, posts, repos)
Typeahead suggestions as the user types
Filters/facets (category, date range, price)
Relevance ranking, pagination
Typo tolerance
Non-functionalhow well it must do it
Typeahead p99 < 100ms (it fires on every keystroke)
Search p99 < 300ms
Index freshness: new documents searchable within ~seconds
Read-heavy, high query volume
Out of scopedeliberately left out
Personalization/learned ranking models
Image search
Scale
Documents:    1B
Queries:      100,000/sec search
Typeahead:    500,000/sec (fires per keystroke — 5x search volume!)
Index size:   1B docs × ~1KB indexed → ~1 TB → ~20 shards of 50 GB
Query cache hit rate: 60-80% (head queries are extremely repetitive)
Conclusion
Typeahead is a higher-volume, lower-latency problem than search itself. It needs a completely different data structure (in-memory trie), not the search cluster. Recognizing that is half the answer.

API / Model

GET
/v1/search?q=&filters=&sort=&cursor=&limit=20
200OKranked results
GET
/v1/suggest?q=&limit=10
separate service, separate SLA
200OKcompletions
primary or partition keysort keyforeign key → referenced columnHover a table or column to trace its keys

High-level architecture

DatabaseCacheQueue / streamFocusClick a node for details

The design has an indexing path that keeps the inverted index a few seconds behind the source of truth, and a query path with two services: the Search Service for submitted queries and the Suggest Service for keystrokes. Only search touches the index shards.

  1. When the user submits a query, the Search Service checks the Redis query cache under a normalized key, and 60-80% of queries are answered there.
  2. On a miss, the Query parser runs the same analysis chain used at index time, plus spell correction.
  3. The parser scatters the query to every shard. The index is document-partitioned, so each shard holds a complete index for its share of documents.
  4. Each shard scores its matches with BM25 and returns its top 20.
  5. The gather step merges and re-ranks those lists into the global top 20, using hedged requests and a timeout so one slow shard can only cost partial results.
  6. Hydrate adds titles, snippets and facets, caches the result, and returns it to the user.

Typeahead runs beside this. Every keystroke, debounced by 50ms, goes to the Suggest Service, which walks an in-memory trie of top-K completions per prefix in about 5ms; the trie is rebuilt offline from query logs. Documents reach the shards through the indexing path: changes in the source of truth flow through CDC into Kafka document.changed, the analysis pipeline tokenizes, lowercases, drops stopwords, stems and enriches them, and each document goes to the shard chosen by hash(doc_id).


Trade-offs and deep dives

01
Why an inverted index at all

WHERE body LIKE '%distributed%' cannot use a B-tree index (leading wildcard) and scans every row. The inverted index flips the mapping so lookup is O(1) on the term plus a posting-list intersection. For a multi-word query, intersect the posting lists, starting with the shortest one to minimize work.

02
Index-time and query-time analysis must be identical

If you stem at index time but not at query time, a search for "running" won't match the indexed token "run". This is the single most common real-world search bug and a good thing to mention.

03
Ranking: TF-IDF → BM25

TF-IDF says a term matters if it's frequent in this document but rare in the corpus. BM25 adds saturation (the 50th occurrence of a word adds less than the 2nd) and document-length normalization (so long documents don't win by accident). Then layer business signals — recency, popularity, quality — as a weighted combination or a learned model.

04
Sharding by document, not by term
  • Document-partitioned (standard): each shard holds a complete index for its subset of documents. Every query hits every shard (scatter-gather), but each shard's work is small and the system is easy to grow.
  • Term-partitioned: each shard holds all postings for a subset of terms. A single-term query hits one shard, but multi-term queries require shipping huge posting lists across the network, and popular terms create brutal hotspots. Almost always the wrong choice — worth knowing so you can reject it with a reason.
05
Tail latency is the defining constraint

With 20 shards, your p99 is roughly the p99 of the maximum of 20 samples, which is far worse than any single shard's p99 (Module 1's tail amplification). Mitigations to name:

  • Hedged requests: after waiting the p95 latency, send a duplicate request to another replica and take whichever answers first.
  • Timeout with partial results: return what you have from 19 shards rather than waiting for the 20th. Search users tolerate slightly worse results far better than a spinner.
  • Replica load balancing away from slow nodes.
06
Near-real-time, not real-time

Engines buffer writes in memory and periodically flush to a new immutable segment (Elasticsearch refreshes ~once per second by default). Segments are later merged in the background. So there is a ~1s window where a written document isn't searchable. Say this out loud; claiming instant searchability is a credibility hit. If a user must see their own new item immediately, special-case it from the source of truth rather than tightening the refresh interval globally.

07
Typeahead is a different system

Requirements differ on every axis: 5x the query volume, 10x tighter latency, and only prefixes matter. So:

  • Use an in-memory trie with the top-K completions precomputed and stored at each node, so a lookup is a walk down the prefix with no ranking work at query time.
  • Rebuild it offline from query logs (hourly or daily) — suggestions don't need to be fresh to the second.
  • Debounce on the client (~50ms) and cancel in-flight requests when the user keeps typing. This alone cuts backend load by more than half.
  • Cache aggressively at the edge; prefix distribution is extremely head-heavy.
08
Typo tolerance

Edit-distance matching is expensive over 1B docs. Practical approaches: precomputed common misspelling → correction mappings from query logs, n-gram indexes for fuzzy matching, and "did you mean" suggestions rather than silently expanding the query.

09
Query cache

Normalize the query (lowercase, sort filters, strip whitespace) before using it as a cache key, or you'll cache the same query a dozen ways. Head queries repeat enormously, so hit rates of 60-80% are realistic and this is the cheapest latency win available.

10
The index is derived, always

Feed it from CDC or an event stream, never make it the write path's dependency, and be able to rebuild it from scratch by replaying. If the index is corrupted or the mapping changes, you reindex — which is only possible because the source of truth is elsewhere.


Possible follow-up questions

6 questions·try answering before you reveal