Contents
- pg_fts capability / production-readiness matrix
- Capability matrix
- Answers
- 1. Concurrent index builds (CIC / REINDEX CONCURRENTLY)
- 2. Index-only scans and the count tradeoff
- 3. REPACK / pg_repack / in-place compaction
- 4. Feature parity vs. pg_search/Tantivy, ZomboDB/Elasticsearch, tsvector/GIN
- 5. Drop-in replacement for a tsvector/pg_textsearch system under logical replication?
- 6. Asynchronous I/O (AIO / read_stream)
pg_fts capability / production-readiness matrix
BM25 full-text search index access method (fts) for PostgreSQL.
Every claim below is grounded in the source under contrib/pg_fts/. File:line
citations are to the tree this document was generated against; SQL objects cite
the squashed install script pg_fts--0.3.2.sql. All
IndexAmRoutine flags cited are from the fts_handler function in
pg_fts_am.c.
Capability matrix
| Capability | Supported? | Evidence |
|---|---|---|
@@@ boolean / phrase / NEAR / prefix / fuzzy / regex match |
Yes | opclass strategy 1, pg_fts--0.3.2.sql:237; @@@ operators pg_fts--0.3.2.sql:130,139; bitmap scan bm25_getbitmap pg_fts_am_scan.c:1970 |
<=> relevance ordering scan (ORDER BY d <=> q LIMIT k, no Sort) |
Yes | amcanorderbyop=true pg_fts_am.c:3313; <=> operators + FOR ORDER BY pg_fts--0.3.2.sql:319,326,336; bm25_gettuple block-max WAND/MaxScore pg_fts_am_scan.c:1197 |
| BM25 (Okapi) scoring, index-maintained corpus stats (N, avgdl, df) | Yes | fts_bm25 pg_fts--0.3.2.sql:156; fts_index_stats/fts_index_df pg_fts--0.3.2.sql:243,251; metapage meta->ndocs pg_fts_am.c:820,2694 |
| BM25 variants (lucene, robertson, atire, bm25+, bm25l) | Yes | fts_bm25_opts pg_fts--0.3.2.sql:164 |
| BM25F multi-field weighting | Yes | fts_bm25f(ftsdoc[], ftsquery, weights, ...) pg_fts--0.3.2.sql:177 |
Phrase queries ("a b c") / NEAR via per-term positions |
Yes | evaluated from stored positions; adjacency enforced exactly on all paths (@@@, bitmap, ranked, fts_count) – verified 2026-09-08 by cross-checking seq scan, index positions=off and index positions=on against regex ground truth (all 1000/1000). Where adjacency cannot be verified (a doc built without positions, or a boolean sub-expression under a phrase) the result is false, matching PostgreSQL’s OP_PHRASE without TS_EXEC_PHRASE_NO_POS – 1.6.0 fixed a silent degradation to a conjunction here. One deliberate exception: a prefix operand inside a phrase ("quick bro*") is presence-only, since prefix positions are not tracked, so it is over-permissive by design |
Index-native phrase/NEAR (no heap recheck) with WITH (positions=on) |
Yes | token positions stored in the postings (BM25 format v3, 4th lazily-decoded FOR column); phrase count/match answered from postings, no per-candidate heap fetch. Default positions=off keeps the smaller index + correct-but-slower heap recheck. Non-phrase queries never decode positions (bm25_decode_term skip); size cost ~1.03x (prose) to ~2.8x (high term-repetition) |
Prefix (term*), fuzzy (term~k), regex (/re/) |
Yes | README lines 31-34; sequential + index paths, sql/pg_fts.sql:147-271; optional trigram pre-filter pg_fts_trgm_index.c, built only WITH (trigrams = on) (default off; regex/long-fuzzy fall back to a dictionary scan without it) |
Ranked (<=>) over fuzzy/prefix/regex returns a correct subset |
Partial | the ranked WAND path builds cursors from the literal term, so docs matching only via an expansion aren’t ranked; results are always correct (never a non-match) but may be incomplete. Use @@@ for exhaustive fuzzy/prefix/regex retrieval. PHRASE/NEAR/boolean ranking is exact (bm25_recheck_exact, pg_fts_am_scan.c:1988) |
| Highlight / snippet | Yes | fts_highlight, fts_snippet pg_fts--0.3.2.sql:188,195 |
Fast bulk count (fts_count(regclass, ftsquery)) |
Yes | pg_fts--0.3.2.sql:299; visibility-map-aware, heap probed only for not-all-visible pages. 1.3.0: a single plain term over a tombstone-free / pending-free / all-visible index is answered from the dictionary df alone (no posting decode, ~hundreds of times faster); the transparent count(*) WHERE @@@ Custom Scan pushdown now also fires for a plain-column index, not only an expression index |
Lexical anomaly detection (fts_anomalous_docs(index, k, max_df)) |
Yes | top-k docs containing globally rare terms, scored by max idf on global df; walks only the low-df dictionary tail (skips high-df terms before decode -> sub-ms, not a full scan) pg_fts_am_scan.c; pg_fts--0.3.2.sql:288; tombstones honored |
| MVCC-correct deletes (tombstones) | Yes | bm25_bulkdelete per-segment livedocs tombstone bitmap pg_fts_am.c:2971; scans/counts subtract, merge drops |
| Oversized-document handling | Yes | bm25_insert_oversized_as_segment one-doc segment (no per-doc size cap) pg_fts_am.c:2566,2660 |
| WAL-logged / crash-safe / physical-replication safe | Yes | every page write via GenericXLog (15 GenericXLogStart cycles: 13 in pg_fts_am.c, 2 in pg_fts_trgm_index.c); no raw XLogInsert/log_newpage/MarkBufferDirty/PageSetLSN/smgrwrite anywhere (grep: 0 matches); header pg_fts_am.c:26-28; failover verified (t/002 promotes the standby and re-queries) |
| Managed-service safety (replica guard, privileges, live-only stats) | Yes (1.3.0) | fts_merge/fts_vacuum error during recovery (RecoveryInProgress) and require index ownership (object_ownercheck), shared bm25_maintenance_guard pg_fts_am.c; fts_search/fts_anomalous_docs REVOKEd from PUBLIC (install + pg_fts--1.2.2--1.3.0.sql); corpus stats exclude tupleIsAlive=false recently-dead tuples pg_fts_am.c |
| tsquery -> ftsquery migration | Yes (partial: helper, not transparent) | tsquery_to_ftsquery pg_fts_migrate.c:130 + ASSIGNMENT cast pg_fts--0.3.2.sql:214 |
| CREATE INDEX CONCURRENTLY / REINDEX CONCURRENTLY | Yes (verified empirically) | aminsert (bm25_insert) routes all concurrent writes to the pending list (immediately searchable) pg_fts_am.c:2632; see Q1 |
| Index-only / covering scan (IOS) | No | amcanreturn = bm25_canreturn returns false pg_fts_am_scan.c:1144; amcaninclude=false pg_fts_am.c:3332; non-covering (stores postings, not the ftsdoc) |
| Parallel index build (PARALLEL workers) | Yes, with a size trade-off | amcanbuildparallel=true pg_fts_am.c:3330; parallel heap scan, per-worker segment flush, leader merge. Faster but larger: workers pack output pages independently, measured at 2.19M docs (bench/RESULTS_GATING_2026-09-09.md) – at maintenance_work_mem=1GB, 464 s / 5,386 MB with 4 workers vs 523 s / 4,605 MB serial; the gap nearly vanishes at 2GB. The same fragmentation makes a parallel fts_merge 1.45x SLOWER and 19% larger than serial (bench/RESULTS_PARALLEL_MERGE_2026-09-08.md), so do not raise max_parallel_maintenance_workers for merges |
| Parallel scan | No | amcanparallel=false pg_fts_am.c:3328; amestimateparallelscan/aminitparallelscan/amparallelrescan = NULL pg_fts_am.c:3362-3364 |
| Parallel VACUUM | No | amparallelvacuumoptions = VACUUM_OPTION_NO_PARALLEL pg_fts_am.c:3334 |
| Unique / multicolumn / ordered-btree / clusterable | No | amcanunique=false :3320, amcanmulticol=false :3321, amcanorder=false :3312, amclusterable=false :3326 |
| NULL / optional-key indexing | No | amsearchnulls=false :3324, amoptionalkey=false :3322 (a NULL ftsdoc is not indexed: bm25_insert returns early on isnull[0] pg_fts_am.c:2649) |
| Predicate locks (SSI) | No | ampredlocks=false pg_fts_am.c:3327 |
| Ranked scan over unflushed pending docs | No (partial) | <=>/fts_search cover merged segments only; pending docs matched by @@@/counted by fts_count but ranked only after a flush |
| Faceting / aggregation Custom Scan pushdown | No | none in tree; only fts_count count-pushdown exists |
| Impact-ordered postings | No | postings are docid-ordered (block-max WAND); listed as future work, README lines 62-64 |
| Storage AIO / read_stream prefetch | No (build heap scan gets core AIO free) | 0 read_stream/StartReadBuffers sites; nextblk pointer-chains defeat prefetch; see Q6 |
REPACK <table> command (PG19) — plain + (CONCURRENTLY) |
Yes | REPACK (renamed CLUSTER + new concurrent mode) rewrites the heap and rebuilds the bm25 index via the standard REINDEX path (repack.c:515); pg_fts’s ambuild/aminsert handle it like VACUUM FULL/CIC. CONCURRENTLY replays concurrent writes via logical decoding + core WAL (pg_fts is fully GenericXLog-logged). see Q3 |
REPACK <table> USING INDEX <bm25> (PG19) |
No (correctly rejected) | orders the heap by an index; check_index_is_clusterable (repack.c:800) errors on amclusterable=false. BM25 order is query-dependent, so this is semantically inapplicable — clean error, not a gap. |
| pg_repack extension of the table | N/A (table-level tool) | see Q3 — pg_fts offers VACUUM+fts_merge() and REINDEX for in-place compaction |
Answers
1. Concurrent index builds (CIC / REINDEX CONCURRENTLY)
Yes — verified empirically. amcanbuildparallel=true enables parallel
CREATE INDEX (a separate capability from) the two-phase concurrent build. What
makes CIC correct here is that aminsert
(bm25_insert, pg_fts_am.c:2632) always routes a new document to the
pending write buffer so newly inserted rows are immediately visible to
@@@ without a REINDEX. Oversized documents that will not fit a
pending page take the equivalent path as a one-document segment
(bm25_insert_oversized_as_segment, pg_fts_am.c:2566). So writes that
arrive during the build’s validate phase are captured by the index and found by
the subsequent scan; the build (bm25_build) does a
standard table_index_build_scan and never disables inserts. Both CREATE INDEX
CONCURRENTLY and REINDEX CONCURRENTLY have been verified to work.
2. Index-only scans and the count tradeoff
No index-only scan — this is by design and drives the count strategy.
amcanreturn = bm25_canreturn returns false (pg_fts_am_scan.c:1144):
the index is non-covering because it stores analyzed postings (terms,
term frequencies, positions, doc lengths), not the original ftsdoc, so it
cannot reproduce a column value. amcaninclude=false (pg_fts_am.c:3332), so
there is no covering INCLUDE either.
Consequence for counting: count(*)/EXISTS need no attribute but the planner
still includes the @@@ restriction column in the IOS coverage check, so with
amcanreturn=false they fall back to a bitmap (or plain index) scan — every
matching TID is visited (bm25_canreturn, pg_fts_am_scan.c:1144).
The fast count is therefore the explicit
fts_count(regclass, ftsquery) (pg_fts--0.3.2.sql:299), which counts
matches in bulk from the index using the visibility map, probing the heap only
for not-all-visible pages — no per-tuple executor round-trips.
Tradeoff: you trade IOS convenience (transparent count(*) over the index) for
a smaller, ranking-ready index (no stored source doc) plus an explicit,
MVCC-correct bulk-count primitive. Callers wanting a fast count must call
fts_count rather than relying on the planner picking an index-only count(*).
3. REPACK / pg_repack / in-place compaction
Two different things share the name. (a) The PG19 in-core REPACK command
(renamed CLUSTER + a new CONCURRENTLY mode) rewrites the table and then
rebuilds every index via the standard REINDEX path (repack.c:515) — pg_fts is
rebuilt correctly by it, exactly as by VACUUM FULL (plain and CONCURRENT both
work; the USING INDEX <bm25> form is rejected because amclusterable=false,
which is correct — BM25 order is query-dependent). Note: REPACK ... CONCURRENTLY’s
logical-decoding replay is a newer path than CIC and is not yet empirically
exercised in the isolation suite (PG19 isn’t in the local test matrix) — the
code properties are all satisfied, but an isolation test is a good future add.
(b) The pg_repack extension rewrites the table too and is orthogonal to
fts. Either way, for compacting the index itself pg_fts offers:
- VACUUM +
fts_merge()— the size-tiered segment merge. VACUUM’samvacuumcleanupfolds pending docs into a segment and merges;fts_merge(regclass)(pg_fts--0.3.2.sql:344) forces it on demand.bm25_merge_segments(pg_fts_am.c:2120) coalesces similarly-sized segments and physically drops tombstoned docs (pg_fts_am.c:25,bm25_bulkdeletecommentpg_fts_am.c:1368,1414). - REINDEX / REINDEX CONCURRENTLY — full rebuild.
Honest gap: the merge leaves superseded blocks unreferenced; they are reclaimed
only by REINDEX (“Old blocks are left unreferenced and reclaimed by REINDEX (a
page recycler is future work)”, pg_fts--0.3.2.sql). So fts_merge()
compacts logical content (fewer segments, tombstones gone) but does not shrink
the physical file — REINDEX is the only way to reclaim that space. There is no
online index REPACK beyond VACUUM+fts_merge() and REINDEX.
4. Feature parity vs. pg_search/Tantivy, ZomboDB/Elasticsearch, tsvector/GIN
HAS:
- BM25 (Okapi) + variants (lucene/robertson/atire/bm25+) and BM25F
multi-field weighting (fts_bm25f, pg_fts--0.3.2.sql:177).
- Rich query language: boolean, phrase "a b c", NEAR(...), prefix term*,
fuzzy term~k, regex /re/ (README 28-34; sql/pg_fts.sql:147-271,416-426).
- <=> relevance ordering index scan (no Sort) via block-max WAND /
MaxScore (pg_fts_am_scan.c:1197, pg_fts--0.3.2.sql:336).
- Fast MVCC-correct bulk count fts_count (pg_fts--0.3.2.sql:299).
- Highlight / snippet (fts_highlight, fts_snippet, pg_fts--0.3.2.sql:188,195).
- MVCC-correct tombstone deletes (bm25_bulkdelete, pg_fts_am.c:2971).
- Oversized-document handling (one-doc segments, pg_fts_am.c:2566).
- Full WAL logging via GenericXLog → crash recovery + physical replication
safety, no custom resource manager (pg_fts_am.c:26-28; 0 raw-write sites).
HONEST GAPS:
- No parallel scan (amcanparallel=false, pg_fts_am.c:3328; parallel-scan
hooks all NULL, :3362-3364) → single-threaded query execution.
- No index-only / covering scan (amcanreturn→false, amcaninclude=false).
- No faceting / aggregation Custom Scan pushdown (only count-pushdown exists).
- No impact-ordered postings — docid-ordered only (README 62-64).
- <=> / fts_search ranking does not cover unflushed pending docs until a
flush.
Versus Elasticsearch/Tantivy this is a single-node, single-threaded-per-query
engine with no distributed aggregation; versus tsvector/GIN it adds real BM25
ranking, index-maintained corpus stats, and a <=> ordering scan that GIN
cannot provide, at the cost of GIN’s parallel-scan and mature-tooling maturity.
5. Drop-in replacement for a tsvector/pg_textsearch system under logical replication?
No — it is a re-platform, not a drop-in. Three reasons, all code-backed:
(a) Different API, no transparent shim. pg_fts uses ftsdoc/ftsquery with
@@@ (pg_fts--0.3.2.sql:130) and <=> (pg_fts--0.3.2.sql:319), not
tsvector/tsquery/@@. Ranking is fts_bm25/<=>, not ts_rank. There is
a migration helper tsquery_to_ftsquery() (pg_fts_migrate.c:130, faithful
&→AND, |→OR, !→NOT, <N>→FTS_OP_PHRASE preserving the gap) and an
ASSIGNMENT cast (pg_fts--0.3.2.sql:214) so existing tsquery values
flow into @@@, but there is no transparent operator/type shim — queries,
index DDL (USING fts (to_ftsdoc(...))) and ranking calls must be rewritten.
(b) Logical replication does not replicate indexes. Under logical
replication the subscriber maintains its own indexes; a subscriber must have
pg_fts installed and its own fts index provisioned. This is no worse than GIN
(indexes are never logically replicated), but it is a per-subscriber
provisioning step, not automatic.
© Physical replication + crash recovery ARE safe. Every page write goes
through GenericXLog (15 GenericXLogStart cycles; zero raw-WAL/buffer-dirty
sites), so the index is fully WAL-logged and replicated on a physical standby
with no custom resource manager (pg_fts_am.c:26-28).
Bottom line: physical replicas and crash recovery are covered transparently; moving a tsvector/GIN workload to pg_fts is a deliberate migration (rewrite queries/DDL, provision the index per subscriber for logical replication), not a transparent swap.
Note: the WAL write-site count is 15
GenericXLogStart cycles (13 in pg_fts_am.c, 2 in pg_fts_trgm_index.c).
The underlying claim is what matters: 100% of page
mutations go through GenericXLog, with zero raw XLogInsert, log_newpage,
MarkBufferDirty, PageSetLSN, or smgrwrite/smgrextend sites (grep: 0
matches).
6. Asynchronous I/O (AIO / read_stream)
pg_fts issues no storage AIO of its own; the one place it matters already
gets it from core. Every index-side read is a plain synchronous
ReadBuffer + LockBuffer (grep for read_stream/StartReadBuffers/
PrefetchBuffer across contrib/pg_fts/: 0 matches; the only prefetch hits
are __builtin_prefetch CPU cache-line hints in vendored sparsemap, unrelated
to storage AIO). The build’s heap scan runs through
table_index_build_scan → heap_getnextslot → read_stream_next_buffer, so
CREATE INDEX’s heap side is already streamed/prefetched by core with no
pg_fts code.
Could it? Only in one path with real payoff. Every pg_fts on-disk structure
is a nextblk linked list (BM25PageOpaqueData.nextblk), so the next block is
known only after the current page is read — the classic pointer-chase that
read_stream cannot prefetch. The hot query path (block-max WAND) is
anti-prefetch by design: it reads block headers precisely to skip blocks
without reading their payload, so prefetching would fetch pages it means to
skip. The only cheap win is the cold merge full-scan
(bm25_read_segment_into), which reads every posting page end-to-end: those
pages are written as one contiguous run per segment, so recording a
[firstblk,lastblk] range in BM25SegMeta would let a trivial blk++
read_stream callback prefetch the merge.
Should it? Not for a warm-cache OLTP search workload (a handful of resident pages per selective query; AIO adds setup cost with no I/O to hide). A bounded, low-effort win exists for cold TB-scale merges if a cold-merge I/O bottleneck is actually measured — deferred until then.
AIO for the parallel-merge WRITES? Considered and rejected on two grounds. (1) No API: pg_fts writes every page through shared buffers + GenericXLog (a WAL/MVCC/crash-safety requirement), and this tree’s buffer manager exposes AIO for reads only (aio_shared_buffer_readv_cb; there is no buffer-manager AIO write path – FlushBuffer is synchronous). Using the low-level pgaio_io_start_writev would mean bypassing shared buffers with raw smgr writes, breaking the GenericXLog invariant the design rests on. (2) It would not help: the merge tail measured at 2M is CPU-bound (one backend decoding + re-encoding postings; workers=0 with the index resident in a 32 GB shared_buffers, so the writes are absorbed by shared buffers and flushed lazily by the checkpointer – no write I/O wait to hide). AIO accelerates I/O wait, not CPU-bound re-encode. The real lever for the merge tail is the same as the ranked-query gap: a cheaper posting codec (format v3), not asynchronous writes.