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 v1.4.0 (head-to-head 2026-09-30, bench/RESULTS_PGTS_2026-09-30.md; earlier columns 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 Yes (1.x: @@ tsquery filter, then sort) Yes No (ranking only) Yes
Exact count(*) of a match set Yes (index-native) Yes, but seqscan (@@; 251 s on 2.19M rows) Yes No Yes (heap/bitmap)
Phrase queries Yes Yes (1.x: <-> filter + recheck) Yes (###) No Yes (<->)
NEAR / proximity with distance Yes No n/t No Yes (<N>)
Prefix terms (term*) Yes Yes (1.x: :* filter) 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 1.x added boolean/phrase/prefix as a @@ tsquery filter over its ranked scan (not index-native, per its own README), and still has no NEAR, fuzzy or regex; 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 (fts_search) 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 —

Caution: the pg_fts column above used fts_search(); the competitors used ORDER BY forms (see the retraction in CHANGELOG 1.8.6). And pg_textsearch has since moved. Head-to-head on 2026-09-30, both engines using ORDER BY forms (pg_fts 1.8.6 vs pg_textsearch v1.4.0, same hardware and corpus):

Measure pg_fts 1.8.6 pg_textsearch 1.4.0
rare / mid k10 10.21 / 15.99 ms 0.92 / 1.17
common k10 / k100 49.16 / 50.14 11.41 / 13.75
AND / OR2 / OR3 k10 9.03 / 10.69 / 15.40 37.69 / 36.94 / 31.79
prefix / phrase k10 18.38 / 239 (positions=on) 13.41 / 41.8
exact count(*) 2.50 ms 251 s (seqscan)
saturated tps, rare / common / count 1,050 / 205 / 2,645 8,130 / 640 / –
index size 1,421 MB 1,887 MB

Read: pg_fts wins size, count(*) and multi-term boolean ranked. pg_textsearch 1.4.0 now leads on every single-term ranked query (4-14x) and on prefix and phrase. Both trail pg_search on common-term ranked.

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) Yes (5 workers, 260 s) 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. Run fts_vacuum once after a large build. Details in bench/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 single-term ranked queries (pg_textsearch 1.4.0 and pg_search are both faster).
  • 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 the fastest single-term BM25 ranking on top of PostgreSQL’s own analyzer (1.4.0 leads pg_fts 4-14x there). Boolean/phrase/prefix work as a filter over the ranked scan, and there is no index-backed count.
  • 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.