System Design Drill Day 16: Product Search, Ranking and Typeahead
How to build product search and autocomplete: inverted indexes, analyzers, BM25 ranking, keeping the index in sync and fast typeahead.

Someone types "mobil cover" into the search box, or "chamal", or a product name in Devanagari. A LIKE '%...%' query usually returns nothing useful for any of them. LIKE is fine when a Laravel project is new, but it stops being enough long before the catalogue gets big.
So the job is proper product search and typeahead for an online marketplace: typo tolerance, Nepali and romanized queries, fast price and stock sync, and a fallback for when the search engine is down.
Core concepts
Why LIKE '%phone%' breaks down
A leading wildcard can't use a normal B-tree index, so MySQL scans the whole table. LIKE also can't rank results by relevance or cope with typos.
The inverted index
It works like the index at the back of a textbook, where each word points to the pages it appears on. Here each word points to the ids of the products that contain it.
Before indexing, text goes through an analyzer that splits it into words, lowercases them, strips accents, reduces words to their stem ("shoes" becomes "shoe") and adds synonyms. A query intersects the id lists for its words and scores the matches with BM25, which weighs how often a word appears, how rare it is and how long the field is. Business signals such as sales, rating and stock status are then added as boosts.
Figure 1: Text goes through the analyzer once at index time. Queries then hit a word-to-ids map instead of scanning rows.
Engines
Elasticsearch and OpenSearch are powerful but heavy to run. Meilisearch and Typesense are simple and fast, with typo tolerance built in. MySQL FULLTEXT works as a starter but ranks poorly. Laravel Scout ships first-party drivers for Algolia, Meilisearch, Typesense and the database, which makes a later switch between those less painful. Elasticsearch and OpenSearch work with Scout only through community-maintained drivers.
The index is a copy
The index lags slightly behind MySQL, and MySQL remains the source of truth. Keep the two in sync with queued model events (mind the dual-write problem from Day 11), or with an outbox or change-data-capture (CDC) feed. Run a periodic full rebuild into a fresh index as well, and point an alias at it so the switch causes no downtime.
Typeahead is its own problem
Each keystroke gets a budget of roughly 50 to 100 ms. The usual approaches:
- a prefix tree (trie) that stores the top suggestions at each node,
- edge n-grams in the engine, where every prefix of a word gets indexed,
- completions precomputed for each prefix and stored in Redis.
On the client, wait 150 to 250 ms after typing stops (debounce), cancel the request still in flight, require at least 2 characters, and cache results per prefix.
Filters and facets
Filters such as brand, price range and category are applied before scoring.
Pause and think: Search shows a product at NPR 1,200 but the price changed to NPR 1,350 a few seconds ago. What does the customer pay at checkout?
Answer: The MySQL price. The index is a slightly stale copy used to find products. Product pages, the cart and checkout always re-read price and stock from MySQL.
The design problem
Design search for the marketplace's 2M products. It needs typo tolerance, queries in Nepali and romanized Nepali, and typeahead at around 3k queries per second.
Price and stock changes have to show up quickly, merchants run bulk catalogue imports, and there must be a plan for when the search engine is down.
Try the problem before reading the solution.
Questions to think about
- Why is the search index not the source of truth, and what does that mean for price and stock at checkout?
- How do you keep bulk imports from delaying price and stock updates?
- What happens to search and typeahead when the engine is down?
Model solution
Sync
Price and stock changes reach the index ahead of everything else, so stock changes appear within seconds.
Bulk imports
Imports go to a separate low-priority search-bulk queue with its own workers, sent in rate-limited chunks of 1 to 5k documents, so price and stock updates never wait behind them. A weekly full reindex with an alias swap corrects any drift.
Figure 2: Two lanes into the index, so a big import never delays a price change.
Search path
The API first checks a Redis cache for hot queries, keyed by the normalized query plus filters plus page, with a 30 to 60 second expiry. On a miss it queries the engine, with in-stock and category as filters and scoring by BM25 plus boosts for sales, rating and stock. Results return ids and display fields.
The index finds products, and MySQL supplies the truth. Product pages and the cart re-read MySQL, and checkout always takes price and stock from MySQL under the Day 8 lock.
Language
Rely on the engine's typo tolerance, which allows 1 to 2 wrong letters depending on word length. Keep a synonym list (mobile and phone, chamal and rice), and index both a Devanagari field and a romanized one, because plenty of people in Nepal type Nepali words in Latin script.
Typeahead
Search logs go to a Redis stream in the background. An hourly job counts queries and writes the top 10 completions for each prefix of 2 to 15 characters into ta:{prefix} in Redis, so each keystroke costs one Redis GET and 3k queries per second is easy. Rare prefixes with nothing stored fall back to a prefix search on titles in the engine. Keep a blocklist of bad terms, and have the client wait about 200 ms after typing stops.
Figure 3: Do the heavy work hourly so each keystroke is one Redis lookup.
Outage
Put a circuit breaker in front of the engine (Day 10). While it's open, fall back to MySQL FULLTEXT on titles with basic filters, or serve cached results, and show a "search is limited" banner. Typeahead serves only from Redis or shows nothing.
Answers to the questions
- The index is a copy that lags slightly behind MySQL, fed by queued events or an outbox. It's there to find products quickly. Product pages and the cart re-read MySQL, and checkout always takes price and stock from MySQL under the Day 8 lock, so a stale indexed price never reaches the customer's bill.
- Give them separate lanes. Price and stock changes go through a high-priority queue, while merchant imports go to a low-priority
search-bulkqueue with its own workers, in rate-limited chunks of 1 to 5k documents. A weekly full reindex with an alias swap corrects any drift. - A circuit breaker in front of the engine opens. Search falls back to MySQL FULLTEXT on titles with basic filters, or to cached results, and shows a "search is limited" banner. Typeahead serves only from the precomputed Redis prefixes, or shows nothing.
Bonus
An interviewer may ask why you can't just put Redis in front of MySQL LIKE. Caching only helps repeated queries; it does nothing for ranking, typo tolerance, or the full table scan on every cache miss.
Interview one-liner
"Use the index to find products and MySQL for the truth: an engine behind Scout kept in sync through queued events or an outbox, rebuilt with an alias swap, BM25 plus business boosts, synonyms for romanized Nepali, and precomputed prefixes in Redis for typeahead."
Conclusion
A search index is a fast, slightly stale copy, so never let checkout trust its price or stock. Give price and stock updates their own fast lane into the index, or a large merchant import will quietly leave search showing old numbers.
Try it yourself
Install Meilisearch or Typesense locally, connect it with Laravel Scout, and import a few thousand products. Search with a deliberate typo and compare the results with a LIKE '%...%' query in MySQL.
Then add a synonym (say "mobile" and "phone") and see how the ranking changes. As an extra, build the ta:{prefix} keys in Redis from a fake search log and time a single GET.
This is part of my daily System Design Drills series.
Comments
Join the discussionThe two lane sync idea is the part I will remember from this drill. Sending price and stock through a high priority queue while bulk imports wait in their own low priority lane explains the stale price example clearly. A search result at NPR 1,200 that is really NPR 1,350 at checkout is exactly the kind of bug that erodes trust. I also liked the typeahead math. Doing the heavy counting hourly and leaving each keystroke as one Redis GET makes the 50 to 100 ms budget feel achievable instead of aspirational.



