Autocomplete and Suggestion Systems
Autocomplete looks simple because the interface is small: a user types a few characters and the system returns a short list of likely completions. In production, however, autocomplete is a latency-sensitive search problem with very different requirements from ordinary full-text search.
A good suggestion system must respond within tens of milliseconds, handle incomplete prefixes, rank popular and contextually useful candidates, avoid stale or low-quality suggestions, and remain efficient under extremely high request rates.
The core design principle is to treat autocomplete as a specialized retrieval system rather than as a normal search request executed on every keystroke.
Table of Contents
- Why Autocomplete Is Different
- Generating Suggestion Candidates
- Ranking Suggestions
- Query Suggestions from Search Logs
- Context and Personalization
- Designing for Very Low Latency
- Keeping Suggestions Fresh
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
Why Autocomplete Is Different
Normal search begins after the user submits a complete query. Autocomplete runs while the query is still being constructed.
User types:
"p"
"po"
"pos"
"post"
"postg"
"postgr"
"postgres"
One short typing session can therefore generate many requests.
If every keystroke triggered a normal full-text search:
1 user
x 8 keystrokes
= 8 search requests
10,000 active users
x several keystrokes/sec
= very high request volume
Autocomplete also has a much tighter latency budget because users directly perceive delays between keystrokes and suggestions.
A useful target might look like:
p50: < 20 ms
p95: < 50 ms
p99: < 100 ms
The exact numbers depend on the product, but the general requirement is clear: autocomplete must be small, bounded, and extremely fast.
It should usually return only a handful of candidates:
input:
"postg"
results:
PostgreSQL
PostgreSQL replication
PostgreSQL indexing
PostgreSQL performance
PostgreSQL partitioning
This differs from a normal search result page, where dozens or hundreds of documents may be considered and ranked.
Generating Suggestion Candidates
The first problem is efficiently retrieving phrases or entities that begin with a partially typed prefix.
Several indexing strategies can support this.
Prefix Indexes
The simplest conceptual approach is to precompute prefixes.
For the term:
"postgresql"
the system could index:
p
po
pos
post
postg
postgr
postgre
postgres
postgresq
postgresql
The query:
"postg"
can then perform an exact lookup against the prefix structure instead of scanning all possible strings.
This produces very fast retrieval but can increase index size substantially because a single suggestion generates many prefix entries.
For a candidate of length n, the system may create approximately n prefix entries.
"database"
length = 8
prefixes:
d
da
dat
data
datab
databa
databas
database
Large suggestion vocabularies therefore require careful control over which prefixes are stored.
Edge N-Grams
Search engines commonly implement prefix matching using edge n-grams.
For:
"distributed"
an edge n-gram analyzer might generate:
dis
dist
distr
distri
distrib
distribu
distribut
distribute
distributed
The index may deliberately skip extremely short prefixes such as one-character inputs because they match too many candidates.
min_gram = 3
max_gram = 15
This provides an important trade-off:
smaller min_gram
|
+--> suggestions appear earlier
+--> larger index
+--> more candidate matches
larger min_gram
|
+--> smaller index
+--> less query fan-out
+--> suggestions appear later
The autocomplete index should therefore reflect actual user behavior rather than trying to support every theoretical prefix.
Trie-Based Retrieval
A trie stores strings according to shared prefixes.
For the words:
car
card
care
cat
a simplified trie looks like:
c
|
a
/ \
r t
/ \
d e
Searching for the prefix ca navigates directly to the corresponding node and explores descendants from there.
Conceptually:
prefix = "ca"
find node("ca")
|
v
descendants
|
+--> car
+--> card
+--> care
+--> cat
Trie-like structures can provide excellent prefix performance and are especially useful when the suggestion vocabulary is relatively stable and can be kept efficiently in memory.
The trade-off is memory usage. A naïve trie can consume substantial space, so production implementations often use compressed variants or specialized finite-state structures.
Ranking Suggestions
Prefix matching only determines which candidates are possible. The system still needs to decide which suggestions should appear first.
For the prefix:
"java"
possible suggestions might include:
java
javascript
java hashmap
java virtual machine
java spring boot
javascript array
java interview questions
A ranking function may combine several signals:
score =
prefix_match_quality +
popularity +
recent_trend +
query_success +
contextual_relevance
Popularity is often an important baseline.
Suppose historical query frequency is:
"javascript" 4,800,000
"java" 3,900,000
"java spring boot" 850,000
"java hashmap" 620,000
"java virtual machine" 310,000
A frequency-based system might return:
ja
|
+--> javascript
+--> java
+--> java spring boot
+--> java hashmap
However, raw popularity should not be the only ranking signal. Historical popularity can preserve outdated or low-quality queries indefinitely.
A more robust score might use:
score =
0.50 * popularity +
0.20 * recent_frequency +
0.15 * click_success +
0.10 * conversion +
0.05 * freshness
Ranking should also consider prefix quality. A suggestion beginning exactly with the typed query generally deserves stronger treatment than one matching only after aggressive normalization.
The broader ranking concepts are covered in Search Relevance and Ranking Strategies.
Query Suggestions from Search Logs
Autocomplete candidates do not have to come directly from indexed products or documents. Many systems build suggestions from historical queries.
Suppose the search logs contain:
"postgresql replication" 18,420 searches
"postgresql indexing" 15,930 searches
"postgresql performance" 12,100 searches
"postgresql partitioning" 8,240 searches
"postgresql vacuum" 6,820 searches
The system can periodically aggregate these queries:
Search Logs
|
v
Aggregation Job
|
+--> frequency
+--> recency
+--> clicks
+--> conversions
|
v
Suggestion Dataset
|
v
Autocomplete Index
This approach can produce suggestions that reflect real user language instead of only document titles.
However, raw search logs should not automatically become public suggestions.
They may contain:
- personally identifying information;
- spam;
- offensive queries;
- random identifiers;
- URLs;
- low-frequency noise;
- sensitive terms.
A production pipeline should filter and normalize candidates before publication.
Raw Queries
|
v
Normalization
|
v
Frequency Threshold
|
v
Safety / Quality Filtering
|
v
Scoring
|
v
Published Suggestions
Low-frequency queries can be excluded:
if query_count < MIN_OCCURRENCES:
exclude()
This improves quality while reducing the size of the suggestion index.
Context and Personalization
Global popularity is a good default, but the most useful completion may depend on context.
Consider the prefix:
"app"
Possible suggestions could include:
apple
application architecture
application load balancer
app store
apple watch
A backend engineer and an online shopper may reasonably expect different ranking.
Contextual signals may include:
language
country
category
current page
recent searches
product type
organization
device
session behavior
For an e-commerce category page:
category = "laptops"
prefix = "mac"
global suggestions:
mac
macbook
mac address
mac cosmetics
category-aware suggestions:
macbook
macbook air
macbook pro
Context can improve relevance without requiring deep user personalization.
Personalized autocomplete may incorporate recent history:
global:
"postgresql"
"postgresql tutorial"
"postgresql download"
recent user searches:
"postgresql replication"
"postgresql failover"
personalized:
"postgresql replication"
"postgresql failover"
"postgresql tutorial"
However, personalization reduces cache reuse because responses are no longer identical for all users.
A practical design often combines a cached global result with a small personalized layer:
Global top suggestions
|
+--> cached
|
v
Merge
^
|
Recent user suggestions
|
+--> small personalized set
This keeps most of the retrieval path highly cacheable while still providing useful personalization.
Designing for Very Low Latency
Autocomplete is one of the highest-frequency search endpoints, so small inefficiencies multiply quickly.
The client should avoid sending a request for every raw keyboard event.
Debouncing can reduce request volume:
Typing:
p
po
pos
post
postg
Without debounce:
5 requests
With short debounce:
1-2 requests
The client can also wait until a minimum prefix length is reached:
if len(query) < 2:
return []
On the server side, the query path should remain minimal:
Client
|
v
Autocomplete API
|
v
Cache
|
v
Suggestion Index
|
v
Top 8
Avoid downstream service fan-out:
Bad:
Autocomplete
|
+--> Search
+--> Product Service
+--> User Service
+--> Analytics
+--> Inventory
Better:
Autocomplete
|
+--> dedicated suggestion index
The suggestion index should normally contain everything required to render the result:
{
"text": "postgresql replication",
"type": "query",
"score": 0.94
}
or for entity suggestions:
{
"text": "PostgreSQL",
"type": "technology",
"id": 284,
"slug": "postgresql"
}
This avoids expensive enrichment during the typing path.
Caching is especially effective for popular prefixes because many users generate the same requests:
"iph"
"ipho"
"iphone"
"post"
"postg"
"postgr"
A cache key can remain simple:
autocomplete:{locale}:{prefix}
Cache effectiveness, request coalescing, and hot-key behavior should be considered using the same principles described in Designing Multi-Level Caching Architectures.
Keeping Suggestions Fresh
Autocomplete data often needs different freshness guarantees from the main search index.
Some candidates change slowly:
countries
brands
product categories
technology names
Others change quickly:
trending queries
breaking news topics
new products
popular searches
events
A common architecture combines offline and near-real-time updates:
Historical Search Logs
|
v
Batch Aggregation
|
v
Base Suggestion Index
|
+------------------+
|
Recent Query Stream |
| |
v |
Real-Time Aggregation |
| |
+---------> Merge -+
|
v
Autocomplete API
The base dataset provides stable popularity signals, while a smaller real-time layer captures emerging trends.
A ranking formula can combine both:
score =
0.70 * historical_score +
0.30 * recent_score
Time decay prevents old popularity from dominating indefinitely:
trend_score =
frequency * exp(-lambda * age)
For products, updates may come from the same event pipeline that maintains the main search index:
Product Database
|
v
Change Stream
|
+--> Main Search Indexer
|
+--> Suggestion Indexer
This allows the two read models to evolve independently while sharing the same authoritative source.
Production Design Example
Consider an e-commerce platform with 80 million products and several million searches per day.
The main search system is optimized for full queries, while autocomplete uses a separate suggestion index.
DATA PIPELINE
Product Catalog
|
v
Change Stream
|
+---------------------+
| |
v v
Search Indexer Suggestion Builder
| |
v v
Main Search Index Product Suggestions
Search Logs
|
v
Query Aggregation
|
v
Query Suggestions
|
+-----------+
|
v
Suggestion Index
The read path is intentionally short:
Browser
|
| prefix = "wirel"
v
Autocomplete API
|
v
Cache
|
| miss
v
Suggestion Index
|
v
Top 8 suggestions
The index might contain both queries and entities:
{
"text": "wireless headphones",
"type": "query",
"popularity": 0.96,
"recent_score": 0.82
}
{
"text": "Wireless Noise Cancelling Headphones",
"type": "product",
"product_id": 1042,
"popularity": 0.91,
"available": true
}
The prefix wirel may produce 300 candidates.
Prefix lookup
|
v
300 candidates
|
v
Availability / quality filters
|
v
120 candidates
|
v
Fast ranking
|
v
Top 8
A ranking function might look like:
score =
0.45 * popularity +
0.25 * prefix_quality +
0.15 * recent_score +
0.10 * conversion_score +
0.05 * contextual_score
Popular prefixes are cached:
autocomplete:en-US:wirel
autocomplete:en-US:iph
autocomplete:en-US:lap
The client applies a short debounce and ignores responses belonging to older requests.
request 1: "post"
request 2: "postg"
request 3: "postgr"
response 1 arrives last
discard response 1
because current input = "postgr"
This prevents slow earlier requests from replacing newer suggestions in the interface.
The architecture scales well because the most frequent interaction uses a compact read model, small responses, bounded candidate sets, and highly reusable cache entries.
Common Mistakes
Running Full Search on Every Keystroke
Reusing the normal search endpoint for autocomplete may appear simpler, but it often performs unnecessary work.
Autocomplete needs:
8 short suggestions
Full search performs:
large candidate retrieval
facets
aggregations
advanced ranking
result enrichment
That cost is multiplied across every keystroke.
A dedicated suggestion path is usually smaller, faster, and easier to scale independently.
Ranking Only by Popularity
Popularity can produce good initial rankings, but it can also create stale feedback loops.
popular query
|
v
shown more often
|
v
clicked more often
|
v
becomes even more popular
Without freshness and quality controls, outdated suggestions can remain permanently dominant.
Popularity should be balanced with recency, match quality, success metrics, and domain-specific signals.
Searching Very Short Prefixes Aggressively
A one-character prefix can match an enormous percentage of the suggestion vocabulary.
"a"
|
v
hundreds of thousands
or millions of candidates
It also provides very little information about user intent.
Waiting until two or three characters are available can significantly reduce load while usually having little impact on usability.
Indexing Unbounded Suggestion Candidates
Building suggestions from every historical query can create a huge index dominated by low-value data.
200 million raw queries
after normalization:
80 million unique strings
after frequency threshold:
3 million useful candidates
Frequency thresholds, normalization, expiration, and quality filtering can dramatically reduce index size while improving result quality.
Production Checklist
- Use a dedicated autocomplete path when suggestion traffic becomes significant.
- Choose prefix, edge n-gram, trie, or specialized completion structures based on workload.
- Avoid indexing unnecessarily short prefixes.
- Keep suggestion payloads small and self-contained.
- Return only a small bounded number of suggestions.
- Use client-side debouncing to reduce request volume.
- Discard stale responses when newer input has already been submitted.
- Cache popular prefixes when response personalization allows it.
- Combine popularity with freshness and quality signals.
- Filter low-frequency and low-quality historical queries before indexing them.
- Keep authorization-sensitive information out of public suggestion indexes.
- Use contextual ranking only where it produces measurable value.
- Monitor p95 and p99 autocomplete latency separately from normal search.
- Track suggestion click-through, acceptance rate, and zero-suggestion rate.
Conclusion
Autocomplete is a specialized search workload built around incomplete prefixes, extremely high request frequency, and very small latency budgets. Efficient systems avoid running the full search stack for every keystroke and instead maintain compact data structures optimized specifically for prefix retrieval.
Candidate generation may use edge n-grams, prefix indexes, tries, or dedicated completion structures. Ranking then combines match quality with popularity, recent trends, successful historical queries, and limited contextual signals.
The best production designs keep the request path short, aggressively bound candidate counts, cache common prefixes, filter noisy historical queries, and update suggestions independently from the main search experience when necessary.
Key Takeaway: Autocomplete should be optimized as its own read path. Precompute useful candidates, retrieve them through prefix-oriented indexes, rank only a small set, and keep the entire interaction fast enough to run repeatedly while the user is still typing.
Comments (0)