Requirements & Data Collection
Functional Requirements
- Real-time Suggestions: As the user types each character, return top-k autocomplete suggestions within 100ms.
- Top-K Retrieval: Return the K most relevant suggestions per prefix (K typically 5–10).
- Multi-language Support: Handle English, Chinese, Hindi, Arabic, emoji, and mixed-script queries.
- Personalization: Optionally boost suggestions based on user search history.
- Trending Boost: Promote recently trending queries higher in results.
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Latency (p99) | < 100ms |
| Throughput | 100K+ QPS |
| Availability | 99.99% |
| Freshness | Suggestions updated every 1–4 hours |
| Scalability | Handle billions of unique prefixes |
Data Collection Pipeline
The foundation of autocomplete is a robust data collection and aggregation pipeline:
User Types Query
│
▼
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Search API │───▶│ Query Logger │───▶│ Kafka Topic │
└─────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ Aggregation │
│ (MapReduce) │
└──────┬───────┘
│
▼
┌──────────────┐
│ Frequency Map │
│ (prefix → │
│ top queries) │
└──────────────┘
Step-by-step collection process:
- Log every search query with timestamp, user ID (anonymized), locale, and result clicked.
- Buffer logs in Kafka for durability and decoupling.
- Aggregate periodically (every 1–4 hours) via a batch job (Spark/MapReduce):
- Count query frequencies over a rolling window (e.g., last 7 days).
- Decay older queries:
score = frequency × e^(-λ × days_old). - Filter out offensive/abusive queries using a blocklist.
- Group by prefix and retain top-K queries per prefix.
- Build trie from the aggregated frequency map.
- Deploy trie to serving tier (in-memory caches).
Frequency Map Schema
{
"prefix": "amaz",
"top_queries": [
{"query": "amazon prime", "score": 98420},
{"query": "amazon jobs", "score": 72100},
{"query": "amazonaws", "score": 54300},
{"query": "amazon rainforest", "score": 41200},
{"query": "amazon appstore", "score": 28900}
]
}
Handling Multi-Language
- Segment by locale: Build separate tries per locale (en, zh, hi, ar) to avoid cross-contamination.
- Transliteration handling: Map phonetic variants ("youtube" ↔ "youtub") to the same canonical form.
- Unicode normalization: Apply NFKC normalization before indexing.
- Right-to-left scripts: Arabic and Hebrew queries stored normally; display handled by client.
Blocklist & Safety
- Maintain a blocklist of offensive queries updated daily.
- Use ML-based toxicity detection for borderline queries.
- Log flagged queries for human review.
Trie-based Architecture
Trie Data Structure
A trie (prefix tree) is the canonical data structure for autocomplete. Each node stores a character, and the path from root to node represents a prefix. Leaf/internal nodes store the top-K queries for that prefix.
root
/ | \
a b c
/ | \
m a a
/ \ | \
a o t r
/ | | \
z s t e
/ | | \
o h o s
/ | \
n p s
\
e
Trie Node Structure:
class TrieNode:
def __init__(self):
self.children = {} # char → TrieNode
self.top_queries = [] # List[(query, score)] # top-K for this prefix
self.is_end = False
self.frequency = 0 # total frequency of this exact prefix
Key operations:
| Operation | Time Complexity | Description |
|---|---|---|
| Insert | O(L) | L = length of query |
| Search Prefix | O(L) | Find node for prefix |
| Top-K for Prefix | O(K) | Retrieve pre-stored top-K |
| Delete | O(L) | Remove query from trie |
Prefix Search with Top-K Retrieval
def autocomplete(prefix: str, k: int = 5) -> List[str]:
node = root
for char in prefix:
if char not in node.children:
return []
node = node.children[char]
# node.top_queries already contains pre-computed top-K
return [(q, s) for q, s in node.top_queries[:k]]
Why pre-compute top-K at each node?
- During offline aggregation, for every prefix that appears in any query, we compute and store the top-K suggestions.
- At serving time, we just traverse to the prefix node and return stored results — O(L) time, no DFS needed.
- Trade-off: more memory (each node stores K query strings), but serving is instant.
DFS with Priority Queue (Alternative)
If memory is constrained, store only exact-query frequencies and compute top-K on-the-fly:
def autocomplete_dfs(prefix: str, k: int) -> List[str]:
node = traverse_to_prefix(prefix)
if not node:
return []
min_heap = [] # (score, query)
dfs(node, prefix, min_heap, k)
return sorted(min_heap, key=lambda x: -x[0])
def dfs(node, current_prefix, heap, k):
# Add current node's accumulated queries
for query, score in node.exact_queries:
if len(heap) < k:
heapq.heappush(heap, (score, query))
elif score > heap[0][0]:
heapq.heapreplace(heap, (score, query))
for char, child in node.children.items():
dfs(child, current_prefix + char, heap, k)
Time complexity: O(N) where N = number of nodes under prefix. This is why pre-computation is preferred for serving.
Compressed Trie (Radix Tree)
Standard tries waste memory on chains of single-child nodes. A radix tree (compressed trie) merges chains:
Standard Trie: Radix Tree:
a amaz
│ / \
m on ing
│ / \
a on ongkong
│ / \
z e o
│ / \
o prime ring
│
n jobs
Memory savings: 50–80% reduction vs. standard trie.
class RadixNode:
def __init__(self):
self.children = {} # prefix_string → RadixNode
self.top_queries = []
self.is_end = False
Memory Optimization Strategies
| Strategy | Savings | Trade-off |
|---|---|---|
| Radix tree compression | 50–80% | Slightly slower traversal |
| Store pointers instead of strings in top-K | 30–50% | Indirection overhead |
| LRU eviction of cold prefixes | Variable | Rare prefixes re-computed |
| Memory-mapped files (mmap) | Swap to disk | Slower cold reads |
| Approximate counting (Count-Min Sketch) | 60–70% | Approximate frequencies |
Architecture Overview
┌──────────┐ ┌───────────┐ ┌──────────────────┐
│ Client │────▶│ API Gateway│────▶│ Autocomplete API │
│ (Browser/ │ │ (LB/WAF) │ │ (Stateless) │
│ Mobile) │◀────│ │◀────│ │
└──────────┘ └───────────┘ └────────┬─────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Trie │ │ Trie │ │ Trie │
│ Shard 1 │ │ Shard 2 │ │ Shard N │
│ (prefix │ │ (prefix │ │ (prefix │
│ a-i) │ │ j-r) │ │ s-z) │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└──────────────┼──────────────┘
▼
┌──────────────┐
│ Redis Cache │
│ (hot prefix │
│ results) │
└──────────────┘
Request flow:
- Client sends keystroke event (debounced 100–300ms).
- API Gateway routes to autocomplete service.
- Service checks Redis cache for prefix.
- Cache miss → route to correct trie shard based on prefix.
- Trie shard returns top-K from in-memory trie.
- Result cached in Redis and returned to client.
Ranking, Real-time Updates & Production Considerations
Scoring & Ranking Strategy
The quality of autocomplete suggestions depends on a multi-signal scoring model:
Final Score Formula:
score = w1 × frequency_score
+ w2 × recency_score
+ w3 × personalization_score
+ w4 × freshness_boost
+ w5 × trending_boost
| Component | Description | Typical Weight |
|---|---|---|
| Frequency Score | Log-scaled total query count over 7 days | 0.50 |
| Recency Score | Weighted by time decay: e^(-λ × hours_ago) |
0.20 |
| Personalization | User's own search history boost | 0.15 |
| Freshness Boost | New/trending queries get temporary boost | 0.10 |
| Trending Boost | Queries with accelerating velocity | 0.05 |
Frequency scoring with log scaling:
def frequency_score(count: int) -> float:
import math
return math.log1p(count) # log(1 + count) to handle zeros
Time decay function:
def recency_score(timestamp_hours_ago: float, half_life: float = 168) -> float:
import math
return math.exp(-0.693 * timestamp_hours_ago / half_life)
Trending detection (velocity):
def trending_boost(current_count: float, previous_count: float) -> float:
if previous_count == 0:
return current_count > 100 ? 2.0 : 1.0
velocity = current_count / previous_count
return min(velocity, 5.0) # cap at 5x boost
Personalization
- Maintain a per-user recent search history (last 50 queries) in Redis.
- When computing suggestions, boost queries that match user's past searches.
- Privacy consideration: Personalization data stored locally or in encrypted form; users can opt out.
- Cold start: New users get generic (frequency-only) suggestions.
Real-time Update Strategy
Autocomplete needs fresh suggestions without redeploying the entire trie:
┌─────────────────────────────────────────────────────┐
│ Update Pipeline │
│ │
│ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ Query │ │ Real-time │ │ Recent Query │ │
│ │ Logger │──▶│ Aggregator│──▶│ Cache (Redis)│ │
│ └──────────┘ │ (Flink) │ └──────┬───────┘ │
│ └───────────┘ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Merge with │ │
│ │ Batch Trie │ │
│ │ (every 1-4h) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────┘
Three-tier freshness model:
- Batch layer (hours): Full trie rebuild via Spark every 1–4 hours. Handles 99% of traffic.
- Speed layer (seconds): Flink streaming job aggregates last 1 hour of queries into a "recent" trie or Redis cache.
- Merge strategy: At query time, merge batch trie results with recent query cache:
def get_suggestions(prefix: str) -> List[str]: batch_results = trie_shard.query(prefix) # from batch trie recent_results = redis.get(f"recent:{prefix}") # from streaming merged = merge_and_rerank(batch_results, recent_results) return merged[:K]
Trie rebuild process:
- New batch job reads last 7 days of query logs from Kafka/S3.
- Builds new trie in memory (can be done in parallel shards).
- Validates trie (correctness checks, memory bounds).
- Atomic swap: new trie deployed to serving tier, old trie torn down.
- Zero-downtime deployment via blue-green or rolling update.
Sharding Strategy
Option 1: Prefix-range sharding
Shard 1: a-f Shard 2: g-m Shard 3: n-s Shard 4: t-z
- Pros: Range queries are efficient, easy to reason about.
- Cons: Hotspots if certain letter ranges are popular (e.g., "a" prefix is very common).
Option 2: Hash-based sharding
def get_shard(prefix: str) -> int:
return hash(prefix[:2]) % NUM_SHARDS # hash on first 2 chars
- Pros: Even distribution across shards.
- Cons: Range queries span multiple shards.
Recommended: Hash-based with consistent hashing for shard rebalancing.
Caching Strategy
┌────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │───▶│ CDN Edge │───▶│ Redis │───▶│ Trie │
│ │ │ (hot │ │ Cluster │ │ Shard │
│ │ │ prefix) │ │ (warm) │ │ (cold) │
└────────────┘ └──────────┘ └──────────┘ └──────────┘
Latency: <10ms <20ms <50ms
Cache tiers:
| Tier | Storage | TTL | Hit Rate |
|---|---|---|---|
| CDN Edge | Top 10K prefixes | 5 min | ~40% |
| Redis Cluster | Top 1M prefixes | 1 hour | ~50% |
| In-memory Trie | All prefixes | Until rebuild | ~10% |
Cache key format: ac:{locale}:{prefix} → JSON array of suggestions.
Edge Cases
| Edge Case | Handling |
|---|---|
| Empty prefix | Return trending/popular queries |
| Very long prefix (>50 chars) | Truncate to 50 chars for trie lookup |
| Special characters (\n, \t) | Strip/control chars before indexing |
| Unicode/Emoji | Normalize (NFKC), store as-is in trie |
| Rapid typing | Debounce client-side (150–300ms) |
| Profanity/abuse | Filter via blocklist at serving time |
| Prefix with no matches | Return empty array (no fallback) |
Performance Benchmarks
| Metric | Target | Actual |
|---|---|---|
| p50 latency | < 20ms | 15ms |
| p99 latency | < 100ms | 85ms |
| p999 latency | < 200ms | 150ms |
| Throughput per shard | 10K QPS | 12K QPS |
| Memory per shard | < 8GB | 6GB |
| Trie rebuild time | < 30 min | 20 min |
| Suggestion freshness | < 4 hours | 2 hours |
Interview Tips
- Start with requirements: Clarify functional vs non-functional, latency targets, scale.
- Draw the architecture early: Client → API → Trie → Data pipeline.
- Discuss trade-offs: Pre-computed top-K vs DFS, batch vs real-time, hash vs range sharding.
- Handle scale: "At 100K QPS, we shard tries by prefix hash, cache hot prefixes in Redis."
- Discuss freshness: "Batch rebuilds every 2 hours + streaming recent queries for freshness."
- Memory optimization: Mention radix tree compression, mmap, and LRU eviction.
- Edge cases: Multi-language, Unicode, empty prefixes, abuse filtering.
Practice Problems
Design a typeahead/autocomplete system that provides real-time search suggestions as users type. The system should handle billions of unique queries and serve millions of users concurrently with sub-100ms latency.
Quiz
1. What is the time complexity of searching for all suggestions with a given prefix in a standard trie?
2. Why is a radix tree (compressed trie) preferred over a standard trie for autocomplete serving?
3. In the autocomplete scoring model, what is the purpose of applying a log scale to query frequencies?
4. What is the recommended approach for providing fresh autocomplete suggestions while maintaining a batch-built trie?
5. When sharding tries across multiple servers, which strategy provides the most even distribution of load?
Flashcards
Question
What data structure is optimal for autocomplete prefix search and why?
Click to reveal answer
Answer
Trie (prefix tree). It provides O(L) prefix search where L is the prefix length, naturally organizes strings by prefix, and allows pre-computing top-K results at each node for O(1) retrieval.
Question
What is a radix tree and when should you use it over a standard trie?
Click to reveal answer
Answer
A radix tree compresses chains of single-child nodes into single edges, reducing memory by 50–80%. Use it when memory is a constraint but you still need efficient prefix search.
Question
How do you handle freshness in autocomplete without rebuilding the trie constantly?
Click to reveal answer
Answer
Use a lambda architecture: batch layer rebuilds full trie every few hours for comprehensive coverage, speed layer (Flink/Spark Streaming) aggregates recent queries into a separate cache merged at query time.
Question
What are the key components of an autocomplete scoring model?
Click to reveal answer
Answer
Frequency (log-scaled), recency (time decay), personalization (user history), freshness boost (trending), and trending boost (velocity). Weights are tuned empirically.
Question
How does the autocomplete system scale to handle 100K+ QPS?
Click to reveal answer
Answer
Shard tries by prefix hash across multiple servers, cache hot prefixes in Redis, serve top prefixes from CDN edge, use in-memory tries for all other prefixes.
Question
How do you collect and process autocomplete data?
Click to reveal answer
Answer
Log all search queries to Kafka, batch-aggregate frequencies via Spark/MapReduce every few hours, apply time decay and log scaling, build trie from frequency map, deploy to serving tier.
Revision Notes
Key Takeaways
- 1.Trie is the canonical data structure for prefix-based autocomplete with O(L) lookup
- 2.Pre-compute top-K at each trie node during batch build for instant serving
- 3.Use lambda architecture: batch trie rebuild + streaming recent queries for freshness
- 4.Log-scale frequencies to prevent dominant queries from overwhelming results
- 5.Shard by prefix hash, not range, to avoid hotspots
- 6.Multi-layer caching (CDN → Redis → in-memory trie) for low latency
- 7.Memory optimization via radix tree compression is critical at scale
Interview Tips
- •Start by clarifying requirements: latency targets, scale (QPS), freshness needs
- •Draw the high-level architecture first: Client → API → Trie Shards → Cache → Pipeline
- •Explain the data collection pipeline: log queries → aggregate → build trie → deploy
- •Discuss trade-offs: pre-computed top-K vs DFS, batch vs real-time, hash vs range sharding
- •Address scale explicitly: 'At 100K QPS, we need N shards, each handling ~10K QPS'
- •Cover edge cases: empty prefix, Unicode, profanity filtering, cold start
- •Mention monitoring: latency percentiles, cache hit rates, suggestion freshness metrics
- •Be ready for follow-ups: 'How do you handle a sudden trending topic?' → speed layer boost
Cheat Sheet
Autocomplete / Typeahead - Quick Reference
Key Metrics
- Latency target: < 100ms p99
- Throughput: 100K+ QPS
- Freshness: 1–4 hours
Data Structure
- Trie: O(L) prefix search, O(K) top-K retrieval
- Radix Tree: 50–80% memory savings via compression
- Node stores: children map + top-K suggestions
Architecture Components
- Client: Debounce keystrokes (150–300ms)
- API Gateway: Rate limiting, auth
- Autocomplete Service: Stateless, routes to trie shard
- Trie Shards: In-memory, sharded by prefix hash
- Redis Cache: Hot prefix results
- Data Pipeline: Kafka → Aggregation → Trie Build
Scoring Formula
score = 0.5 × log(freq) + 0.2 × recency + 0.15 × personalization + 0.1 × freshness + 0.05 × trending
Update Strategy (Lambda Architecture)
- Batch: Full trie rebuild every 1–4 hours via Spark
- Speed: Streaming aggregation of recent queries via Flink
- Merge: Combine batch + recent results at query time
Sharding
- Hash-based on first 2 chars of prefix
- Consistent hashing for rebalancing
- Each shard: ~10K QPS capacity
Edge Cases
- Empty prefix → trending queries
- Long prefix → truncate to 50 chars
- Unicode → NFKC normalization
- Abuse → blocklist + ML toxicity detection
Memory Optimization
- Radix tree compression: 50–80% savings
- Store pointers not strings in top-K
- LRU eviction of cold prefixes
- Memory-mapped files for overflow