๐Ÿ“š Study Notes / Home / HLD / Session 13
Session 13 ยท Case Study โ€” Typeahead-2

Typeahead-2: actually building, storing, and scaling autocomplete

In the previous session we sketched what a search "type-as-you-go" suggestion box should do. Today we roll up our sleeves and build it for real: how to store the data (tries vs. a plain key-value cache), how to make every keystroke feel instant, how the system becomes terrifyingly read- and write-heavy, and the two clever tricks โ€” batching and sampling โ€” that tame the write storm. We finish with the "make it smarter" extras: recency/freshness, geolocation, personalization, and typo handling. We assume you've studied none of this before, so every topic opens with a tiny "explain like I'm 5" story and ends with a recap.

โฑ 27 min read๐Ÿ“– 8 topics

1 The problem & the prefix rule


Explain like I'm 5

You start typing in a search box โ€” just the letters "What" โ€” and before you even finish, a little list pops up guessing the rest: "what is 2+2", "what is the color of the sky"โ€ฆ It's like a friend who finishes your sentences, and always with the most popular endings. Our whole job today is to build that friend: given the few letters typed so far, instantly show the top 5 most-searched queries that begin with exactly those letters.

This is the typeahead (autocomplete) feature. The rule is simple to state: if the user types the partial query "What", we must show the top 5 search queries that are a strict prefix match โ€” i.e. queries that literally start with "What".

That little phrase "prefix match" is the heart of everything. It tells us two things: (1) the data structure we pick must be great at "find everything starting with these letters", and (2) among all those matches, we must rank by popularity and keep only the best few. The rest of this session is two different ways to do exactly that โ€” and then how to make it survive real-world scale.

Two operations to support
  • typeahead(partialQuery) โ€” the read: given what's typed so far, return the top suggestions. This must be blazing fast (it fires on every keystroke).
  • log_search(searchQuery) โ€” the write: when a user actually runs a full search, record it so popularity counts stay current.
Recap Typeahead must, on every keystroke, return the top 5 queries that strictly start with the typed prefix, ranked by popularity. That means we need a data structure good at prefix lookups plus a way to rank by count. Two operations matter: a very fast read (typeahead) and a write that keeps counts fresh (log_search).

2 Approach 1 โ€” Tries


Explain like I'm 5

Imagine a giant tree made of letters. You start at the top (the root) and walk down one letter at a time: W โ†’ h โ†’ a โ†’ t. Everything hanging below the spot where you stop is a possible way to finish the word. So to suggest endings for "What", you walk to the "What" spot and look at everything underneath it. That letter-tree is called a trie (say "try").

The classic data-structures answer to "find everything with this prefix" is a trie (a prefix tree). Each path from the root spells out a string, and shared prefixes share the same path โ€” so "what is 2+2" and "what does the fox say" share the "what" branch.

A trie of letters with a highlighted path spelling a prefix
A trie: start at the root and walk one letter per child. Everything in the subtree below the node you land on is a possible suggestion for that prefix.

What each trie node stores

  • children โ€” links to all the subsequent letters.
  • isTerminal โ€” does a complete search query end at this node?
  • count โ€” only meaningful when isTerminal: the number of times this exact query has been searched.
class TrieNode {
    TrieNode children[63]     // a-z + A-Z + 0-9 + space
    long count;               // how many times has this
                              // query been searched
}

Why 63 children? Lowercase letters (26) + uppercase (26) + digits (10) + the space character (1) = 63 possible "next characters" at any node.

The read: typeahead(partialQuery)

๐ŸŒฑ
Start at root
Top of the trie
โ†’
๐Ÿ”ค
Walk letters
One child per letter of the prefix
โ†’
๐Ÿ“
Land on node
Matching the partialQuery
โ†’
๐Ÿ†
Scan subtree
Find the top 5 in it

To serve typeahead(partialQuery): start from the root and, for each letter of the partial query, descend into the appropriate child. Once you reach the node that matches the partialQuery, all possible suggestions live inside the subtree of that node. To find the answer you'd have to walk that entire subtree (every query that matches the prefix) and pick out the top 5.

A trie node whose entire subtree must be scanned to find top suggestions
The catch: under a short prefix the subtree can hold billions of entries. Scanning all of them on every keystroke is far too slow.
Watch out โ€” the subtree can be enormous

Walking the whole subtree is highly time-consuming. There are potentially billions of entries under a short prefix like "wh". Re-scanning that on every keystroke is a non-starter.

The fix: data-augmentation

We can fix this with data-augmentation: at every node, pre-compute and store the top 5 results for that prefix. Now typeahead just reads the already-computed answer off the node โ€” no subtree scan at all. The price is that updates become (slightly) slow, because writing a new count may change the stored top-5 of many ancestor nodes.

Worked example โ€” augmented read is instant

The node for "what" stores, right on itself: ["what is 2+2" โ†’ 10000, "what is the color of the sky" โ†’ 5000, "what does the fox say" โ†’ 2000]. When the user types "what", we walk four letters, land on the node, and hand back its stored list. No billion-entry scan โ€” just a four-step walk and a read.

The write: log_search(searchQuery)

To update the count for a query:

  • Traverse to the node that matches this searchQuery.
  • Increment its count.
  • If augmented, also update the stored top-5 data for all nodes from here up to the root (because a bigger count here might now belong in the top-5 of every prefix above it).
Recap A trie answers typeahead(prefix) by walking the prefix and then scanning the subtree for the top 5 โ€” but that subtree can hold billions of entries. So we data-augment every node with its pre-computed top 5: reads become instant, while writes get a little slower because each new count must propagate up to the root.

3 Sharding a trie


Explain like I'm 5

One giant tree won't fit on one computer, so we cut it into pieces and put each piece on a different machine. The question is: how do we decide which piece a query belongs to? The rule we choose is the sharding key. We want each machine to hold a fair, roughly-equal share of the data โ€” not one machine groaning under all the popular stuff while another sits nearly empty.

Q: What will be the sharding key? A sharding key decides which server (shard) a piece of data lives on. For a trie the natural choices are "first letter" or "first few letters" of the query.

Option A โ€” shard by the first letter

Every query that starts with the letter a goes to the same shard. Important: this does not mean the letter a gets a dedicated shard โ€” a single shard might contain several starting letters. We're only guaranteeing that all queries starting with a land together in one place.

Sharding a trie by the first letter of each query
First-letter sharding: all queries beginning with the same letter go to one shard. Simple, but only 26 buckets and very uneven sizes.
  • Low cardinality โ€” only 26 possible children, so at most ~26 groups to spread across machines.
  • Uneven data distribution โ€” the number of queries starting with a is much higher than those starting with z. Some shards get swamped, others idle.

Option B โ€” shard by the first 3 letters

Every query that starts with the letters abc goes to the same shard. (Same caveat: a single shard may hold several starting triples; we only guarantee that all abc queries are together.)

Sharding a trie by the first three letters of each query
First-3-letters sharding: far more buckets (โ‰ˆ17,000), giving finer control โ€” but still lopsided, since common triples like "why" dwarf rare ones like "zxz".
  • High cardinality โ€” 26ยณ โ‰ˆ 17,000 possible groups, so we have far more buckets to balance across machines.
  • Still uneven โ€” many more queries start with why than with zxz.

Fixing the load imbalance: manual grouping

To balance the load with the 3-letter key, we manually create groups of starting triples so that frequent prefixes get their own room and rare prefixes are clubbed together:

Manually grouping rare three-letter prefixes onto shared shards
Manual grouping: hot prefixes (e.g. "why") get a dedicated shard, while many rare prefixes ("zxz", "zyx", "zxy"โ€ฆ) are clubbed onto one shared shard to even out the load.
  • Queries starting with why are very frequent โ‡’ give them a dedicated shard.
  • Queries starting with zxz are rare โ‡’ group them with other rare prefixes (zyx + zxy + zxz + โ€ฆ) onto one shard.
Sharding keyCardinalityProblem
First letter (a โ†’ same shard)Low โ€” only 26 childrenVery uneven: way more queries start with a than z.
First 3 letters (abc โ†’ same shard)High โ€” 26ยณ โ‰ˆ 17,000Still uneven (why โ‰ซ zxz); fix by manually grouping rare triples.
Q: Which existing databases support tries?

There's no popular database built for storing tries. Tries exist only as a 3rd-party extension in some databases, but none was built specifically for them. So with this approach you'd have to build your own DB โ€” which is exactly the pain that pushes us toward Approach 2.

Recap Shard a trie by the first letter (low cardinality, very uneven) or first 3 letters (โ‰ˆ17,000 buckets, finer but still lopsided). To balance load, manually group rare prefixes together while giving hot prefixes dedicated shards. Crucially, no off-the-shelf database stores tries, so you'd have to build your own โ€” motivating a simpler approach.

4 Approach 2 โ€” Hashmap / Key-Value


Explain like I'm 5

Forget the fancy tree. What if we just kept a giant cheat-sheet? On the left, every possible thing someone might have typed so far ("wha", "what", "what d"). On the right, the best few suggestions for that. When you type, we just look up your row and read the answer โ€” basically instant. That cheat-sheet is a hashmap (key โ†’ value).

The big idea โ€” augmentation IS a cache

As a DSA person, we'd build a trie and augment each node with its top-5. As an HLD person we realise we're effectively just caching the top-5 results for every possible prefix. So: prefix == TrieNode and data-augmentation == cache. We can drop the trie entirely and store this as a plain key-value cache!

Two databases

The design splits into two stores:

  • Search Frequency DB โ€” for each actual searchQuery, store its count (how many times it's been searched).
  • Top Suggestions DB (cache) โ€” for each possible prefix we'd show suggestions for, store the top-k suggestions.
Two stores: a Search Frequency DB and a Top Suggestions cache
Two key-value stores: a Search Frequency DB (query โ†’ count) and a Top Suggestions cache (prefix โ†’ top-k suggestions).

Search Frequency DB (query โ†’ count)

Search QueryCount
what is the color of the sky5000
what is the day today1000
what is 2 + 210000
what does the fox say2000
what does a fox eat?1900
how to kill someone2000
how to cook eggs1500
how to sing500

Top Suggestions DB (prefix โ†’ top k = 3)

For each prefix we store the best 3 completions, already sorted by count:

wha
[
  what is 2+2                  => 10000
  what is the color of the sky =>  5000
  what does the fox say        =>  2000
]

what
[
  what is 2+2                  => 10000
  what is the color of the sky =>  5000
  what does the fox say        =>  2000
]
...

what i
[
  what is 2+2                  => 10000
  what is the color of the sky =>  5000
  what is the day today        =>  1000
]

what d
[
  what does the fox say        =>  2000
  what does a fox eat?         =>  1900
]

how
[
  how to kill someone          =>  2000
  how to cook eggs             =>  1500
  how to sing                  =>   500
]
...

how to k
[
  how to kill someone          =>  2000
]

The read: typeahead(partial_query)

If someone types "what i", we just go to the suggestions DB and look up that key. This is very fast: hashmaps have O(1) lookup. (Technically it's O(l) in the key length l โ€” but keys in a hashmap should never be long; if your hashmap keys are long, you're using hashmaps wrong.) Here the average key length was assumed to be 10 chars and the max 50 chars. There's no computation or processing needed โ€” we just fetch the value for the given key.

Worked example โ€” sizing the cache with Redis

Our non-functional needs: ultra-low latency (< 10 ms) and high read throughput (10 million reads/second at peak).

  • A Redis lookup has latency ≤ 1 ms. โœ…
  • A single Redis server easily handles 100,000+ reads/writes per second.
  • So we need only ~100 Redis servers to absorb 10M reads/s โ€” trivial (Google had over 10 million servers as of 2020).
Sharding & databases for this approach

Sharding is now automatic โ€” based on hash(key). And unlike tries, there's a whole category of databases with first-class hashmap support. Q: Are there databases that have first-class support for hashmaps? Yes โ€” not just one, an entire category! Key-value databases are basically hashmaps distributed across servers: Redis / Memcached / DynamoDB / โ€ฆ

Recap Augmenting a trie is really just caching top-k per prefix, so store it as a key-value system: a Frequency DB (query โ†’ count) plus a Top Suggestions cache (prefix โ†’ top-k). Reads are O(1) and Redis-fast (~100 servers for 10M reads/s). Sharding is automatic via hash(key), and many real databases (Redis, Memcached, DynamoDB) support this out of the box.

6 Optimizing reads & writes


Explain like I'm 5

A library that lots of people read from is easy โ€” keep copies everywhere. A library where lots of people are constantly scribbling new notes is harder. A library where people both read tons AND scribble tons at the same time is a nightmare. The trick is to figure out which kind you have, and lean on a fast scratchpad (a cache) for the easy direction so the slow, careful book (the database) only does what it must.

Let's generalise beyond typeahead. How you optimize depends on whether the system is read-heavy, write-heavy, or both โ€” and on whether you can tolerate eventual consistency.

If the system is read-heavy (not write-heavy)

  • If eventual consistency is OK โ‡’ absorb the reads in the cache, and optimize the DB for writes.
  • If immediate consistency is needed โ‡’ either:
    • incur extra write latency with a write-through cache (your DB stays optimized for writes), or
    • optimize the DB for reads โ€” writes get slower (fine, there are few of them), and both reads & writes go to the DB. This is slower than the cached approach.

If the system is write-heavy (not read-heavy)

  • Regardless of eventual vs immediate consistency โ‡’ optimize the DB for writes.
  • If your reads are significant, absorb them in the cache; if not significant, let them hit the DB and be slow.
  • Your cache cannot handle the writes โ€” a write-back cache would lose data if the cache server crashes with unsynced changes.

If the system is both read AND write heavy

This is very VERY difficult โ€” and it's exactly our typeahead situation. Two escape routes:

  • Reduce the writes:
    • Batching โ€” forces eventual consistency: wait for a bunch of requests to collect, then process them all in one go.
    • Sampling โ€” forces data loss: don't process all the requests, process only some of them.
  • If you can't reduce writes, shard more. Sharding improves both reads & writes โ€” but then you can't join across shards efficiently, so any query needing data from multiple shards is slow.
Key takeaway

For typeahead we can afford eventual consistency and even some data loss โ€” these are part of our non-functional requirements (suggestions only need to be relevant, not perfectly ranked or perfectly up-to-date). That permission is exactly what unlocks batching and sampling to crush the write load.

Recap Read-heavy โ‡’ absorb reads in a cache (or optimize the DB for reads if you need immediate consistency). Write-heavy โ‡’ optimize the DB for writes; caches can't safely absorb writes. Both โ‡’ very hard: either reduce writes (batching = eventual consistency, sampling = data loss) or shard more (helps reads & writes, but kills cross-shard joins).

7 Reducing writes โ€” batching & sampling


Explain like I'm 5

If 10 guests want tea, you don't boil one tiny cup, run it over, come back, boil the next โ€” you make one big pot and serve everyone at once. That's batching. And if you want to know how a whole country voted, you don't ask all 1 crore people โ€” you ask a small random handful outside polling booths and trust the trend. That's sampling. Both let us do far less work while still getting an answer that's good enough.

Because typeahead tolerates eventual consistency and data loss, we can use both tricks to shrink the 11 million writes/second down to something tiny.

Batching / batch processing

Many small tasks collected and processed together as one batch
Batching: instead of handling each task the moment it arrives, let many pile up and process them together in one go.

Instead of doing each task one by one, you wait for a lot of tasks to pile up, then do all of them in one go as a batch. (Example: 10 guests need tea โ€” make individual cups one-by-one, or brew a whole pot at once and serve together. The pot is batching.)

For typeahead: whenever we get a log_search(search_query) request, instead of updating the suggestions DB immediately, we wait for the count to increase by a fixed amount (say 1000) before pushing the suggestions update.

Earlier (no batching)

fn log_search(search_query):         // 1 million qps
    frequency_db.inc(search_query)      // 1 million writes/s
    update_prefixes(search_query)

fn update_prefixes(search_query):
    for i = 3 ... len(search_query) - 1  // 10 iterations
        prefix = search_query[:i]
        ... logic to update the suggestions list
            in the suggestions db
        // 10 million writes/s

With batching

fn log_search(search_query):         // 1 million qps
    updated_count = frequency_db.inc(search_query)
    // 1 million writes/s

    if updated_count % 1000 == 0:
        // update the prefixes only if the count
        // has changed by the batch size
        update_prefixes(search_query)

fn update_prefixes(search_query):
    ...   // (1 million / 1000 qps) * 10 updates
          // 10,000 writes/sec

Effectively, we only update the suggestions when the count increases by 1000.

Worked example โ€” the write count collapses

Earlier: 1M writes/s (freq DB) + 10M writes/s (suggestions DB) = 11M writes/s.

After batching: 1M writes/s (freq DB) + (10M / 1000) = 1.01M writes/s.

We can't go below 1M/s โ€” that's how often we must at least update the counts in the frequency DB.

What batching costs you

Batching does not cause data loss โ€” the frequency data is always up-to-date. It only causes delays (stale reads) in the suggestions updates.

Sampling

Estimating an overall result from a small random sample, like an election exit poll
Sampling: ask only a small random slice of the population. An unbiased sample reflects the same trends as the whole.
Analogy โ€” election exit polls
  • A country-wide election has 1 crore voters; the actual counting takes several days.
  • News channels want to predict the winner with high accuracy before the real results are announced.
  • They can't talk to all 1 crore voters. Instead they stand in front of a small fraction of polling booths and, as people exit, ask a few random people "who did you vote for?".
  • Using just that small subset/sample they estimate the winner. If the channel is unbiased, the exit-poll result almost perfectly matches the actual election result.

If you draw an unbiased sample from a population, then any trends that hold within the population will also hold within the sample. Typically a good way to get unbiased samples is to just sample uniformly at random.

Here's the architecture before and after. The search service is what calls the typeahead service's log_search endpoint.

Search service calling the typeahead service's log_search endpoint on every search
Earlier: the search service calls log_search on every search, so the typeahead service updates counts and suggestions for all of them.

Earlier (every search logged)

// search service
fn search(query):
    ...
    make an API call to typeahead service's log_search endpoint

// typeahead service
fn log_search(search_query):
    frequency_db.inc(search_query)
    update_prefixes(search_query)

fn update_prefixes(search_query):
    for i = 3 ... len(search_query) - 1
        prefix = search_query[:i]
        ... logic to update the suggestions list
            in the suggestions db

With sampling

// search service
fn search(query):
    ...
    if rand() < 0.001:
        // with a probability of 0.1% make the following call
        make an API call to typeahead service's log_search endpoint

// typeahead service
    ...

For 99.9% of the searches, we don't even update the counts (we just ignore them). For a random 0.1% of searches, we call log_search and update both the counts and the suggestions. Thanks to sampling, instead of 1M + 10M writes/second, we're back to just (1M + 10M) / 1000 = 10,000 writes/second.

Won't the counts be inaccurate under sampling?

Yes โ€” each count is effectively divided by approximately 1000 (not exactly, since it's random). But it doesn't matter: the suggestions shown to users stay highly relevant.

Won't the suggestions be slightly out of order?

Yes. And again it doesn't matter โ€” relevance is preserved, which is all the feature needs.

With sampling, won't infrequent queries get tiny counts or be missed entirely?

Yes, that can happen โ€” and it's a feature, not a bug! A rare query would never be part of typeahead suggestions anyway (because it isn't popular). Sampling automatically prunes the majority (99.9%) of the "bad" queries.

What sampling costs you

Sampling can lose individual data points, but it won't lose the overall trends. Use it only when data loss is okay.

Recap Batching updates the suggestions DB only every Nth count (e.g. 1000), cutting 11M writes/s to ~1.01M writes/s โ€” costing stale reads but never data loss. Sampling only logs a random 0.1% of searches, cutting writes to ~10k/s โ€” costing data loss of individual points but preserving trends, and conveniently discarding rare queries that would never be suggested anyway.

8 Future scope โ€” recency, geo, personalization, typos


Explain like I'm 5

A good autocomplete doesn't just know what's popular forever โ€” it knows what's hot this week (recency), what people near you care about (location), what you personally search, and it forgives your typos. These extras turn a decent suggestion box into a great one.

Recency factor

Problem statement: when a big event happens โ€” COVID lockdown gets declared, election results are out, a high-profile person gets arrested, Federer wins Wimbledon โ€” the query becomes trending. Even though "why is the sky blue?" might have a higher overall count, a trending "what happened in Nepal?" (recent + popular) should rank higher when someone types "wh".

Approach 1 โ€” separate counts per window

For each query keep three counts: Total, Last Week, Last Day.

total:why is the sky blue?    => 5000
 week:why is the sky blue?    =>  200
  day:why is the sky blue?    =>   50
total:what happened in Nepal? =>  500
 week:what happened in Nepal? =>  300
  day:what happened in Nepal? =>  100

To find the top-k: find top-k from total, find top-k from weekly, find top-k from daily, then merge the results using a scoring function. Goal: give more weightage to the recent counts.

Better solution โ€” decay the historical counts

Simply decay the historical counts after every fixed period of time. For example, after each day, decrease the total count for each query by 10% (multiply by 0.9) before adding the day's new count.

Worked example โ€” a steady query converges

"who has control over nukes?" gets a steady 1000/day: [1000, 1000, 1000, โ€ฆ] โ€” the raw total would be 100,000, but with decay:

day 1: 1000
day 2: 90% of 1000 + 1000  => 1900
day 3: 90% of 1900 + 1000  => 2710
day 4: 90% of 2710 + 1000  => 3439
...
day 100: 1000 * (1 + 0.9 + 0.9^2 + 0.9^3 + ...)
       = 1000 * 1/(1 - 0.9)
       = 10,000

So a forever-steady query converges to a decayed score of 10,000 (not 100,000).

Worked example โ€” a fresh burst overtakes it

"who won the wimbledon" is silent, then bursts: [0, 0, โ€ฆ, 5000, 5000, 5000] โ€” raw total just 15,000.

day 97: 0
day 98: 5000
day 99: 5000 * 0.9 + 5000   =>  9,500
day 100: 9500 * 0.9 + 5000  => 13,550

Its decayed score (13,550) now beats the steady query's (10,000) โ€” recency wins. If a count after decay reduces below a threshold (say 0), we can remove that entry.

Geolocation-based personalization

  • Given the user's request, find out their location based on IP address.
  • Build a separate database for each location โ‡’ shard the DB by country_id. Now an Indian user's request only goes to the Indian shard.
"global: what does the fox say" => 1000
"India:  what does the fox say" =>   10
"USA:    what does the fox say" =>   10

When an Indian user types "wha", we find the suggestions for the "wha" prefix and for the "India:wha" prefix, merge the results, and return the final set.

User-based personalization

  • Do this purely on the client side: the browser stores the user's search/browsing history.
  • The browser creates typeahead suggestions from the user's local data.
  • The browser also initiates a backend request to get the global typeahead suggestions.
  • The browser merges these two lists.
  • Google actually pulls this from the backend too โ€” because it maintains your search & browsing history in its backend DB.

Handling typos

  • Whenever a search is made, apart from updating the count of search_query, also update the count of spell_corrected(search_query).
  • Whenever a user types something, find the suggestions for partial_query, but also include the suggestions for spell_corrected(partial_query).
def spell_corrected(input: str) -> str:
    """Corrects the spellings of the words within
       the input string, and returns the corrected
       string.
       Note:
           - each word in the input is spell corrected
           - gracefully handles partial words
    """

You can simply have a dictionary and calculate the edit-distance between the user's words and the words in the dictionary. Peter Norvig's classic essay "How to Write a Spelling Corrector" is the canonical reference for this.

Recap Make typeahead smarter with: recency (decay counts ~10%/day so trending queries overtake stale popular ones โ€” or keep separate total/week/day counts and merge), geolocation (shard by country, merge global + local prefixes), user personalization (merge client-side history with backend suggestions), and typo handling (also track/serve spell_corrected() via edit-distance against a dictionary).

โ˜… Putting it all together


You just designed a real, planet-scale autocomplete. Here's the one-paragraph story that connects every topic:

Typeahead must, on every keystroke, return the top queries that strictly start with the typed prefix. A trie can do this if we augment each node with its pre-computed top-5 โ€” but no database stores tries, and sharding it evenly is painful. So we realise the augmentation is just a cache and store everything as key-value: a Frequency DB (query โ†’ count) and a Top Suggestions cache (prefix โ†’ top-k), served from ~100 Redis servers at < 1 ms. The trouble is that every log_search touches ~11 keys, making the system both read- and write-heavy (10M reads/s + 11M writes/s) โ€” which no DB handles. Because typeahead tolerates eventual consistency and data loss, we crush the writes with batching (update only every 1000th count โ†’ ~1.01M writes/s, stale but lossless) and sampling (log a random 0.1% โ†’ ~10k writes/s, lossy but trend-preserving). Finally we make it smart with recency decay, geo-sharding, client-side personalization, and typo correction via edit-distance.

Quick self-check

Why is scanning a trie subtree on every keystroke a bad idea, and what fixes it?

A short prefix can have billions of entries in its subtree, so scanning it per keystroke is far too slow. The fix is data-augmentation: pre-compute and store the top-5 at every node, so a read just reads the node. The cost is that writes must propagate the new count up to the root.

Why move from a trie to a key-value store?

Augmenting a trie is really just caching top-k per prefix, so prefix == TrieNode and augmentation == cache. Unlike tries (no off-the-shelf DB supports them, sharding is uneven), key-value stores like Redis/Memcached/DynamoDB give O(1) lookups, automatic hash(key) sharding, and first-class support.

Why does log_search create such a heavy write load?

Each search bumps the count (1 write) and must update the cached top-k for every prefix of the query (~10 writes), so ~11 writes per search. At 1M searches/s that's ~11M writes/s โ€” combined with 10M reads/s, the system is both read- and write-heavy, which no single DB optimizes for.

Batching vs sampling โ€” what does each cost you?

Batching only updates suggestions every Nth count (e.g. 1000), giving ~1.01M writes/s; it causes stale reads but no data loss (counts stay exact). Sampling logs only a random 0.1% of searches, giving ~10k writes/s; it causes data loss of individual points but preserves overall trends (and usefully discards rare queries).

How do we make a trending query outrank a stale all-time-popular one?

Apply recency decay: each day multiply existing counts by 0.9 before adding the new day's count. A steady 1000/day query converges to 10,000, while a fresh burst (e.g. Wimbledon) can shoot past it (13,550) โ€” so recent popularity wins. Entries that decay below a threshold are removed.

๐Ÿ“š References & Further Reading


Class material

Papers, docs & deep dives