HLD: Design a Search Autocomplete System

Design Google-scale typeahead โ€” trie vs prefix hash, top-K ranking, distributed storage, and < 100ms end-to-end latency.

deep hard โฑ 30 min hldautocompletetypeaheadtrietop-krankingprefix-search
Mastery:
Why interviewers ask this
Search autocomplete tests data structure choice (trie vs inverted index), distributed aggregation, top-K algorithms, and caching under extreme read load.

Step 1: Requirements

Functional:

  • Given a prefix, return the top 5 matching suggestions, ranked by popularity
  • Update rankings based on real search volume (near-real-time, not instant)
  • Support Unicode / international queries

Non-functional:

  • 10M DAU, each types ~5 searches/day โ†’ 50M searches/day โ†’ ~580 queries/sec average
  • Every keystroke = one API call โ†’ 10x keystroke-to-search ratio โ†’ ~5,800 RPS
  • Latency < 100ms end-to-end (including network)
  • Suggestions update within ~10 minutes of a trending query

Estimation:

Queries: 5,800 RPS at peak
Unique prefixes: for 10M searches/day with avg 5 chars typed = 50M prefix queries/day
Top 5 result per query โ†’ results are small (5 ร— 30 chars โ‰ˆ 150 bytes)
Index size: store top-K for every prefix โ†’ depends on vocabulary size

Step 2: Data structure choice

Option A: Trie (prefix tree)

A trie stores characters node by node. Each node optionally stores the top-K suggestions for that prefix.

Trie for ["apple", "app", "apply", "apt"]:

root
 โ””โ”€ a
    โ”œโ”€ p
    โ”‚  โ”œโ”€ p            (word: "app")
    โ”‚  โ”‚  โ”œโ”€ l
    โ”‚  โ”‚  โ”‚  โ”œโ”€ e      (word: "apple")
    โ”‚  โ”‚  โ”‚  โ””โ”€ y      (word: "apply")
    โ”‚  โ””โ”€ t            (word: "apt")

Augmented trie: each node stores top_k: List[string] โ€” the top 5 suggestions for that prefix, sorted by frequency. This makes reads O(1) after traversal.

Problem: trie for billions of queries doesnโ€™t fit in a single machineโ€™s memory.


Option B: Prefix hash table (simpler, scalable)

Pre-compute top-K suggestions for every possible prefix and store in a hash table / Redis:

"a"   โ†’ ["apple", "amazon", "airbnb", "alibaba", "adobe"]
"ap"  โ†’ ["apple", "apple store", "apply", "apt", "apex"]
"app" โ†’ ["apple", "app store", "apple music", "apple id", "apps"]

Read: O(1) โ€” just a hash lookup. Storage: for average query length 5, there are 5 prefix versions of each query โ†’ 5ร— the storage of raw query counts.

This is simpler to distribute and reason about than a trie, and is the preferred approach at scale.


Step 3: System architecture

Two separate paths:

DATA COLLECTION PATH (offline / near-real-time)
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
User searches
  โ””โ”€ Kafka topic "searches" (search term + timestamp + userId)
       โ””โ”€ Stream processor (Flink / Spark Streaming)
            โ”œโ”€ Aggregate search counts per term (5-min windows)
            โ”œโ”€ Merge with historical counts
            โ””โ”€ Update top-K prefix table
                 โ””โ”€ Redis Cluster (prefix โ†’ top-K list)
                 โ””โ”€ Backup: DynamoDB or Cassandra

QUERY PATH (real-time, < 100ms)
โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
User types "app"
  โ””โ”€ Browser: debounce 100ms, GET /suggest?q=app
       โ””โ”€ API Gateway โ†’ CDN cache (if popular prefix, TTL 60s)
       โ””โ”€ Cache miss โ†’ Suggest Service
             โ””โ”€ Redis lookup: GET prefix:app
             โ””โ”€ Return ["apple", "app store", ...] (JSON ~150 bytes)

Step 4: Top-K algorithm

Computing top-K suggestions from a stream of search events:

MapReduce batch job (for historical baseline):

# Mapper: emit (term, 1) for each search event
def mapper(search_event):
    yield (search_event.query, 1)

# Reducer: sum counts per term
def reducer(term, counts):
    yield (term, sum(counts))

# After: sort by count DESC, take top 100K terms
# For each term, generate all prefixes and update prefix hash

Streaming aggregation (for near-real-time updates):

# Flink / Kafka Streams: tumbling 5-min window
stream
  .keyBy("query")
  .window(TumblingEventTimeWindows.of(Time.minutes(5)))
  .sum("count")
  .process(lambda term, count: update_prefix_table(term, count))

Updating prefix table:

def update_prefix_table(term: str, delta: int, redis_client):
    # Update global term frequency
    redis_client.zincrby("term_counts", delta, term)
    
    # Update top-K for each prefix of this term
    for i in range(1, len(term) + 1):
        prefix = term[:i]
        prefix_key = f"prefix:{prefix}"
        
        # Sorted set: member=term, score=frequency
        redis_client.zincrby(prefix_key, delta, term)
        
        # Keep only top 5 (trim excess members)
        # ZREVRANK gives rank (0 = highest)
        rank = redis_client.zrevrank(prefix_key, term)
        if rank is not None and rank >= 5:
            redis_client.zrem(prefix_key, term)

Redis sorted sets are perfect: ZINCRBY, ZREVRANGE with O(log N) complexity.


Step 5: Scaling read path

At 5,800 RPS with p99 < 100ms, the Redis lookup itself needs to be fast and available.

Redis Cluster:

  • Shard by prefix: prefix:a* โ†’ shard 1, prefix:b* โ†’ shard 2, etc.
  • Each shard has a read replica for high availability
  • Lookup: ZREVRANGE prefix:{query} 0 4 WITHSCORES โ†’ O(log N + K)

CDN caching:

  • Most popular prefixes (โ€œaโ€, โ€œapโ€, โ€œtheโ€, โ€œheโ€) are queried millions of times/day
  • Cache at CDN edge with 60s TTL
  • ~20% of queries hit CDN โ†’ reduces Redis load by 20%

Client-side caching:

  • Cache recent prefix results in the browser (Map keyed by query string)
  • If user types โ€œappleโ€ and then deletes to โ€œapplโ€, serve the cached โ€œapplโ€ result without a network call

Step 6: Ranking improvements

Beyond raw frequency, rank by:

  1. Recency โ€” trending queries score higher (time-decay factor)
  2. Personalization โ€” queries from the userโ€™s own history score higher
  3. Geographic โ€” local queries (e.g. โ€œcricketโ€ in India vs โ€œbaseballโ€ in USA)
  4. Spell correction โ€” map โ€œapplwโ€ โ†’ โ€œappleโ€ using edit distance (Levenshtein) on the top-K candidates

Simple recency score:

score = frequency ร— e^(-ฮป ร— days_since_last_seen)
where ฮป controls decay rate (e.g. 0.1 = half-life ~7 days)

Step 7: Handling edge cases

CaseSolution
Offensive / banned queriesBlocklist filter at display time
Very long prefixesOnly pre-compute top-K for prefixes โ‰ค 10 chars; longer โ†’ exact DB lookup
Cold start (new query)Fall through to Elasticsearch full-text search for unrecognized prefixes
Unicode (โ€œๅŒ—ไบฌโ€)Normalize to UTF-8; store full unicode prefix keys
Real-time trendingSpike detector: if term count spikes > 5ร— in 5 min, force prefix cache invalidation

Say it out loud
โ€œI use a prefix hash table stored in Redis sorted sets โ€” each prefix maps to a sorted set of (term, frequency) pairs. ZREVRANGE prefix:app 0 4 returns the top 5 in O(log N). The hard problem is keeping it updated: search events go to Kafka, a stream processor aggregates counts in 5-minute windows, and updates the sorted sets. Popular prefixes are cached at the CDN edge with a 60s TTL for the bulk of the read traffic. The query path itself is stateless โ€” just a Redis GET โ€” and easily horizontally scalable.โ€

Likely follow-up questions
  • How does a trie work? What are its time/space complexities?
  • How do you store a trie that is too large to fit in memory?
  • How do you update suggestion rankings without rebuilding the whole trie?
  • How do you personalize suggestions?
  • How do you handle typos and fuzzy matching?

References