Backend & APIs

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.

Postgres full-text search vs Elasticsearch vs Meilisearch cover: a SQL card with a bm25 index and nDCG@10 scores for pg_textsearch, pg_search, Elasticsearch and tsvector
On this page
  1. TL;DR: Postgres full-text search vs Elasticsearch vs Meilisearch
  2. How I tested
  3. Built-in Postgres full-text search: tsvector, GIN and tsrank
  4. pgsearch and pgtextsearch: BM25 inside Postgres
  5. One gotcha: pick one BM25 extension per database
  6. Typos: fuzzy matching can hurt more than it helps
  7. Meilisearch: great for instant search, wrong tool for long documents
  8. Index build time and size
  9. Freshness: when does a new row show up in search?
  10. Using Postgres BM25 search from Node.js
  11. Which should you use?
  12. Limitations

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.

Engine (my setup)nDCG@10 SciFactnDCG@10 FiQAnDCG@10 TREC-COVIDp50 latency, 171k docsNew row searchable after
Postgres tsvector, AND (websearch_to_tsquery)0.0740.0550.2851.86 ms2.3 ms
Postgres tsvector, OR + ts_rank0.5650.1470.561238.83 ms2.3 ms
pg_search 0.26.2 (ParadeDB)0.6680.2530.6878.32 ms3.7 ms
pg_textsearch 1.5.1 (Timescale)0.6880.2490.6332.19 ms1.7 ms
Elasticsearch 9.5.50.6910.2540.68812.29 ms705.8 ms
Meilisearch 1.55.0 (stop words, frequency)0.5010.1350.2385.50 ms61.9 ms

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 = 64MB and maintenance_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.

Bar chart of nDCG@10 on SciFact, FiQA and TREC-COVID for Postgres tsvector, pg_search, pg_textsearch, Elasticsearch and Meilisearch

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:

sql
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:

sql
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;
  1. Ranking quality. ts_rank counts 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.
  2. Speed. An OR query matches a large share of the table, and Postgres has to compute ts_rank for 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 bm25 index type and a <@> scoring operator on top of Postgres's own text configurations.
sql
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;
sql
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:

nDCG@10 / recall@100SciFactFiQATREC-COVID
pg_search0.668 / 0.9010.253 / 0.5520.687 / 0.113
pg_textsearch0.688 / 0.9180.249 / 0.5490.633 / 0.124
Elasticsearch0.691 / 0.9190.254 / 0.5490.688 / 0.117

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:

p50 / p95 latency, top 10SciFact (5k)FiQA (58k)TREC-COVID (171k)
pg_textsearch0.31 / 0.62 ms0.60 / 1.51 ms2.19 / 5.27 ms
pg_search1.03 / 1.55 ms3.74 / 5.58 ms8.32 / 19.02 ms
Elasticsearch2.27 / 3.10 ms1.85 / 2.44 ms12.29 / 19.73 ms
Meilisearch (stop words)2.23 / 3.71 ms3.51 / 7.01 ms5.14 / 7.84 ms

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.

nDCG@10 on SciFactClean queriesWith a typo
pg_textsearch0.6880.654
pg_search0.6680.634
pg_search with pdb.fuzzy(1)0.5320.487
Postgres tsvector OR0.5650.520
Elasticsearch, fuzziness: AUTO vs off (typo run)0.660 vs 0.652

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:

json
{
  "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:

Build timeIndex size
tsvector generated column + GIN26.31 s + 2.77 s65.7 MB GIN (plus the stored tsvector column)
pg_search3.92 s82.6 MB
pg_textsearch10.03 s61.5 MB
Elasticsearch (bulk load + refresh)14.64 s98.1 MB store
Meilisearch37.88 s1,891 MB database

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.

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:

Insert, then poll until found (median of 10, FiQA)Time
pg_textsearch1.7 ms
tsvector + GIN2.3 ms
pg_search3.7 ms
Meilisearch (async task queue)61.9 ms
Elasticsearch (default 1 s refresh)705.8 ms

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:

ts
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_textsearch or pg_search. In my test they matched Elasticsearch's ranking, answered in single-digit milliseconds and needed no second system. pg_textsearch was the lightest and fastest. pg_search has 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 use ts_rank for 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/b values 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