Feature comparison matrix — pg_fts vs PostgreSQL BM25 alternatives
Scope: PostgreSQL-embedded BM25 full-text search extensions, plus built-in
tsvector/GIN as the baseline everyone already has. Compiled 2026-09-09 against
pg_fts v1.6.0.
Competitor rows reflect what we exercised in our own benchmark runs (see
bench/BENCHMARK_SUMMARY.md and bench/data_5way_159b/), not vendor claims. Where
a capability was not exercised, it is marked untested rather than guessed.
Versions measured: pg_textsearch f940210; pg_search (ParadeDB) 0.25.6 /
paradedb c807ede; VectorChord-bm25 14fc2a3.
Legend: Yes = exercised and working · No = absent · n/t = not tested by us
Not in this matrix: TIN (PlanetScale)
TIN is deliberately absent from every table below, because it cannot be obtained. It
is a closed extension available only inside PlanetScale’s managed Postgres: there is no
public repository (github.com/planetscale/tin is 404), no source or package referenced in
its documentation, and no TIN backend even in PlanetScale’s own public fork of the
benchmarker they used. Installation instructions direct you to update your PlanetScale
cluster.
So its capabilities cannot be exercised here and its published numbers cannot be
reproduced or contested by anyone outside PlanetScale. Adding a TIN column with vendor
figures would violate the rule this document is built on — competitor rows reflect what we
ran ourselves. An architectural comparison, based on reading their published design in
full, is in bench/NOTE_VS_TIN_2026-09-14.md; the short version is that TIN’s headline
architectural argument (use ctid as the posting identifier, so merges never renumber) is
a design pg_fts already implements, while its two-level bitmap + AVX-512 execution
engine is a genuine advantage over our scalar delta-packed postings.
Query capability
| Capability | pg_fts | pg_textsearch | pg_search | vchord | tsvector/GIN |
|---|---|---|---|---|---|
| BM25 ranked top-k | Yes | Yes | Yes | Yes | No (ts_rank is not BM25) |
| Boolean match predicate (AND/OR/NOT) | Yes | No | Yes | No (ranking only) | Yes |
Exact count(*) of a match set |
Yes (index-native) | No | Yes | No | Yes (heap/bitmap) |
| Phrase queries | Yes | No | Yes (###) |
No | Yes (<->) |
| NEAR / proximity with distance | Yes | No | n/t | No | Yes (<N>) |
Prefix terms (term*) |
Yes | No | Yes | No | Yes |
| Fuzzy terms (Levenshtein) | Yes (term~k, DFA) |
No | n/t | No | No |
| Regex terms | Yes (/re/) |
No | n/t | No | No |
| BM25 variants (lucene/robertson/atire/bm25+/bm25l) | Yes | No | No | No | n/a |
| BM25F multi-field weighting | Yes | No | n/t | No | No |
| Field zones / weighted sections | Yes (term:A) |
No | n/t | No | Yes (setweight) |
| Highlight / snippet | Yes | No | n/t | No | Yes (ts_headline) |
| Index-maintained corpus stats (N, avgdl, df) | Yes | No public API | n/t | No | No |
| Lexical anomaly detection | Yes | No | No | No | No |
tsquery migration path |
Yes (cast + helper) | n/a | No | No | n/a |
Read: query-language breadth is pg_fts’s widest margin. pg_textsearch is ranked retrieval only; vchord is ranking-only by design (no boolean or count support at all); pg_search is the only competitor with comparable breadth.
Correctness and semantics
| Property | pg_fts | pg_textsearch | pg_search | vchord |
|---|---|---|---|---|
Uses PostgreSQL english text-search config |
Yes | Yes | No (Tantivy) | Yes |
| English stemming applied | Yes | Yes | No | Yes |
| Match counts agree with the other stemming engines | Yes (byte-identical) | Yes | No (−33% on year) |
Yes |
| Exact top-k (verified against a heap sort) | Yes (parity_check.sh, 10 cases) |
n/t | n/t | n/t |
| Unverifiable phrase behaviour | false (matches PG OP_PHRASE) |
n/a | n/t | n/a |
| MVCC-correct deletes | Yes (tombstones) | n/t | n/t | n/t |
| WAL-logged / crash-safe | Yes (all writes via GenericXLog) | n/t | n/t | n/t |
| Physical-replication safe (failover tested) | Yes (t/002) |
n/t | n/t | n/t |
Read: the stemming row is the important one. pg_search’s speed advantage is
partly a different (smaller) unit of work — it does not stem, so year matches
495,580 documents where the correct English answer is 734,896, confirmed by regex
on the raw text.
Performance (2.19M Wikipedia articles, identical single column)
| Measure | pg_fts | pg_textsearch | pg_search | vchord |
|---|---|---|---|---|
| Index size | 1,421 MB | 1,887 MB | 2,734 MB | 2,902 MB |
| Build time | 381 s | 496 s | 127 s | 56 s |
| rare k10 | 5.89 ms | 7.36 | 2.13 | 2.48 |
| mid k10 | 10.64 | 7.96 | 2.04 | 2.40 |
| common k10 | 36.16 | 20.71 | 2.12 | 3.49 |
| common k100 | 46.01 | 50.71 | 3.72 | 24.52 |
exact count(*) |
2.20 ms | — | 13.63 | — |
| phrase (tuned) | 229 ms | — | 22.9 | — |
Read: pg_fts wins size and count(*), is competitive on rare/mid against the
like-for-like comparator, and clearly trails on common-term ranked and phrase.
Operational surface
| Property | pg_fts | pg_textsearch | pg_search | vchord |
|---|---|---|---|---|
Requires shared_preload_libraries |
No (verified: every benchmark/test cluster ran without it, and the FtsCount CustomScan pushdown still fires) |
Yes | Yes | Yes |
| Pure C (no Rust toolchain to build) | Yes | Yes | No (Rust/pgrx) | No (Rust/pgrx) |
| Extra build dependencies | none | none | openblas, pgvector, pgrx | pgrx |
| Incremental maintenance (no REINDEX to add rows) | Yes (pending list) | n/t | n/t | n/t |
CREATE INDEX CONCURRENTLY |
Yes (verified) | n/t | n/t | n/t |
| Parallel index build | Yes (faster; no durable size cost) | n/t | n/t | n/t |
| Parallel scan | No (built, measured, reverted) | n/t | n/t | n/t |
| Managed-service safe (replica guard, privileges) | Yes | n/t | n/t | n/t |
| Non-UTF-8 server encodings | Yes (fixed 1.5.9) | n/t | n/t | n/t |
| Big-endian hosts | Untested (no CI) | n/t | n/t | n/t |
Note on parallel builds (corrected 2026-09-09): an earlier sweep reported a parallel build as ~17% larger than serial. That was measured before
fts_vacuum. Re-measured with the vacuum step, serial and 4-worker builds land at identical size (1,420 MB each) with identical match counts — a parallel build is faster and leaves more reclaimable residue, not a bigger index. Runfts_vacuumonce after a large build. Details inbench/BENCHMARK_SUMMARY.md.
Read: not needing shared_preload_libraries is a genuine deployment
advantage — the other three all require a restart to install, and on a managed
service may require provider support. Being pure C also matters: two competitors
need a Rust toolchain, and pg_search additionally needs OpenBLAS and pgvector.
Choosing between them
- pg_fts — you want one index that answers ranked BM25 and boolean, exact counts, phrase, prefix, fuzzy and regex, with PostgreSQL-consistent stemming, the smallest on-disk footprint, and no preload/Rust requirement. Accept slower common-term ranked queries.
- pg_search — you want the fastest ranked latency across the board and can
accept a Tantivy analyzer that does not stem (so results differ from
to_tsvector), a 1.9× larger index, and a Rust build. - vchord — you want fast ranking and nothing else; it has no boolean or count support, and the largest index here.
- pg_textsearch — you want a minimal, familiar BM25 ranking on top of PostgreSQL’s own analyzer, and need nothing beyond ranked retrieval.
- tsvector/GIN — already in PostgreSQL, mature tooling, but no BM25 and no index-native top-k.
What this matrix does NOT cover
Three dimensions normally used to judge a BM25 access method are absent, and their
absence is not evidence of parity (bench/COVERAGE_AUDIT_2026-09-10.md):
- Concurrent throughput (QPS) — MEASURED cross-engine 2026-09-11
(
bench/RESULTS_C1X_CROSSENGINE_2026-09-11.md), all four engines, one documented query form, same instance type. Headlines: No engine collapses — all four rise to 8 clients then plateau, host-CPU-bound on 16 vCPU / 8 physical cores. pg_fts has the best scaling factor (10.7x rare / 10.0x common, latency flat 1→8) and the worst absolute ranked throughput: at 32 clients 1,076 tps rare (pg_textsearch 8,349) and 208 tps common (pg_search 4,298, i.e. 20.7x — the common-term gap is worse under load than the 17x single-client figure).count(*)is ours by a wide margin: 2,923 tps / 10.9 ms at 32 clients vs pg_search 513 tps / 62.3 ms (5.7x), and pg_textsearch/vchord cannot do it at all. Correction recorded there: an earlier claim that vchord collapses was wrong — it peaks at 8 clients and declines only 4.7-7.2% by 32. - Ingest / update throughput. We measure bulk build only. Sustained INSERT rows/s, DELETE/UPDATE cost, and how latency degrades as the pending list grows between merges are unmeasured for every engine.
- Ranking quality (NDCG / recall vs a reference). We verify our own exactness (top-k parity against an exact sort, gated per release) but have never compared relevance ordering against a rival. This matters here because pg_search does not stem, so it answers a different query.
Caveats on this matrix
Every n/t is a real gap in our knowledge, not an implied “No”. We benchmarked the competitors for latency, size and match counts on one corpus; we did not audit their crash safety, replication behaviour, MVCC correctness or concurrency, all of which pg_fts has been tested for at length (and had bugs found in). A fair reading is that our own rows are better evidenced than the competitors' — including the rows where we lose.