Search & Autocomplete — Trade-offs & Decisions
Every major architectural decision — alternatives considered, consequences accepted, and the reasoning behind each choice.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
| Decision | Default | Flip when |
|---|---|---|
| Database vs search index | Database-native search for small corpora and modest QPS | Relevance ranking, fuzzy matching, facets or QPS competing with transactional load justify a dedicated index |
| Freshness vs indexing cost | A domain-specific freshness window (seconds is a common assumption) | Tighten batching and refresh for live-inventory or ticketing cases and accept merge cost |
| Cache TTL | Chosen from query cardinality and staleness tolerance; 60 s is not a universal default | Shorten when staleness has product cost; lengthen for stable, highly repeated queries |
| Ranking quality vs latency | Retrieve then re-rank a bounded candidate set | Deepen the re-ranker only while latency stays inside the SLO |
| Autocomplete index | Depends on latency SLO, QPS and ranking needs | Dedicated index for consistent low latency and isolation; unified engine for less operational surface |
| Prefix structure | Trie/FST for compact, fast prefix lookup | Edge n-grams for simplicity and ranking flexibility, at the cost of a larger index |
| Change capture | Snapshot bootstrap plus incremental CDC or application events | Batch/poll when freshness lag and source read load are acceptable |
| Sharding | Hash by document ID for broad, unpredictable queries | Routed/domain sharding when many queries can supply a routing key |
| Pagination | Offset for shallow pages | Search-after with a tiebreaker for deep or stable paging |
| Caching | Layered: choose the level with reusable structure | No cache when the effective key is nearly unique per request |
| Shard failure | Product decision | Partial results (clearly marked) for general product search; fail the query where completeness is mandatory |