# 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 (``) | | 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. 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 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.