See It Flow
System Design · 11 decisions · ~14 min read

Search & Autocomplete — Trade-offs & Decisions

Every major architectural decision — alternatives considered, consequences accepted, and the reasoning behind each choice.

Mangesh MandlikLast updated:
Decision 01

Database vs Dedicated Search Index

Should search queries go to the source database directly, or to a dedicated search engine?

Database-native search

Relational databases are not limited to plain B-tree prefix lookups for text — many support full-text indexes, trigram indexes, and specialized extensions, alongside prefix indexes for suitable access patterns. For smaller corpora, modest search QPS, and simple relevance needs, database-native full-text search can be entirely reasonable — no separate system to run, no index-lag consistency model to reason about.

Dedicated search index

Becomes justified once requirements grow: sophisticated relevance ranking (BM25-style scoring, multi-signal ranking), high search QPS that would compete with transactional workload, fuzzy/typo matching, faceted aggregation, and the ability to scale search capacity independently of the transactional database.

Decision 02

Freshness vs Indexing Cost

Fresher search results cost more to produce. The knobs available go well beyond "index more often."

Refresh interval

How often buffered documents become a new searchable segment (see Architecture Deep Dive → Refresh, Segments and Visibility) — more frequent refresh means fresher results and more background merge overhead.

Batching

Processing indexing events in larger batches is more throughput-efficient per event but delays when any individual event's effect becomes visible.

Pipeline capacity

The indexing pipeline's own throughput ceiling — a pipeline sized for steady-state load will fall behind during bulk imports or event bursts, regardless of refresh interval.

Merge / resource cost

More aggressive refresh creates more, smaller segments, increasing background merge work — this competes for the same CPU/I/O capacity serving live queries.

Decision 03

Cache TTL vs Freshness

Caching search results reduces load dramatically but returns results as of cache-fill time — the right TTL is workload-dependent, not a fixed universal number.

What actually drives the right TTL

Query repetition/cardinality (how often the exact same cache key recurs), how heavily the workload is personalized or filtered, how fresh the underlying ranking/index needs to feel, and product tolerance for slightly stale results. A high-repetition, low-personalization workload can tolerate a much longer TTL than a highly filtered, highly personalized one.

Shorter TTLs

Fresher results, generally lower hit rate, more load reaching the index — reasonable for rapidly changing corpora or where staleness has real product cost.

Longer TTLs

Higher hit rate, staler results, less index load — reasonable for stable corpora and generic, highly-repeated queries.

Decision 04

Ranking Quality vs Latency

More ranking signals and larger candidate sets generally improve relevance — and cost more compute and latency.

The retrieve-then-rerank pattern (see Architecture Deep Dive → Ranking Pipeline) bounds re-ranking cost to a chosen candidate-set size, letting a team improve the re-ranker's sophistication without changing how retrieval itself works. But this bounds the re-ranking stage's cost specifically — it does not make the total query cost constant: retrieval itself still has variable cost depending on posting-list sizes and filters, and a larger chosen candidate set for re-ranking directly increases re-ranking cost.

Decision 05

Separate Autocomplete Index vs Unified Search

Should autocomplete be served by the same search engine handling full-text queries, or by a separate, purpose-built index/service?

Unified search engine

Less operational surface — one system to run and keep synchronized. Whether it reliably meets an aggressive autocomplete latency budget under load depends on the specific engine, its prefix-query implementation, and how much concurrent full-text query load it's also absorbing — not something to assume either way without knowing the real numbers.

Dedicated autocomplete index/service

Can be purpose-built for consistent low-latency prefix lookup, isolated from full-text query load entirely. Costs: a second system/index to maintain and keep synchronized from the same underlying data.

Decision 06

Trie/FST/Prefix Index vs Edge N-Grams

Given a dedicated autocomplete approach is warranted, how should prefix matching actually be implemented?

Trie / FST-like structure

Efficient prefix traversal, and can be compact and purpose-built for this exact access pattern if implemented well. Update cost (inserting new terms, adjusting precomputed top-k completions) and memory/operational complexity vary meaningfully by the specific implementation chosen — it is not automatically the cheapest or simplest option.

Edge n-grams / prefix terms

Simpler to reason about — prefixes are just indexed terms, reusable with standard inverted-index query machinery — at the cost of a larger index (every prefix of every term is a separate indexed entry) and correspondingly higher index-time write cost.

Decision 07

CDC vs Application Events vs Batch Reindex

How should source-of-truth changes actually reach the indexing pipeline? CDC is one legitimate approach among several, not the only valid one.

CDC (change data capture)

Reads committed database changes (via WAL/binlog or an equivalent mechanism) with low coupling to application code — the application doesn't need to know indexing exists. Adds real operational complexity: a CDC connector, a broker/log to buffer events, and ordering/offset-management semantics that depend on the specific connector and broker chosen — not an automatically zero-cost, zero-latency mechanism.

Application-emitted events

The application explicitly publishes a domain event ("ProductUpdated") when it makes a change, carrying rich domain semantics beyond a raw row diff. Requires the application to reliably publish these events (including under partial failure — the classic dual-write problem between the database write and the event publish) — producer correctness becomes a real dependency.

Batch / poll / full import

Periodically querying the source for changes, or re-importing a full snapshot on a schedule. Operationally simple for some datasets, but generally has higher freshness lag and adds direct read load to the source on each poll/import cycle.

Decision 08

Hash Sharding vs Routed/Domain Sharding

How documents are assigned to shards determines how much of the cluster a given query has to touch.

Hash / document-ID sharding

Produces balanced document distribution across shards with a simple, predictable assignment rule. The cost: since any document could be on any shard, a general keyword query typically must fan out to every shard to be sure it's found all matches.

Routed / domain sharding

Routes documents (and matching queries) by a meaningful key — tenant, category, region. Queries that can supply that key touch only the relevant shard(s), reducing fan-out and coordination cost meaningfully. The cost: risk of imbalance/hot shards if some tenants/categories are much larger than others, and added complexity for any query that must search across the routing dimension.

Decision 09

Offset Pagination vs Search-After/Cursor

How should users page through search results — simple page-number offsets, or a cursor carried forward between requests?

Offset pagination

Simple to implement and reason about for shallow pages. Cost grows with page depth in a scatter-gather system (every shard must compute results up to the requested offset, not just the page size), and results can shift or duplicate across pages if the underlying document set changes mid-pagination.

Search-after / cursor pagination

Carries forward the last result's stable sort values plus a deterministic tiebreaker, letting each shard resume from that position without recomputing from the start — cost stays roughly constant regardless of how deep the user pages. Cost: slightly more complex API contract (opaque cursor rather than a page number), and it doesn't by itself solve result-set changes mid-pagination without added snapshot/point-in-time semantics.

Decision 10

Cache Full Results vs Cache Components

"Add a Redis cache" is not a complete caching strategy — what actually gets cached, at what granularity, matters.

Full ranked-result cache

Caches the complete, final response for a specific cache key (query + filters + sort + locale + page + ranking version). Highest potential latency win on a hit, but only useful when that exact combination recurs — poor fit for highly personalized or highly filtered traffic.

Candidate cache

Caches the retrieval-stage candidate set (before final ranking/personalization), reusable across requests that share retrieval parameters even if final ranking differs — useful when personalization/re-ranking varies but the underlying candidate pool doesn't.

Filter / facet cache

Caches facet counts or filter-eligible document sets independently of the keyword query, since these can be expensive to recompute and may be reusable across many different keyword queries sharing the same filter.

Query-normalization cache

Caches the (potentially non-trivial) result of analyzing/normalizing a raw query string, reusable regardless of what filters or personalization apply on top.

No cache for high-cardinality cases

For traffic where the effective cache key is nearly unique per request (heavy personalization combined with many filter combinations), a cache may add complexity without a meaningful hit rate — sometimes the right answer is not caching that layer at all.

Decision 11

Fail Query vs Return Partial Results

When some shards are unreachable, should the system fail the whole query, or return whatever the reachable shards found?

Fail the whole query

Guarantees the user never sees an incomplete result silently presented as complete. Costs full availability during any partial shard outage — a single unreachable shard takes down the entire query, even though most of the corpus was reachable.

Return partial results

Preserves availability — users still get useful results from the shards that responded. Requires explicitly marking the response as partial/incomplete (a flag, a UI indicator, a monitored signal) so it is never silently mistaken for a complete result set.

Decision summary

DecisionDefaultFlip when
Database vs search indexDatabase-native search for small corpora and modest QPSRelevance ranking, fuzzy matching, facets or QPS competing with transactional load justify a dedicated index
Freshness vs indexing costA domain-specific freshness window (seconds is a common assumption)Tighten batching and refresh for live-inventory or ticketing cases and accept merge cost
Cache TTLChosen from query cardinality and staleness tolerance; 60 s is not a universal defaultShorten when staleness has product cost; lengthen for stable, highly repeated queries
Ranking quality vs latencyRetrieve then re-rank a bounded candidate setDeepen the re-ranker only while latency stays inside the SLO
Autocomplete indexDepends on latency SLO, QPS and ranking needsDedicated index for consistent low latency and isolation; unified engine for less operational surface
Prefix structureTrie/FST for compact, fast prefix lookupEdge n-grams for simplicity and ranking flexibility, at the cost of a larger index
Change captureSnapshot bootstrap plus incremental CDC or application eventsBatch/poll when freshness lag and source read load are acceptable
ShardingHash by document ID for broad, unpredictable queriesRouted/domain sharding when many queries can supply a routing key
PaginationOffset for shallow pagesSearch-after with a tiebreaker for deep or stable paging
CachingLayered: choose the level with reusable structureNo cache when the effective key is nearly unique per request
Shard failureProduct decisionPartial results (clearly marked) for general product search; fail the query where completeness is mandatory
Defaults and flip conditions drawn from the eleven decisions above