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:
- Recency โ trending queries score higher (time-decay factor)
- Personalization โ queries from the userโs own history score higher
- Geographic โ local queries (e.g. โcricketโ in India vs โbaseballโ in USA)
- 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
| Case | Solution |
|---|---|
| Offensive / banned queries | Blocklist filter at display time |
| Very long prefixes | Only 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 trending | Spike detector: if term count spikes > 5ร in 5 min, force prefix cache invalidation |
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.โ