Search that sells: Postgres tsvector vs Meilisearch vs Typesense
Visitors who use search buy at several times the rate of visitors who browse — which makes the search box a conversion feature, not a technical checkbox. When the database you already run is enough, when typo tolerance pays for itself, and the sync problem that is the real project either way.
On most shops we take over, the search box is the single highest-intent element on the page and the least examined thing in the codebase. Someone who types "waterproof jacket 42" has already decided to buy something; they are asking whether you have it. Everyone else is browsing. That is why search sessions convert at multiples of browsing sessions on nearly every catalogue we've measured — and why "search is a bit rubbish, we'll get to it" is a revenue decision disguised as a backlog item.
The question we get asked is which engine is best. That's the wrong first question. The right one is what your search is failing at right now, because the answer decides whether you need a new engine at all — and we've said Postgres is enough often enough to be honest about where that stops being true.
Measure the failure before you shop for a fix
Three numbers, all of which you can have by the end of the week, and none of which need a new container:
- Search usage rate. What share of sessions touch the search box at all. Under 10% on a catalogue site usually means the box is hidden or the results are bad enough that people gave up — not that customers prefer browsing.
- Zero-result rate. The share of queries returning nothing. This is the one that pays. Every zero-result query is a customer who wanted something specific and left. Log the query string, not just the count: half of them are typos, half are words in your customers' vocabulary that aren't in your product titles.
- Search-to-purchase rate, next to your overall conversion rate. If searchers convert at 4× browsers, a 10% improvement in search quality is worth more than a redesign.
Read a week of zero-result queries before you do anything else. If they're misspellings of products you carry, typo tolerance will pay for a search engine on its own. If they're words your catalogue simply doesn't use — customers typing "raincoat" where the PIM says "shell jacket" — no engine fixes that; synonyms and better product data do, and you can add those to Postgres today.
Postgres goes further than the benchmarks imply
The default assumption is that "real" search needs a search engine. For a catalogue in the tens of thousands of SKUs it usually doesn't. A generated tsvector column with weighted fields, a GIN index, and pg_trgm for fuzziness cover the majority of what a business catalogue asks for:
ALTER TABLE products ADD COLUMN search tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('simple', coalesce(name, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(sku, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(brand, '')), 'B') ||
setweight(to_tsvector('simple', coalesce(description, '')), 'C')
) STORED;
CREATE INDEX products_search_idx ON products USING gin (search);
CREATE INDEX products_name_trgm_idx ON products USING gin (name gin_trgm_ops);
-- exact-ish matches first, then anything close enough to be a typo
SELECT id, name, price,
ts_rank(search, query) AS rank,
similarity(name, $1) AS fuzzy
FROM products, websearch_to_tsquery('simple', $1) AS query
WHERE (search @@ query OR name % $1)
AND active
ORDER BY rank DESC, fuzzy DESC
LIMIT 24;Two things this buys you that a separate engine cannot. The results are transactionally correct — a product goes out of stock and the very next query knows, because there is no index to fall behind. And filters are just SQL, so "in stock, under €50, ships to Belgium, in these three categories" composes with search instead of fighting it. On an ERP-backed storefront that second point matters more than people expect: price and availability rules that live in Odoo don't have to be duplicated into a search index to be filterable.
Facet counts are a GROUP BY — no extra moving parts:
SELECT c.id, c.name, count(*) AS n
FROM products p
JOIN categories c ON c.id = p.category_id, websearch_to_tsquery('simple', $1) AS query
WHERE p.search @@ query AND p.active
GROUP BY c.id, c.name
ORDER BY n DESC;Get the fundamentals right before concluding Postgres is slow: an unaccented dictionary or unaccent so "cafe" finds "café", the right text search configuration per language, and — the one everybody misses — a query plan you have actually looked at. We've "fixed" more than one slow search by adding the GIN index that was never created.
Where Postgres actually stops
We reach for a dedicated engine when one of four things is true. Not before.
Search-as-you-type at scale. Instant results on every keystroke means a query per 100 ms of typing against a database that is also serving checkout. Postgres will do it for a few thousand products; at a few hundred thousand, with facet counts recomputed per keystroke, you're building a workload your primary database should not be carrying.
Typo tolerance as a first-class ranking input. pg_trgm gives you similarity, and you can bolt it on as above — but it's a separate signal you rank by hand, not part of one relevance model. Meilisearch and Typesense treat edit distance as a native ranking dimension, tuned per field, with word-position and prefix awareness. On real customer typing the difference is visible.
Relevance the business wants to tune. Boost this brand for a fortnight, pin these three SKUs to the top of "gift", add synonyms as marketing learns the vocabulary. Doable in SQL, painful in SQL, and it turns every merchandising request into a deploy.
Faceted navigation over millions of documents. Facet counts over a large filtered result set are exactly what these engines are architected for and exactly what a GROUP BY on a shared OLTP database is not.
If none of those describe you, the honest recommendation is to spend the week on product data instead. Better titles, real synonyms and populated attributes beat an engine swap on a catalogue whose descriptions are three words long.
Meilisearch and Typesense: the differences that actually decide it
Both are open-source, self-hostable, sub-50 ms, typo-tolerant out of the box, and both will feel like magic after a week of hand-tuned SQL. They are far more alike than either is like Elasticsearch, which remains a cluster you operate rather than a service you run. The differences that decide a project:
| Meilisearch | Typesense | |
|---|---|---|
| Licence | MIT | GPL v3 |
| Written in | Rust | C++ |
| Memory model | Memory-mapped index — can exceed RAM | Index held fully in RAM |
| High availability | Single node in the OSS build | Raft clustering, minimum 3 nodes |
| Relevance model | Ordered ranking rules you reorder | Per-field weights in query_by |
| Vector / hybrid search | Built in, with embedders including OpenAI, Mistral, Cohere, Hugging Face and local Ollama | Supported, typically with embeddings you supply |
The two rows that have actually changed our recommendation on real projects are memory and availability.
Typesense keeps the whole index in RAM. That's where the latency comes from, and it's also your capacity plan: a large catalogue with rich attributes turns into a memory figure you must budget, on nodes that must all hold it. This is not a flaw — it's the trade — but it's the number to compute before the first docker run, the same right-sizing exercise as any other stateful workload.
Meilisearch's open-source build is a single node. It is superb at being one node, and one node with a replica-free restore plan is genuinely fine for most business catalogues. But if your requirement is "search must survive a node reboot without a blip", Typesense's Raft clustering is a first-class answer in the box, and Meilisearch's is Meilisearch Cloud or your own failover.
Licence, if you embed. MIT and GPL v3 make no difference when you run the engine as a service behind your own API — which is how both are used 99% of the time. If you distribute software that ships the engine, get your lawyer's opinion before your architect's.
Our default for a Belgian mid-market shop: Meilisearch, on the memory model and the smaller operational surface, moving to Typesense when HA is a stated requirement rather than a wish. Neither choice is one you can't reverse in a sprint — both index the same denormalised documents, and the interesting code is not engine-specific.
The sync is the project
Here is the part nobody quotes for. The moment you add an engine, you have a second copy of the truth, and every failure mode of headless architecture arrives with it: stale prices, ghost products, and a search index that confidently sells something you deleted last Tuesday.
Two rules keep it honest.
Push, don't poll. Emit on write — the same transactional outbox pattern we use everywhere, so the index update commits with the product change or not at all — and let a worker drain it. A cron that re-scans "everything changed since yesterday" is how you get a shop where a price fix takes 24 hours to reach search.
Never search the truth-critical fields. The index answers "which products match" and nothing else. Price at checkout, stock at the moment of add-to-cart, and customer-specific pricing come from the source of truth on the product page — same rule as caching a catalogue, for the same reason. Showing an approximate price in a result card is fine. Charging one is not.
Full reindexes need to be non-events, which means indexing into a new collection and swapping an alias, never wiping the live one:
const target = `products_${buildId}`
await client.collections().create({ name: target, fields, default_sorting_field: "popularity" })
await client.collections(target).documents().import(documents, { action: "upsert" })
// One atomic pointer move — searches never see a half-built index.
await client.aliases().upsert("products", { collection_name: target })Get this right on day one and a reindex is a deploy detail. Get it wrong and every schema change is an outage window, which in practice means the schema never changes, which in practice means the search stays bad.
Two operational details worth writing down: only ever ship a search-only API key to the browser — both engines issue scoped keys, and admin keys in a bundle are a full index takeover — and give the frontend a fallback path to the database when the engine is unreachable. Degraded search beats a storefront with no search at all, and it turns a 3 a.m. page into a morning ticket.
Where we don't recommend adding an engine
- Under ~50,000 documents with simple filters. Postgres with the right indexes will answer in single-digit milliseconds. Adding a container here buys latency you didn't have a problem with and a sync bug you didn't have before.
- When the catalogue data is the problem. Three-word titles and empty attributes produce bad results in every engine ever written. Fix the data; then measure again.
- When nobody will own it. An engine is a stateful service with a capacity plan, a backup story and an upgrade path. If that has no owner, it will drift — the same objection we raise to any box added to an architecture diagram without a name next to it.
- Internal admin lookups. Finding a customer or an invoice in a back-office screen is a
WHEREclause with a GIN index, not a search platform. Keep it in the database.
The rule of thumb
Instrument search before you re-engineer it — usage rate, zero-result rate, and search-to-purchase, in that order. Start on the Postgres you already run, because transactionally-correct results and SQL filters are worth more than most teams realise. Graduate to Meilisearch or Typesense when instant search, typo tolerance as a ranking signal, business-tuned relevance or large-scale faceting show up as measured problems — Meilisearch by default for the memory model, Typesense when high availability is a requirement rather than an aspiration. Then treat the index as a derived copy that must never be trusted for money: push updates on write, swap aliases to reindex, and always keep a fallback to the database.
That's how we build catalogue search on every headless commerce project, and it's the same sequencing we apply to every custom web application we take on — the box gets added when the numbers ask for it, and it runs on infrastructure someone owns. A search box that finds what people came for is a growth feature. One you added because a benchmark said 12 ms is just another container.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.