Postgres Full-Text Search vs Elasticsearch vs Meilisearch: BM25 Benchmarked (2026)
I scored built-in tsvector, pg_search, pg_textsearch, Elasticsearch 9.5 and Meilisearch 1.55 on three BEIR datasets: ranking quality (nDCG@10), latency, index size, typos and how fast new rows become searchable.

On this page
Postgres full-text search can now rank results as well as Elasticsearch, as long as you use a BM25 extension instead of the built-in ts_rank. I loaded three public BEIR retrieval datasets (5,183 to 171,332 documents) into five setups on one box: built-in tsvector, ParadeDB's pg_search, Timescale's pg_textsearch, Elasticsearch 9.5.5 and Meilisearch 1.55.0. Then I scored every engine with the datasets' own relevance judgments.
The short version: both Postgres BM25 extensions landed within about 0.05 nDCG@10 of Elasticsearch on every dataset, answered faster in my setup, and made new rows searchable in 2-4 ms instead of about 700 ms. The built-in tsvector search returned nothing at all for 274 of 300 natural-language queries with its default AND matching. Meilisearch, which is built for search-as-you-type, ranked long documents worst.
TL;DR: Postgres full-text search vs Elasticsearch vs Meilisearch#
If your data already lives in PostgreSQL and you need ranked keyword search, try a BM25 extension (pg_textsearch or pg_search) before adding a search cluster. Keep Elasticsearch for multi-node scale, heavy aggregations, log search and complex analyzers. Use Meilisearch for instant, typo-tolerant search over short fields like product names, not for ranking long documents.
nDCG@10 runs from 0 to 1, and higher means the right documents sit higher in the top 10. "New row searchable after" is the median of 10 trials on the FiQA index.
How I tested#
Everything ran on one Linux VM with 8 vCPUs and 15 GB of RAM, one engine at a time, with the same documents and queries.
- Datasets (from BEIR): SciFact (5,183 scientific abstracts, 300 test queries), FiQA-2018 (57,638 finance forum posts, 648 queries) and TREC-COVID (171,332 papers, 50 queries). Each comes with human relevance judgments, so I could score ranking quality instead of guessing.
- Metrics: nDCG@10 and recall@100, computed from the top 100 results per query. These are the two numbers BEIR reports.
- Latency: 20 warm-up queries, then every query 3 times asking for the top 10. I report p50 from those runs. Postgres was queried through psycopg with prepared statements over a Unix socket. Elasticsearch and Meilisearch were queried over local HTTP with Python
requests, so their times include HTTP and JSON overhead. That is how an app talks to them too, but keep it in mind. - PostgreSQL 18 with
shared_buffers = 1GB,work_mem = 64MBandmaintenance_work_mem = 512MB. Elasticsearch ran as a single node with 1 shard and 0 replicas. Meilisearch ran with default settings, then with an English stop-word list. - Freshness: insert one document containing a random token, then poll search every 5 ms until it comes back. 10 trials per engine.
Each document's title and body were indexed with English stemming in every engine. Queries are the raw natural-language questions from the datasets, which is what a search box sends.
As a sanity check, my Elasticsearch scores (0.691, 0.254, 0.688) are close to the BM25 baselines in the BEIR paper (0.665, 0.236, 0.656), so the setup isn't broken.

Built-in Postgres full-text search: tsvector, GIN and ts_rank#
Postgres ships with full-text search: to_tsvector turns text into stemmed lexemes, a GIN index makes @@ matches fast, and ts_rank sorts the matches. This is the setup most tutorials show:
ALTER TABLE docs ADD COLUMN tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;
CREATE INDEX docs_tsv_gin ON docs USING gin (tsv);
SELECT id
FROM docs, websearch_to_tsquery('english', $1) q
WHERE tsv @@ q
ORDER BY ts_rank(tsv, q) DESC
LIMIT 10;It works for short, keyword-style input. With real questions it fell apart: websearch_to_tsquery joins every word with AND, so a document must contain all of them. On SciFact, 274 of 300 queries returned zero rows. On FiQA it was 393 of 648.
Switching the AND to OR fixes recall but exposes two other problems:
SELECT id
FROM docs,
cast(replace(plainto_tsquery('english', $1)::text, ' & ', ' | ') AS tsquery) q
WHERE tsv @@ q
ORDER BY ts_rank(tsv, q) DESC
LIMIT 10;- Ranking quality.
ts_rankcounts how often the terms appear in a document. It has no inverse document frequency, so a common word counts as much as a rare one, and long documents win. nDCG@10 was 0.565 on SciFact against 0.691 for Elasticsearch, and 0.147 against 0.254 on FiQA. - Speed. An OR query matches a large share of the table, and Postgres has to compute
ts_rankfor every match before it can sort. On the 171k-document TREC-COVID set the median query took 238.83 ms, with p95 at 394.13 ms.
Built-in full-text search is still useful for filters ("documents that contain these words") and for small tables. For a ranked search box, it is the weakest option I tested.
pg_search and pg_textsearch: BM25 inside Postgres#
BM25 is the ranking function Elasticsearch, OpenSearch and Lucene use by default. It weighs rare terms more than common ones and normalizes for document length. Two Postgres extensions now add it with their own index type:
- pg_search from ParadeDB builds an index on Tantivy, a Rust search library modeled on Lucene.
- pg_textsearch from Timescale adds a
bm25index type and a<@>scoring operator on top of Postgres's own text configurations.
CREATE EXTENSION pg_search;
-- the exact statement from my benchmark; id is the key column
CREATE INDEX docs_search ON docs USING paradedb (
id,
(title::pdb.unicode_words('stemmer=english', 'stopwords_language=english')),
(body::pdb.unicode_words('stemmer=english', 'stopwords_language=english'))
);
SELECT id, pdb.score(id) AS score
FROM docs
WHERE title ||| $1 OR body ||| $1 -- ||| matches any of the terms
ORDER BY pdb.score(id) DESC
LIMIT 10;CREATE EXTENSION pg_textsearch;
CREATE INDEX docs_bm25 ON docs
USING bm25 ((title || ' ' || body)) WITH (text_config = 'english');
-- <@> returns a negative BM25 score, so ascending order puts the best match first
SELECT id
FROM docs
ORDER BY (title || ' ' || body) <@> to_bm25query($1, 'docs_bm25')
LIMIT 10;Results against Elasticsearch, same queries:
On SciFact and FiQA the three are within 0.023 of each other, which is noise at this query count. On TREC-COVID, pg_textsearch scored lower on nDCG@10 (0.633) but had the highest recall@100. It indexes title and body as one string, while pg_search and Elasticsearch scored the two fields separately. Recall@100 is low for every engine on TREC-COVID because each of its 50 queries has between 111 and 1,266 judged-relevant papers, far more than 100.
Latency was where the extensions stood out in my setup:
Part of Elasticsearch's gap is the HTTP round trip, so read this as "fast enough that you will not notice", not as a verdict on Elasticsearch's engine. A note on Elasticsearch tuning: with tie_breaker: 0 on the multi_match query, TREC-COVID dropped to 0.552 nDCG@10 but p50 fell to 2.72 ms. With tie_breaker: 0.5 (the table above) it scored 0.688 at 12.29 ms.
One gotcha: pick one BM25 extension per database#
Both extensions register an index access method named bm25 (I created the pg_search index with its paradedb name, but the extension still claims bm25), so I could not install them in the same database. I ran them in two databases on the same server. Pick one per database.
Typos: fuzzy matching can hurt more than it helps#
I made a typo version of SciFact by swapping two letters in the longest word (5+ letters) of each query, then ran it again.
One typo in a 12-word question cost every engine only a few points, because the other words still match. Turning on fuzzy matching did not pay for itself here: pg_search with pdb.fuzzy(1) lost to plain pg_search even on the typo queries, and on FiQA it collapsed to 0.039 nDCG@10 at 69.78 ms p50. Fuzzy terms match many unrelated words and drown the ranking. Elasticsearch's fuzziness: AUTO gained less than 0.01. Save fuzzy matching for short inputs like names and product titles, where a typo can be the whole query.
Meilisearch: great for instant search, wrong tool for long documents#
Meilisearch scored lowest everywhere: 0.378 nDCG@10 on SciFact and 0.063 on FiQA with defaults. That is not a bug. Meilisearch doesn't use BM25. It sorts results through ranking-rule buckets (words matched, typos, proximity, attribute, exactness) designed for search-as-you-type on short fields, and its default matchingStrategy: last drops query words from the end until something matches.
Two settings helped:
{
"stopWords": ["a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "if", "in", "into", "is", "it", "no", "not", "of", "on", "or", "such", "that", "the", "their", "then", "there", "these", "they", "this", "to", "was", "will", "with"]
}…plus "matchingStrategy": "frequency" in the search request, which drops the most common words first. With both, SciFact rose from 0.378 to 0.501 and FiQA from 0.063 to 0.135. Still well behind BM25. If your search box is for product names, city names or a command palette, Meilisearch's typo tolerance and speed are what you want. If users type questions against articles, tickets or docs, it is the wrong tool.
It was also the heaviest on disk. For the 171k TREC-COVID documents, the Meilisearch database grew to 1,891 MB and took 37.88 s to index, against 61.5 MB for the pg_textsearch index.
Index build time and size#
TREC-COVID, 171,332 documents, 196.4 MB of raw table:
The stored tsvector column nearly doubled the table, from 196.4 MB to 386.5 MB. The BM25 indexes don't need an extra column.
Freshness: when does a new row show up in search?#
This is the difference users notice. A Postgres index is updated inside the same transaction as the insert, so the row is searchable the moment the commit returns:
Elasticsearch makes new documents visible on its periodic refresh, 1 second by default. Meilisearch queues every write as an async task. Neither is a problem by itself, but with a separate search engine you also need a sync pipeline (outbox table, queue or CDC) that keeps it in step with the database, plus a plan for when it falls behind. With a Postgres extension there is nothing to sync, and a search filter can join your real tables and respect row-level security in the same query.
Using Postgres BM25 search from Node.js#
There is no special client. It's a normal parameterized query, here with pg:
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export async function search(q: string, limit = 10) {
const { rows } = await pool.query(
`SELECT id, title, pdb.score(id) AS score
FROM docs
WHERE title ||| $1 OR body ||| $1
ORDER BY pdb.score(id) DESC
LIMIT $2`,
[q, limit],
);
return rows;
}With Prisma or Drizzle, use their raw-SQL escape hatches ($queryRaw or the sql template) and keep the user input as a bound parameter. Never interpolate the query string into SQL. I compared the two ORMs in Drizzle vs Prisma in 2026.
Because the search index lives next to your data, you can also combine it with vectors in the same database. pgvector installed fine alongside pg_search in my test database. Hybrid search (BM25 + embeddings, merged with reciprocal rank fusion) is a few lines of SQL from there. The vector side is covered in pgvector vs Pinecone vs Qdrant.
Which should you use?#
- Already on Postgres, need a search box: start with
pg_textsearchorpg_search. In my test they matched Elasticsearch's ranking, answered in single-digit milliseconds and needed no second system.pg_textsearchwas the lightest and fastest.pg_searchhas the richer query language (field-level matching, fuzzy, phrase and more). Check that your host lets you install the extension. Managed Postgres providers support different extension lists. - Built-in
tsvector: fine for "contains these words" filters and small tables. Don't usets_rankfor a natural-language search box. - Elasticsearch (or OpenSearch): when search is its own product. That means multiple nodes, billions of documents, aggregations and facets at scale, log analytics, many languages and custom analyzers. Budget for the sync pipeline and the cluster.
- Meilisearch: instant, typo-tolerant search over short text, such as product catalogs, docs titles or app search. Not ranked retrieval over long text.
If you are building a SaaS on Postgres, as I do with NestJS and Next.js (see the secure NestJS + Prisma API starter), a BM25 extension removes a whole service from the architecture. For more Postgres 18 numbers, see UUIDv7 vs UUIDv4 vs bigint primary keys.
Limitations#
- One machine, one run per configuration. Small nDCG differences (under about 0.02) are within noise, especially on TREC-COVID's 50 queries.
- Default or near-default settings. A tuned Elasticsearch analyzer, BM25
k1/bvalues or Meilisearch ranking rules could change the order. - Read-only query load from one client. I didn't test concurrent writes, replication, or very large corpora (millions of documents).
- Versions: PostgreSQL 18, pg_search 0.26.2, pg_textsearch 1.5.1, Elasticsearch 9.5.5, Meilisearch 1.55.0. These extensions move fast, so re-check on newer releases.
Questions
Share


