All lessons
E-commerceIntermediate10 min readIn my stack

Product search

From LIKE queries to search engines: typo tolerance, facets, synonyms, ranking and keeping the index fresh.

  • Algolia
  • Scout
  • Elasticsearch
  • Meilisearch
  • Typo tolerance
Step 1/5
A search engine keeps an inverted index: like the index at the back of a book, every word points to the documents that contain it.

In 30 seconds

Search is often the fastest path to a sale: shoppers who search usually know what they want. A simple database LIKE query works for a few hundred products, but real shops need typo tolerance, filters with counts, synonyms and smart ranking. That is what full-text search and dedicated search engines provide — at the cost of a second copy of your data that must stay in sync.

Why I use it

  • I use a dedicated search engine because I built product search for a pet-supplies shop on Algolia through Laravel Scout, covering products, variants, categories, brands and posts.
  • Customers misspell, use different words and expect instant results while typing. A plain database query cannot do that well.
  • Search logs are a goldmine: they show what people want and which searches return nothing.
  • Search touches catalogue, stock, promotions and performance, so it is a good test of whether a platform's data flow is clean.

How it works

01Three levels: LIKE, full-text, search engine

The first level is a database LIKE query: WHERE name LIKE '%dog food%'. It is easy, but a leading wildcard cannot use a normal index, so it scans the table, and it has no idea about typos, word order or relevance. The second level is database full-text search: MySQL FULLTEXT indexes or PostgreSQL tsvector with GIN indexes. They split text into words, handle stemming ("running" matches "run"), and rank by relevance — good enough for blogs and small catalogues, with no extra service. PostgreSQL's pg_trgm extension adds fuzzy matching. The third level is a dedicated search engine, which builds an inverted index optimised for instant, typo-tolerant, faceted search with configurable ranking. Move up a level only when the previous one stops serving users.

sql
-- PostgreSQL full-text search with ranking
ALTER TABLE products ADD COLUMN search tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'B')
  ) STORED;
 
CREATE INDEX products_search_idx ON products USING GIN (search);
 
SELECT id, name, ts_rank(search, query) AS rank
FROM products, websearch_to_tsquery('english', 'dry dog food') AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;

02How a search engine works

A search engine keeps an inverted index: like the index at the back of a book, it maps each word to the list of documents containing it. When you index a product, the engine tokenises the text (splits it into words), normalises it (lowercase, removes accents), and may apply stemming or language rules. At query time it looks up each word, allows a small number of typos — usually one for short words and two for longer ones, measured as edit distance — matches prefixes so results appear while typing, and ranks results. Ranking typically combines textual relevance (how many words match, how close, in which field) with business signals you configure, such as popularity, stock or margin. Facets are counts per filter value ("Brand: 12, Size: 5"), computed alongside the results.

03Choosing an engine

Algolia is a hosted service with excellent speed, typo tolerance, merchandising tools and analytics; you pay per search requests and records, which can grow expensive. Meilisearch and Typesense are open-source engines you can self-host on a small server or use as cloud services; both are fast, typo-tolerant and simple to configure, ideal for most shops. Elasticsearch and OpenSearch are powerful general-purpose engines for huge catalogues, complex queries, aggregations and logs, but they need more memory and operational expertise. In Laravel, Scout gives one API over Algolia, Meilisearch, Typesense and a database driver, so you can start simple and switch engines later.

  • Small catalogue, no budget for another service: database full-text.
  • Most shops: Meilisearch or Typesense, self-hosted or cloud.
  • Merchandising and analytics with no ops: Algolia.
  • Huge data, complex aggregations or an existing cluster: Elasticsearch / OpenSearch.

04Designing the index

A search index is not a copy of your database; it is a flattened document designed for one screen. For a shop, decide what a result is: usually a product, with its variants' attributes merged in (all colours and sizes) so filters work, and the lowest available price for sorting. Include only what search needs: name, brand, category path, attributes, keywords, price, stock flag, popularity and the image URL. Declare which fields are searchable (in priority order), which are filterable (facets), and which are sortable. Use synonyms for words customers use differently ("kitten" and "cat", "nail polish" and "lacquer"), and stop words for noise. Separate indexes for categories, brands and articles allow a rich dropdown of suggestions.

php
// Laravel Scout: what goes into the search document
class Product extends Model
{
    use \Laravel\Scout\Searchable;
    public function toSearchableArray(): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'brand'      => $this->brand?->name,
            'categories' => $this->categories->pluck('name')->all(),
            'colours'    => $this->variants->pluck('colour')->filter()->unique()->values()->all(),
            'price_min'  => $this->variants->min('price'),       // integer minor units
            'in_stock'   => $this->variants->contains(fn ($v) => $v->available > 0),
            'popularity' => $this->sales_last_30_days,
        ];
    }
 
    public function shouldBeSearchable(): bool { return $this->is_active; }
}

05Keeping the index in sync

The index is a second copy of your data, so it can go stale. Update it when data changes: Scout does this automatically on model save and delete, but send the work to a queue so saving a product never waits for the search service. Watch out for changes that bypass model events — bulk SQL updates, ERP imports, stock changes in another table, or a brand rename that affects thousands of products — and trigger reindexing for the affected records explicitly. Schedule a full reindex regularly, ideally into a new index and then swap it with an alias, so users never see a half-built index. Stock and price change often; consider updating just those fields in batches rather than whole documents.

  • Queue index updates; batch them for bulk imports.
  • Rebuild into a new index and swap atomically.
  • Alert when the number of indexed documents differs a lot from the database.

06Ranking, analytics and production tips

Good ranking is tuned, not guessed. Start with the engine's defaults, set searchable fields in order (name before description), and add a custom ranking signal such as popularity or sales. Push out-of-stock items down rather than hiding them, unless the business prefers otherwise. Log searches: the query, number of results and clicks. The list of zero-result searches shows missing synonyms, products and spelling issues; popular searches can power suggestions. Protect the search endpoint with rate limiting, and never expose admin API keys to the browser — use search-only keys, ideally scoped or time-limited. Debounce search-as-you-type on the client to about 150–300 ms, and cache popular queries.

Use it when

  • Catalogues of thousands of products, or any shop where customers search more than they browse.
  • You need typo tolerance, instant results, facets with counts and merchandising control.
  • Content spans several types — products, categories, brands, articles — in one search box.

Skip it when

  • A few dozen or hundred items: database full-text search, or even LIKE, is enough.
  • When the team cannot operate or pay for another service and the database meets current needs.
  • Do not use the search engine as your primary database or source of truth.

Alternatives

  • Database full-text (MySQL FULLTEXT, PostgreSQL tsvector + pg_trgm)

    No extra service, transactional consistency; weaker typo tolerance and facets.

  • Meilisearch / Typesense

    Open-source, fast, typo-tolerant, easy to self-host; great default for shops.

  • Algolia

    Hosted, very fast, strong merchandising and analytics; usage-based pricing.

  • Elasticsearch / OpenSearch

    Most powerful and flexible; heavier to run and tune.

Best practices

  1. Start with the simplest level that works and move up only when users need it.
  2. Design index documents for the results screen: flattened, minimal, with explicit searchable, filterable and sortable fields.
  3. Queue index updates and handle bulk changes that bypass model events.
  4. Rebuild indexes in the background and swap them atomically.
  5. Maintain synonyms and review zero-result searches regularly.
  6. Use search-only API keys in the browser and rate-limit search endpoints.
  7. Combine text relevance with business signals like popularity and stock.

Common mistakes

  • Using LIKE '%term%' on a large table and wondering why search is slow.
  • Indexing whole database rows, including private fields, into a public index.
  • Forgetting to reindex after bulk imports, so search shows old prices or deleted products.
  • Exposing an admin API key in frontend code.
  • Never looking at search logs, missing the easiest conversion wins.

Deep dive

07Relevance internals: BM25 and ranking rules

General-purpose engines such as Elasticsearch and OpenSearch score documents with BM25 by default. In plain words, a document scores higher when it contains the query terms often (with diminishing returns, so repeating a word twenty times does not help much), when the terms are rare across the whole index ("merino" counts more than "the"), and when the field is short (a match in a five-word title beats one in a long description). Field boosts multiply that score. Engines built for instant search, such as Meilisearch, Typesense and Algolia, instead use an ordered list of ranking rules or tie-breakers: number of matched words, typos, proximity, attribute order, exactness, then your custom ranking. Understanding which model your engine uses tells you what to tune: boosts and functions in BM25 engines, rule order and custom attributes in the others.

08Zero-downtime reindexing with aliases and swaps

Changing index settings — new searchable attributes, a different tokeniser, a new language — often requires rebuilding the whole index. Never do that in place, or users see half-filled results for minutes. Build a new index next to the live one, fill it in batches, check document counts and run a few smoke queries, then switch traffic in one atomic step. Elasticsearch and OpenSearch do this with aliases: the application always queries the alias "products", and an alias update moves it from products_v1 to products_v2. Typesense has collection aliases that work the same way. Meilisearch offers an index swap operation that exchanges the contents of two indexes atomically. Keep the old index for a short time so you can switch back instantly if something looks wrong.

bash
# Meilisearch: swap the freshly built index with the live one
curl -X POST 'http://localhost:7700/swap-indexes' \
  -H 'Authorization: Bearer MEILI_ADMIN_KEY' \
  -H 'Content-Type: application/json' \
  --data '[{ "indexes": ["products", "products_new"] }]'
 
# Elasticsearch / OpenSearch: move an alias atomically
curl -X POST 'http://localhost:9200/_aliases' -H 'Content-Type: application/json' -d '{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add":    { "index": "products_v2", "alias": "products" } }
  ]
}'

09Secure search keys and per-user filtering

Browser-side search is fast because the client calls the engine directly, but that means a key is visible to anyone. Use a search-only key, never the admin key. When results must differ per user — B2B price lists, private catalogues, a tenant's own data — do not rely on the client to add a filter, because it can simply remove it. Instead the server generates a restricted key that embeds the filter and an expiry: Algolia calls these secured API keys, Typesense scoped API keys, and Meilisearch tenant tokens, which are signed JWTs. The engine enforces the embedded filter on every query. Also index only public fields; anything in the index can be read by a determined user. Rate-limit by key or IP, and set a short expiry so leaked keys stop working quickly.

ts
// Meilisearch tenant token: the filter is enforced by the engine
import { generateTenantToken } from "meilisearch/token";
 
export async function searchTokenFor(customer: { priceListId: number }) {
  return generateTenantToken({
    apiKey: process.env.MEILI_SEARCH_KEY!,       // a search-only key
    apiKeyUid: process.env.MEILI_SEARCH_KEY_UID!,
    searchRules: { products: { filter: `price_list = ${customer.priceListId}` } },
    expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
  });
}

10Multilingual search and language edge cases

Search quality depends on how text is split and normalised, and that is language-specific. English stemming maps "boots" to "boot"; Russian and Armenian have rich word endings that need language-aware processing or prefix matching; Chinese and Japanese need segmentation because words are not separated by spaces. Mixed scripts appear too: customers type brand names in Latin script, or transliterate local words. Practical approach: store each language in its own field or index, configure the engine's language or locale settings explicitly rather than relying on detection, add synonyms between transliterations ("krem" and the Cyrillic spelling of cream), and normalise accents. Test with real queries from logs in each language, because a configuration that works well in English can quietly fail in another language.

  • One searchable field per language, such as name_en and name_ru.
  • Query the index for the user's interface language first.
  • Treat SKUs and barcodes as exact-match fields with typo tolerance off.

Real-world scenarios

01

Searches for product codes return nonsense

Problem

Staff and B2B customers search by SKU or barcode, such as AB-1042, but results show products with similar names instead, and the exact product is often on page two.

How to solve it

Typo tolerance and tokenisation are working against you: the engine splits the code into pieces and allows a typo, so AB-1043 also matches. Index codes in a dedicated field, mark it as exact or disable typo tolerance on it (most engines support disabling typos per attribute), and place it first in the searchable attributes so an exact code match wins. Detect code-shaped queries in the application with a regex and run an exact filter query first, falling back to normal search. Add both the raw code and a normalised version without dashes and spaces, so AB1042 and AB-1042 both work.

02

Search shows products that were deleted yesterday

Problem

Customers click search results that lead to 404 pages. The catalogue was cleaned up by a bulk SQL script that set is_active to false for 3,000 products.

How to solve it

The bulk update bypassed model events, so the search index never heard about it. Fix the immediate issue by reindexing the affected products, or run a full import into a new index and swap it in. Then close the gap: perform bulk changes through code that dispatches reindex jobs for the affected IDs, or run Scout's searchable or unsearchable methods on query builders in chunks. Add a nightly drift check comparing the number and IDs of active products in the database with the index, alerting above a small threshold. As a last line of defence, the product page should redirect inactive products to their category instead of returning a bare 404.

03

Algolia costs are growing faster than sales

Problem

The monthly search bill doubled after launching search-as-you-type in the mobile app, while orders grew only 10%.

How to solve it

Each keystroke is a billable search. Debounce input to about 200 ms, require at least two characters, and cancel in-flight requests when the user keeps typing. Cache identical queries on the client and at your edge for a short time, and serve popular empty-state suggestions from a precomputed list instead of a query. Check for unintended traffic: bots, prefetching and duplicate requests from re-renders. If costs remain high relative to value, evaluate a self-hosted Meilisearch or Typesense; with Laravel Scout behind an interface, migrating is mostly configuration plus re-tuning ranking, and you can compare quality using logged queries.

Cheat sheet

Import all records into the index (Scout)
php artisan scout:import "App\Models\Product"
Remove all records from the index (Scout)
php artisan scout:flush "App\Models\Product"
Push index settings from config (Scout)
php artisan scout:sync-index-settings
Queue Scout updates
SCOUT_QUEUE=true # in .env; index writes go through the queue
Reindex after a bulk change (Scout)
Product::where('brand_id', $id)->searchable();
Swap indexes (Meilisearch)
POST /swap-indexes [{ "indexes": ["products", "products_new"] }]
Point an alias to a new collection (Typesense)
PUT /aliases/products { "collection_name": "products_v2" }
Fuzzy match in PostgreSQL
CREATE EXTENSION pg_trgm; CREATE INDEX ON products USING GIN (name gin_trgm_ops);
Debounce search input
setTimeout(() => search(q), 200) // clear the previous timer on each keystroke

In my real projects

  • On a pet-supplies platform I built search on Algolia through Laravel Scout, indexing products, variants, categories, brands and posts, with suggestions, popular searches, synonyms sync, search logs and rate limiting.
  • The same platform's analytics include search reports, and ranking and association jobs power recommendations, "buy again" and "recently viewed".

Questions & answers

Q1Why not just use LIKE for search?

LIKE with a leading wildcard cannot use a normal B-tree index, so the database scans every row, which gets slow as the catalogue grows. More importantly, it has no understanding of language: no typo tolerance, no stemming, no relevance ranking, no synonyms and no facets. It is fine for an admin filter on a small table. For customer-facing search, database full-text search is the next step, and a dedicated engine when you need instant, typo-tolerant, faceted results.

Q2What is an inverted index?

It is the core data structure of search engines. Instead of storing documents and scanning them, the engine stores a map from each term to the list of documents containing it, often with positions and frequencies. A query looks up each term's list and intersects or merges them, which is extremely fast. Text is tokenised and normalised before indexing, so different forms of a word map to the same term. Typo tolerance and prefix search work on the term dictionary.

Q3How do you keep a search index in sync with the database?

Incremental updates on every create, update and delete, sent through a queue so the main request stays fast. Laravel Scout does this through model events. Changes that bypass events — bulk SQL, ERP imports, related-model renames — must trigger reindexing explicitly. On top of that, a scheduled full reindex builds a fresh index in the background and swaps it in atomically via an alias. Monitoring compares document counts with the database to catch drift.

Q4Algolia, Meilisearch, Typesense or Elasticsearch?

Algolia when the client wants a hosted service with strong merchandising and analytics and accepts usage-based pricing. Meilisearch or Typesense for most shops: open source, fast, typo-tolerant, easy to configure and cheap to self-host or use in their cloud. Elasticsearch or OpenSearch when data is very large, queries and aggregations are complex, or the company already runs a cluster. With an abstraction like Laravel Scout, switching between the first three is mostly configuration.

Q5What are facets and how do they work?

Facets are the filters with counts next to search results, such as "Brand: Acme (12)" or "Size: Large (5)". You declare which fields are filterable, and the engine computes counts for each value within the current result set. Selecting a value filters the results and updates other counts. For a shop, variant attributes are usually merged into the product document so a product appears under all its colours and sizes. Disjunctive facets keep counts for other values visible after selecting one.

Q6How do you improve search quality over time?

With data. Log every query, its result count and clicks. Review zero-result searches weekly and fix them with synonyms, missing products or better attributes. Look at queries with results but no clicks, which point to bad ranking. Adjust searchable field order, add business ranking signals such as popularity, and push out-of-stock products down. Popular searches can power suggestions. Small, regular tuning beats a big one-time configuration.

Senior-level questions

S1How would you design search for a catalogue with millions of SKUs and per-customer prices?

I would separate what changes slowly from what changes per customer. Product content, categories and attributes go into the index as documents, one per product with variant attributes merged for facets. Per-customer prices should not multiply documents; instead, index price-list IDs or price tiers that can be filtered, and enforce the customer's filter through a secured key or tenant token. Exact prices can be fetched for the visible page of results from the pricing service. Stock and price updates are batched partial updates. I would shard by the engine's guidance, measure p95 latency under load, and keep a full rebuild procedure with aliases. The trade-off is an extra pricing call per page in exchange for a much smaller, cheaper index.

S2How do you evaluate search quality objectively before and after a change?

Opinions about search are unreliable, so I use data. Offline: build a judgement set of real queries from logs, with the products that should rank highly, and compute metrics such as precision at the first few results or NDCG, which rewards relevant results near the top; run it before and after each configuration change. Online: track zero-result rate, click-through rate, position of the first click, search exits and conversion from search, ideally in an A/B test between configurations. Combine both, because offline sets catch regressions cheaply, while online metrics measure real behaviour. The trade-off is effort: a judgement set takes a few days to build, but it prevents tuning that improves one query and quietly breaks fifty others.

S3When would you keep search inside PostgreSQL instead of adding an engine?

When the catalogue is modest, the team is small and transactional freshness matters. PostgreSQL full-text search with tsvector, GIN indexes and ranking, plus pg_trgm for fuzzy matching, covers tens or hundreds of thousands of products well, with no sync pipeline, no second copy of data and permissions enforced by the same queries. Its weaknesses are instant typo-tolerant prefix search, rich faceting at scale, multilingual tuning and merchandising tools. I move to a dedicated engine when those limits show up in real metrics — slow facet queries, poor zero-result rates, marketing needing ranking control — not by default. The trade-off is operational: a dedicated engine adds infrastructure and sync complexity, but gives much better search UX.

Glossary

Inverted index
A map from each term to the documents containing it, the core structure of search engines.
Tokenisation
Splitting text into searchable units, usually words.
Stemming
Reducing words to a common root so "running" and "run" match.
BM25
A standard relevance formula weighting term frequency, term rarity and field length.
Edit distance
The number of single-character changes between two words; used to measure typos.
Facet
A filter with result counts per value, such as brand or size.
Synonym
Words configured to match each other, such as "kitten" and "cat".
Index alias
A stable name that points to a real index and can be switched atomically.
Tenant token
A signed, expiring search key with embedded filters that the engine enforces.

Have a product in mind?Let's build it.

Tell me about your idea, your product or your team. I usually reply within one working day.