Contents
- Changelog
- 1.8.2 - 2026-09-17
- 1.8.1 - 2026-09-17
- 1.8.0 - 2026-09-14
- 1.7.2 - 2026-09-14
- 1.7.1 - 2026-09-14
- 1.7.0 - 2026-09-13
- 1.6.1
- 1.6.0
- 1.5.10
- 1.5.9
- 1.5.8
- 1.5.7
- 1.5.6
- 1.5.5
- 1.5.4
- 1.5.3
- 1.5.2
- 1.5.1
- 1.5.0
- 1.4.1
- 1.4.0
- 1.3.2
- 1.3.1
- 1.3.0
- 1.2.2
- 1.2.1
- 1.2.0
- 1.1.7
- 1.1.6
- 1.1.5
- 1.1.4
- 1.1.3
- 1.1.2
- 1.1.1
- 1.1.0
- 1.0.8
- 1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- 0.3.6
- 0.3.5
- 0.3.4
- 0.3.3
- 0.3.2
- 0.3.1
- 0.3.0
- 0.2.4
- 0.2.3
- 0.2.2
- 0.2.1
- 0.2.0
- 0.1.0 — initial public release
Changelog
All notable changes to pg_fts are documented here.
1.8.2 - 2026-09-17
Code-quality release from the fresh-eyes review (REVIEW_2026-09-17.md), plus one real
fix found while doing it. No on-disk format change; no REINDEX required.
Fixed
- A ninth unvalidated
pd_lowerread, in the trigram blob reader (pg_fts_trgm_index.c). It computed amemcpylength aspd_lower - contents_offset; on a corrupt or recycled page whosepd_loweris below the contents offset that wraps to a hugeSizebefore theMin()clamps it, and the copy runs past the page. Same defect class as the 1.7.0 P0 that made an index permanently unvacuumable. Now routed throughbm25_page_data_end(), as are the 14 remaining reads inpg_fts_am_scan.c. 1.7.1 claimed to have fixed “all eight” sites; this makes it nine, and the count is now every read ofpd_lowerin the tree.
Changed (behaviour-preserving; full gate green after each)
- Allocator state is a scoped struct, and misuse is a hard error. The four file-scope
globals (
bm25_lowfree,_n,_i,bm25_alloc_extend_only) became oneBM25AllocCtxreachable only throughbm25_alloc_scope_enter()/bm25_alloc_scope_exit(), which nest by returning the previous context. In the 1.7.1 work, code read those globals without owning them and handed out garbage block numbers (“could not open file … target block 829694001: previous segment is only 527 blocks”); onlyt/007_segment_cap.plnoticed.bm25_new_buffer()nowelog(ERROR)s on that condition. Deliberately anelog, not anAssert– the release gate is not a cassert build, and a check that fires only in a build nobody ships is documentation, not enforcement. bm25_collect_matchessplit, 412 -> 226 lines. The per-segment loop body is nowbm25_collect_segment()(returnsSEG_RESTARTfor the positional-phrase fallback the loop used to express ass = -1; continue) and the pending-list walk isbm25_collect_pending(). Shared state travels in aBM25CollectCtx.- The single translation unit is now a documented decision, not an accident.
pg_fts_am.c#includespg_fts_am_scan.c,pg_fts_trgm_index.candpg_fts_lev.c. Splitting was costed: 15 statics would go extern, ~10 shared types would move into the on-disk-format header, and the hot-pathstatic inlinehelpers inside the 45%/37% common-term profile would stop inlining (PGXS does not use LTO). In return, three.ofiles and no behaviour change. Kept; the reasoning is at the#includesite and in both included files' headers, with the two prerequisites if separate compilation is ever needed.
1.8.1 - 2026-09-17
Counting-path work from the TIN feasibility review. No on-disk format change; no REINDEX required.
Added
- Ten gate-refusal tests for the single-term
count(*)fast path. That fast path (answering from the dictionary’sdfwith no posting decode and no heap access) has existed since the COUNT pushdown work, but shipped with a single positive test and nothing proving its gates actually refuse — the shape where a missed gate silently returns a plausible wrong count. Each case is now compared against a ground truth computed without the index: prefix, conjunction, disjunction, negation, unmerged pending documents, tombstones, and a not-all-visible heap all fall back and agree exactly, and the fast path is confirmed to resume afterVACUUM.
Changed
fts_count()and theCOUNT(*)pushdown now consult the visibility map once per run of matches on the same heap page instead of once per matching tuple, and create one tuple slot per call instead of one per probed tuple. Safe because matches arrive sorted and de-duplicated and a docid isblock × MaxHeapTuplesPerPage + offset, so matches on a page are contiguous; the visibility map is page-granular.Measured effect: none significant (~1–2%, inside run-to-run noise). An apparent 8.5% improvement came from a single high outlier in the baseline; repeated same-arm runs overlap (base 397.3–408.2 ms, fix 397.4–402.7 ms). The loop is dominated by
table_index_fetch_tupleheap probes, not by visibility-map lookups. Counts are identical in both arms. Shipped as a code-quality change, not a performance feature.
Documentation
- README and the SGML manual now describe when the
dffast count applies and, more importantly, when it refuses. bench/RESULTS_ABC_2026-09-17.mdrecords the measurements, including the two corrections above;bench/NOTE_TIN_FEASIBILITY_2026-09-14.mdis annotated with the outcome. A third proposed item (copying posting bytes verbatim during a merge) was withdrawn after reading the merge path: it decodes through the build hash table and re-encodes at flush, so there is no byte-stream splice point.
1.8.0 - 2026-09-14
Query parsing fix with a behaviour change. No on-disk format change; no REINDEX required — stored data was never affected.
Fixed
-,.and/inside a word are terms, not operators. Reported from the field:query before after pkg-config('pkg' & !'config')'pkg-config'install-info('install' & !'info')'install-info'foo/bar'foo'(rest swallowed)'foo/bar'python3.14('python3' & '14')'python3.14'The hyphen case was the damaging one: the
!clause actively excluded the documents being searched for, so searchingpkg-configreturned everything except pkg-config, andinstall-infomatched 1 row instead of 10./was worse in a different way — it opened a regex and swallowed the remainder of the query. As the reporter put it, this is a worse failure mode than operator injection: injection raises a visible error, this silently returns a different, wrong answer.The separator set is not a guess — it is what the document analyzer already joins, verified against
to_ftsdoc('simple', 'a-b c/d e.f g_h i+j'), which yields'a-b' 'a' 'b' 'c/d' 'e.f' 'g' 'h' 'i' 'j':-,.and/stay inside a token while_and+split. PostgreSQL’s own parser agrees, classifying themasciihword,fileandfile. So the query lexer was the only side that disagreed, and the tokens needed to match these queries were already stored.
Behaviour change
- A
-between two word characters no longer negates.a -bstill excludesb(prefix position), and!bis unchanged, but an application relying ona-bmeaning “a AND NOT b” must now writea !bora - b. c++andgtk+still lex to'c'and'gtk'. A trailing separator is dropped, which is whatto_tsvectorand our own document analyzer do, so this is parity rather than a bug — noted because the original report listed it alongside the others.
Regression cases covering all four inputs, plus prefix negation, leading -, and
/regex/, are pinned in sql/pg_fts.sql.
1.7.2 - 2026-09-14
Measured the cause of the bloat known issue and removed ~31% of it. No on-disk format change; no REINDEX required.
Fixed
Bulk ingest grows the index ~31% less. At high terms-per-document every document exceeds one pending page, so each one mints a one-document segment — and the insert-time merge then folded it in immediately, rewriting a whole level-0 run for every single document. Measured: 23–30 index pages extended per document (linear) against roughly 2 pages of actual postings, a ~12–15× write amplification with page reuse at ~0.3%.
The merge is now gated on there being
BM25_MERGE_FANOUTsmall runs waiting. Below that threshold the leveled compactor would find no level over capacity and do nothing anyway, so this skips work without changing behaviour. Over six 5,000-document batches the index peaked at 21,874 MB instead of 31,537 MB, and the size after onefts_vacuumis byte-identical (124 MB).The segment-directory bound this protects was re-verified under the worst case for segment minting — one row per transaction, 4,000 transactions — reaching a maximum of 15 segments against the hard cap of 128.
t/007_segment_cap.plnow asserts<= 64rather than<= 128, since a bound at the cap only fails once the index is already in the state that motivated the eager merge (a field deployment went 8 → 128 segments in ~1 h and could then neither merge nor VACUUM).
Known issues
The bloat is reduced, not eliminated. Growth is still ~3.7 GB per 5,000 documents at field shape, and
fts_vacuumafter bulk ingest is still recommended (it is fast — tens of seconds for millions of pages — and recovers the space completely).The mechanism is now measured rather than guessed: freed pages are found and then rejected by
bm25_page_recyclable(), because they were freed by the inserting transaction itself andGlobalVisCheckRemovableXid()cannot yet clear them (norecyc=3,169of 5,924 allocations). That gate is correct and must stand — bypassing it previously corrupted a concurrent reader. So in-transaction reuse is impossible by construction, and fully fixing this means moving the merge out of the inserting transaction: a design change, not a point-release edit.
Measurements: bench/RESULTS_KNOWN_ISSUES_2026-09-14.md.
1.7.1 - 2026-09-14
Follow-up to 1.7.0’s page-corruption fix, plus a retraction of one of 1.7.0’s known issues. No on-disk format change; no REINDEX required.
Fixed
- The
pd_lowerbounds guard is now applied at every page-read site. 1.7.0 fixed the dict walk inmerge_source_load_page, where an unvalidatedpd_lowermade the merge request an impossible allocation and left the index permanently unvacuumable. Auditing the siblings found eight sites formingpage + pd_lowerfrom unvalidated on-page data — including both walks inbm25_free_segmentand the doclen and posting readers. All now route through one helper,bm25_page_data_end(), which validates in the integer domain (forming the pointer at all is undefined behaviour for an absurd value) and returns an empty range for anything out of bounds, so a caller degrades to “this page has nothing to read” rather than walking off the page. 1.7.0 fixed one instance of this defect; this fixes the class.
Retracted
- 1.7.0’s “
bm25_free_pageemits one WAL record per page” known issue was wrong. Measured directly:fts_vacuumfreed 8,686,917 pages in 46 seconds — 0.005 ms/page, roughly 2,800× cheaper than the ~14 ms/page I published, and a second run confirmed it (7,912,288 pages in 33 s). There is no per-page WAL problem and no WAL batching is needed. My figure came from a 113-minute run on an index that had already hit the 1.7.0 allocation bug repeatedly;gdbshowed the backend insidebm25_free_pageand I turned “where it is” into “why it is slow”. A stack sample gives a location, not a bottleneck.
Known issues
The transient bloat spike is confirmed and larger than reported: ~210×, not 45×. Measured at field shape (1,660 terms/doc), inserting 5,000 documents at a time with no maintenance: the index grows ~5.6 GB per batch with
nsegmentspinned at 8, reaching 61,814 MB after ten batches, and a singlefts_vacuumreturns it to 236 MB. The space is freed-but-never-reused, not live.The cause is not yet known. Four hypotheses were eliminated: one-doc segment accumulation (impossible —
BM25_MAX_SEGMENTSis 128 and the insert path forces a merge), 128-segment cycling (nsegmentssits at 8), freed pages failing the recyclability XID gate (instrumentation showed the free-list scan is never reached:probe=0 reject=0), and loop-widebm25_alloc_extend_only(scoping it per merge produced byte-identical growth — reverted rather than shipped as a fix). The next step is a counter on each ofbm25_new_buffer’s three outcomes rather than another hypothesis.Practical guidance unchanged: run
fts_vacuumafter bulk ingest. It is fast (tens of seconds for millions of pages) and recovers the space completely.
Details and measurements: bench/RESULTS_KNOWN_ISSUES_2026-09-14.md.
1.7.0 - 2026-09-13
Two field-blocking fixes found by reproducing the reported ~2.87M-doc email-body index shape. No on-disk format change; no REINDEX required.
Fixed
- An index could become permanently unvacuumable. The dict-page walk in
merge_source_load_pagetook its end bound from the page’spd_lowerwith no validation, and stepped by an untrustedtermlen. On a recycled or malformed page the walk ran past the page and counted garbage entries, so the caller’s doubling asked for an impossible allocation:
ERROR: invalid memory alloc request size 3406063183
Because this runs under bm25_merge_segments_streaming, it failed every merge, every
autovacuum cleanup, and fts_vacuum — the index could never be vacuumed or reclaimed
again. This is the likely cause of the reported “VACUUM/merge do not reclaim bloat”.
Isolated with gdb; both the counting and filling walks are now bounds-checked, and
pd_lower is validated as an integer before any pointer is formed from it (forming
page + pd_lower for a corrupt value is itself undefined behaviour — the new fuzz target
for this loop caught that with UBSan).
- Huge-allocation gaps in bm25_doclens_load’s resident docid array and bulkdelete’s
carry/newdead tombstone arrays, which used plain palloc/repalloc and so failed the
same way on a large or delete-heavy index. The FTS_ALLOC_MAYBE_HUGE macros already
existed for the per-term posting arrays; these sites were missed.
- Index cleanup no longer grows the index. bm25_vacuum_compact now skips a compaction
pass when its free space is not yet reusable: bm25_page_recyclable() gates on
GlobalVisCheckRemovableXid(), so pages freed by the same cleanup’s merge are all
rejected, and the pass would relocate live data upward while reclaiming nothing. Measured
before: 35 → 52 → 69 MB across three cleanups with no rows added. After: flat.
Added
- Fuzz target for the dict-page walk (
test/fuzz/fuzz_block.c), asserting the walk stays inside the page and can never report more entries than a page can physically hold, for arbitrary page bytes and arbitrarypd_lower. t/010_vacuum_delete_heavy.plnow asserts no growth across repeated cleanups, and additionally that cleanup still reclaims after deletes.
Known issues
- A transient bloat spike at
nsegments=8, reproduced at field shape: an index went 299 MB → 66,796 MB → 1,016 MB across three churn rounds, settling at 1,480 MB. The index is not permanently bloated — it inflates ~45× while segments accumulate and collapses once merges catch up, so an index sampled during that window looks like unbounded bloat. Not yet fixed. bm25_free_pageemits one WAL record per page.fts_vacuumon a 3.8 GB index ran 113+ minutes without finishing (progressing, not hung): ~489k pages × a fullGenericXLogdelta each. Needs WAL batching. Not yet fixed.
Both are documented with reproductions in bench/RESULTS_FIELDSHAPE_2026-09-13.md.
Documentation
- README and the SGML manual no longer recommend scheduling a periodic
fts_vacuum. Measured at 1M docs with autovacuum on and no manual maintenance: flat at 511 MB over five cleanups, flat at 875 MB over six churn rounds, and 875 → 106 MB after deleting half the table, with results exact throughout.
1.6.1
P0 fix: VACUUM could consume CPU indefinitely and never complete on an index
with many tombstones. C-only, no SQL objects, no on-disk format change
(BM25_VERSION stays 4), no REINDEX — ALTER EXTENSION is the whole upgrade.
Also re-vendors sparsemap 5.4.0 → 5.5.1.
The bug
On a 2,188,038-document index, deleting 312,166 rows and running VACUUM produced
a backend pinned at 100% CPU that never finished — 4h39m of CPU consumed, with
99.75% of perf samples in __sm_get_chunk_offset, reproduced twice. There was
no error and no log line; the index simply never got cleaned.
Because a VACUUM that never completes also never reclaims space, an index that
appeared never to shrink after deletes is a likely symptom of this. If you have seen
either behaviour, this release is the fix.
Measured after the fix, same workload: 393 s and completes, with results identical to sequential-scan ground truth.
| stage | before | after |
|---|---|---|
VACUUM with 312k tombstones |
4h39m CPU, never finished | 393 s, rc=0 |
| further round (376k more deleted) | — | 270 s |
| further round (375k more deleted) | — | 178 s |
fts_vacuum |
— | 135 s |
Correctness verified at every stage — index counts equal sequential-scan counts
(year 504368/504368, 378037/378037, 251902/251902; slovakia 5605/5605,
3751/3751).
Root cause: one mistake in two places
Both sites probed the tombstone sparsemap in a pattern that defeats every acceleration structure it offers, so each membership test walked the chunk chain from the head. At ~1,068 chunks × millions of probes, that does not terminate in practical time. Fixing the first site only exposed the second.
bm25_merge_segments_streaming(the merge/compaction path, reached bybm25_vacuumcleanup). This loop walks terms in sorted order, and each term’s postings ascend from a low docid — so the docid sequence resets at every one of millions of term boundaries. Neither the 8-way MRU cache used here, nor a forward-resume cursor, nor batchedsm_contains_manysurvives that: each pays anO(chunks)startup per term. Fix: the tombstone map is read-only for the whole merge, so decode it once per source into a dense bitmap (sm_next_member, a single forward pass) and test each posting in O(1).bm25_bulkdelete(the per-index delete path). A cursor was used, but was declared inside the walk and therefore reset every iteration, restarting each lookup from the head. The enclosing walk is monotonically ascending — exactly the cursor’s contract — so the cursor was right in intent and defeated by its scope. Fix: hoist the declaration out of the loop.
This is the same class of pathology fixed for the ranked scan in 1.4.1 (24 s → 2.5 ms); these two paths never received the equivalent treatment.
sparsemap 5.4.0 → 5.5.1
Re-vendored as exactly upstream plus our one namespacing block; vendor/sm.h is
byte-identical to upstream, and the public header changes only its version macros, so
this is a drop-in.
Two fixes reach code pg_fts actually executes:
- Big-endian chunk-descriptor flag reads (5.5.0). Ten sites aliased the 64-bit
descriptor as
uint8_t *, walking its 2-bit flags in reverse on big-endian and breaking every counting and navigation path.sm_containswas unaffected because it shifts the word directly — which is precisely why the bug hid behind a working membership test. pg_fts is exposed throughsm_next_member, used to iterate tombstones. (Note: big-endian remains untested in our CI.) __sm_append_datanow returnsbooland iswarn_unused_result(5.5.1). In 5.5.0 the append inside__sm_map_setwas unchecked, so a full buffer silently overflowed instead of reportingENOSPC. pg_fts reaches that path viasm_add_many_grow→__sm_add_c→__sm_map_set, and our grow-and-retry loop depends onENOSPCbeing signalled rather than the buffer being overrun.
The three headline 5.5.0 fixes (sm_difference RLE data loss, sm_offset invalid
maps, sm_split ENOSPC) are in functions pg_fts does not call.
A note on how this was found, and on our test coverage
The hang was found while qualifying the sparsemap bump at scale — not by the local
test suite. installcheck (PG 17/18), the full TAP set, alloc/ascii guards and the
block fuzzer all pass on the unfixed code, and passed on two wrong fixes
before the real cause was isolated with gdb. None of those checks deletes a large
fraction of a large indexed table and then vacuums.
The gap was known: ROADMAP item 9’s outstanding task was exactly “quantify the merge
path under a delete-heavy workload”, and that measurement had never been completed.
Details, including the failed attempts and why each failed, are in
bench/P0_VACUUM_HANG_2026-09-10.md.
1.6.0
Correctness release: an unverifiable phrase no longer silently answers as a conjunction. C-only – no SQL objects change, no on-disk index format change (BM25_VERSION stays 4), no REINDEX. MINOR rather than patch because query results change, for the better.
Also re-vendors sparsemap 5.4.0 -> 5.5.0, which fixes a big-endian corruption that reaches our tombstone iteration.
1. A phrase whose adjacency cannot be verified now returns false
phrase_step() fell back to presence-only AND whenever either operand lacked
positions – “recall preserved, precision degraded” – so a phrase query could
report a NON-ADJACENT document as a match, with nothing to distinguish it from a
real phrase hit.
This is now false, which is what PostgreSQL does: absent
TS_EXEC_PHRASE_NO_POS, OP_PHRASE “always returns false if lexeme position
information is not available” (tsearch/ts_utils.h). Upstream returns false for
strip(to_tsvector('simple','quick brown')) @@ 'quick <-> brown' even though the
words are adjacent; pg_fts_match.c claims to mirror TS_execute and on this
branch did the opposite.
Four routes reached the bad path, all confirmed on 1.5.10:
| input | 1.5.10 | 1.6.0 |
|---|---|---|
$$'brown':1 'quick':1$$::ftsdoc (canonical literal, no @) |
t | f |
to_ftsdoc(strip(to_tsvector(...))) |
t | f |
ftsdoc || ftsdoc where one side lacks positions |
t | f |
quick <-> (brown & fox) on a fully positioned doc, via the tsquery cast |
t | f |
The fourth is the important one: the boolean arms null out positions, so the document’s own flag cannot distinguish “no positions anywhere” from “this operand lost them”. The fix therefore carries an explicit reason flag on the operand, set at exactly one producer.
Prefix-inside-phrase is unchanged. "quick bro*" stays deliberately
permissive, because prefix positions are genuinely not tracked – that is shipped,
documented lossiness rather than a bug, and it now has test coverage it lacked.
Why false and not an error: @@@ is a match operator, so raising an error would
be order-dependent (a predicate that errors on a full scan can succeed under a
LIMIT) and would turn a handful of unverifiable rows into a whole-table failure.
2. Field-zone restrictions on a positionless document match nothing
Zone labels are carried in each position’s high bits, so a document without
positions carries no label information – it is unknown, not “D”. Previously
term:D matched every positionless document while term:A matched none, and
a concatenation that dropped one side’s labels silently answered term:D = true.
A zone restriction that cannot be evaluated now does not match.
3. sparsemap 5.4.0 -> 5.5.0
Re-vendored as exactly upstream 5.5.0 plus our one namespacing block
(SPARSEMAP_PREFIX=__pg_bm25_); vendor/sm.h is byte-identical to upstream.
Header change is purely additive (one new function), so it is a drop-in.
The fix that reaches pg_fts is chunk-descriptor flag reads on big-endian
hosts: ten sites aliased the 64-bit descriptor as uint8_t *, walking the 2-bit
flags in reverse on big-endian and breaking every counting and navigation path.
sm_contains was unaffected because it shifts the word directly – which is
precisely why the bug hid behind a working membership test. pg_fts is exposed
through sm_next_member, used to iterate tombstones, so on a big-endian host that
iteration could silently go wrong. Upstream measured a map with bits 42 and 1024
set reporting cardinality 1, minimum 768, maximum 1792 on sparcv9.
The three headline 5.5.0 fixes (sm_difference RLE data loss, sm_offset
structurally invalid maps, sm_split ENOSPC) are in functions pg_fts does not
call – included, but not our exposure.
Upgrade notes
ALTER EXTENSION pg_fts UPDATE TO '1.6.0' is the whole upgrade. No REINDEX, no
data migration.
If an application relied on the old lossy phrase behaviour as a cheap
conjunction, write the conjunction explicitly ('quick & brown'). If you issue
phrase or NEAR queries at scale, note separately that
WITH (positions = on) is what makes them fast – 36x on a 2.19M-document corpus
(see 1.5.10’s notes and bench/NOTE_PHRASE_PROFILE_2026-09-06.md).
Validation
All three phrase evaluation paths were checked for agreement on a 3,000-row corpus
where only a third of rows have the phrase adjacent: sequential scan (heap
matcher) 1000, index scan with positions=off 1000, index scan with
positions=on 1000, regex ground truth 1000. installcheck (PG 17/18),
full TAP set, alloc/ascii guards, block fuzzer all pass; upstream sparsemap’s own
suite passes 10/10 including its property tests.
1.5.10
Performance release: common-term ranked top-k is 1.56x faster. C-only, no SQL
objects changed, no on-disk index format change (BM25_VERSION stays 4), no
REINDEX. Both wins came from profiling the scan with perf on real hardware,
and both are ordinary read-path fixes – no format, ordering or exactness change.
| query | 1.5.9 | 1.5.10 | change |
|---|---|---|---|
common k10 (year, df 734,896) |
56.30 ms | 36.16 ms | 1.56x |
| common k100 | 69.78 ms | 46.01 ms | 1.52x |
| rare k10 / mid k10 / 2-term OR | 5.85 / 10.69 / 4.32 | 5.89 / 10.64 / 4.12 | flat |
1. Ascending-resume hint in the doclen lookup
bm25_doclen_cursor_lookup() ran an unconditional binary search over the
resident 128-entry doclen block – ~7 branchy iterations per posting, on a
term whose docids are consecutive. perf annotate put ~45% of a common-term
query in that search (the BM25 math itself was 1.68%).
The WAND scan probes docids in strictly ascending order, so the next answer is almost always the next entry: try a short linear walk from a resume hint, fall back to the binary search on a miss, and reset the hint whenever a new block is decoded. 56.30 -> 42.67 ms.
2. bm25_for_get() was still decoding bit-by-bit
bm25_for_unpack() (batch) had long since been optimized to a
word-load/shift/mask extraction – its comment even says it “replaces the per-bit
inner loop that dominated posting decode” – but the random-access twin
bm25_for_get() never got that treatment and still ran one bit-test per bit of
width, per call. It is on the hot path twice: wand_contrib_cur() reads tf
through it for every scored posting, and the v3 inline-doclen path reads |D|
through it too.
Gave it the same extraction, including the shift == 0 undefined-behaviour guard
the batch version documents for a corrupt on-disk width byte. 42.67 -> 36.16
ms. The bm25_for_get(buf, i) == bm25_for_unpack(buf)[i] equivalence is already
asserted by both test/fuzz/fuzz_for.c and test/hegel/test_for.c.
Rejected by measurement (recorded so they are not retried)
- Partial/lazy block decode for rare-term latency. Gains ~1%. The motivating “71x decode amplification” was an arithmetic artifact of ours: the doclen sidecar is keyed by all docids, so a rare term’s target sits at an arbitrary offset in its block – instrumented at 60-82 entries decoded per block, which is the unavoidable gap-decode prefix (delta-encoded docids have no random access), not waste. Forcing a small decode window made it worse (rare 5.83 -> 7.74 ms) via tens of thousands of geometric re-walks.
- Impact-ordered postings / a precomputed block-max score / finer block
granularity / early termination – all previously disproven or rejected; see
bench/NOTE_WAND_PRUNING_2026-09-04.md.
After this release rare and mid are at their floor: what remains in
bm25_doclen_cursor_load_page() is inherent gap-decoding. Moving them further
would need a format change (periodic absolute docids within a block) worth at most
~2x of a portion of the query.
Validation
Ranked top-k parity PASS on all 10 cases (single-term, AND, OR at k=10 and
k=100) against an exact fts_bm25 sort, on 2.19M Wikipedia articles at 1421 MB.
installcheck (PG 17/18), full TAP set, alloc/ascii guards and the block fuzzer
(== ALL CLEAN ==) all pass. Measured on EC2 r6id.4xlarge, medians of runs 4-8.
1.5.9
Correctness fix for non-UTF-8 databases, plus two ranked-latency optimisations. C-only, no SQL objects, no on-disk index format change (BM25_VERSION stays 4).
1. Non-ASCII case folding was broken on non-UTF-8 server encodings
Who is affected: databases whose server encoding is NOT UTF-8 (LATIN1,
WIN1252, etc) and whose locale is not C. UTF-8 databases were never
affected.
fold_token() case-folded ASCII but passed every byte >= 0x80 through
unchanged, so upper- and lower-case accented letters were different terms and
case-insensitive search silently failed for all non-ASCII text. Measured on
LATIN1 + de_DE.iso88591: a document Apfel (A-umlaut, 0xC4) did not match a
query apfel (a-umlaut, 0xE4), while PostgreSQL’s own to_tsvector() did.
We were diverging from PostgreSQL text search on exactly the deployments most
likely to be non-UTF-8.
The non-UTF-8 path now delegates to str_tolower() – the same
locale/collation-aware primitive tsearch’s lowerstr() uses – so pg_fts
produces byte-identical terms to to_tsvector() on those servers.
Behaviour under locale C is deliberately unchanged: the C library has no case
mapping for high bytes there. (PostgreSQL appears to “match” under C only
because its parser discards the accented character; pg_fts keeps it, which
loses less information.) The contract is now explicit: non-ASCII folding on a
non-UTF-8 server follows the database locale, exactly as PostgreSQL does.
Upgrade action for affected databases: terms already stored by an older
version are unfolded and will not match the newly-folded query form. Re-derive
them – UPDATE t SET d = to_ftsdoc('<cfg>', body) for a stored ftsdoc column,
or REINDEX INDEX <name> for an expression index. Until then those rows keep the
old (case-sensitive for non-ASCII) behaviour. Nothing to do on UTF-8.
Also hardened: the UTF-8 folding loop called utf8_to_unicode() without checking
that the character’s bytes fit within the token. Not reachable today (the
tokenizer never splits a well-formed UTF-8 character and pg_fts imposes no term
length cap), but the bound is now explicit.
New regression coverage in t/004_encodings.pl asserts an upper-case accented
document matches a lower-case query on LATIN1 with an ISO-8859-1 locale; it skips
cleanly where no such locale exists. The pre-existing LATIN1 probes only tested
exact-case round-trips, which is why this bug survived.
2. Rare-term ranked latency: 1.7x faster (block-granular doclen decode)
The page-directory cursor decoded a whole sidecar page (~31 blocks, ~4,000 doc entries) to answer one doclen lookup. A rare term scattered across the docid space touches nearly every sidecar page, so scoring 10,875 postings cost ~2.2M doc-decodes – a ~200x amplification, measured as ~7.9 ms of a 10.2 ms rare-term ranked query. Now only the covering 128-doc block is decoded: the page’s block headers are walked (no FOR-unpack) to locate it, then that one block is unpacked.
rare k10 10.2 -> 6.05 ms. Mid is ~flat (a denser term’s consecutive postings already shared a block), common 60.9 -> 56.5 ms.
3. Multi-term ranked latency: 1.44-1.63x faster (shared resident block)
Each (term, segment) has its own doclen cursor, but the scoring loop asks every
cursor sitting at the pivot docid for its contribution – i.e. N cursors look up
the SAME docid, and each decoded the same sidecar block independently. The
resident decoded block is now hoisted into a per-segment slot shared by all of
that scan’s cursors, so the 2nd..Nth lookup of a docid is a pure in-memory binary
search.
| query | before | after | change |
|---|---|---|---|
| 1-term | 6.02 / 10.83 ms | 6.15 / 11.06 ms | flat (one cursor) |
| 2-term OR | 6.17 ms | 4.27 ms | 1.44x |
| 3-term OR | 10.95 ms | 6.71 ms | 1.63x |
(The originally-planned “LRU of decoded blocks” was dropped: the WAND visits a cursor’s docids monotonically ascending, so a block is never revisited by the same cursor and an LRU would have nothing to hit. The duplication is across cursors, which is what this fixes.)
Rejected by measurement (recorded so it is not retried)
A df-threshold “bulk-load the whole sidecar for small-df terms” fast path was
planned and then disproven: measured ranked cost is ~linear in df with no
fixed floor (df 2,560 -> 2.35 ms, already the plain @@@ count floor), while a
bulk load reads ~547 sidecar pages regardless of df. There is no crossover; the
page directory already wins at every df. Details in
bench/NOTE_RARE_MID_LATENCY_OPTIONS_2026-09-05.md.
Validation
Ranked top-k parity PASS on all five query shapes (single-term, AND, OR, at
k=10 and k=100) against an exact fts_bm25 sort, on 2.19M Wikipedia articles.
Encoding fix verified against real LATIN1/WIN1252/UTF-8 clusters and against
to_tsvector on identical bytes. installcheck (PG 17/18), full TAP set (incl.
t/004_encodings running for real with an ISO-8859-1 locale, and
t/006_concurrent_extend), alloc/ascii guards and the block-fuzzer all pass.
1.5.8
Performance + robustness release. C-only, no SQL change, no on-disk index format change (BM25_VERSION stays 4), no REINDEX.
1. Doclen-sidecar ranked latency: 2.5x faster rare/mid terms. The
doclen_sidecar=on default (the ~4.7x smaller index) had a fixed per-scan tax:
1.5.4-1.5.7 decoded the ENTIRE segment sidecar once per ranked scan into a
scan-local array (~18 ms on a 2.19M-doc segment: ~534 page reads plus a
FOR-unpack of every block), which dwarfed the actual scoring for anything but a
very common term. A fresh 5-way benchmark on real Wikipedia caught it (rare
ranked 25.6 ms vs 1.6 ms with inline doclen).
Replaced with a page-directory cursor: one tiny (first_docid, blk) entry
per sidecar PAGE, built by walking only page HEADERS (no block decode) and
cached in the index relcache (rd_amcache) as ONE contiguous chunk keyed by the
metapage generation – so it is built at most once per backend, not per scan. A
lookup binary-searches the directory to the covering page, decodes ONLY that
page, and keeps it resident (the WAND scan visits docids ascending). Single-chunk
is deliberate: it satisfies the rd_amcache “pfree()d wholesale on relcache
invalidation” contract that 1.5.4’s multi-chunk 20 MB decoded array violated (the
1.5.5 crash). The whole-segment bulk decode is retained for the MERGE path,
which legitimately reads every doc sequentially.
Measured on 2.19M Wikipedia articles (median, warm, doclen_sidecar=on):
rare 25.6 -> ~10 ms, mid 29.3 -> ~11 ms. Ranked top-k parity PASSES on all
bands (k=10 and k=100, single-term and AND), and a 90 s concurrent
fts_merge + fts_vacuum + 6-reader soak kept the ranked top-10 stable with
zero mismatches and zero errors.
Known remaining gap (documented, not a regression): a very common term (735k df,
34% of the corpus) is still ~70 ms, and that is NOT the doclen path – a plain
@@@ count of the same term is 2.4 ms while the ranked top-10 reads 8,115
buffers, i.e. the block-max WAND is not pruning it. That is a separate lever.
2. fts_search() SRF could return fewer than k rows. The top-k engine
over-fetches for MVCC (wantk = k*4); the amgettuple ordering scan retries and
grows on its own, but the fts_search SRF called the engine once, so a
heavy-delete workload where most of the top candidates are invisible could yield
nvis < k. bm25_topk_visible now grows wantk and re-generates when the
visibility loop ends short AND more candidates existed, with a bounded growth cap
(and keeps the directory-generation retry inside each attempt).
3. Sparsemap error-path leaks. sm_create() maps are libc-malloc (no
palloc allocator is installed), so an ereport(ERROR) between create and
sm_free leaked past transaction abort. bm25_bulkdelete and
bm25_segment_docids now free them via PG_TRY/PG_FINALLY. (The cleanup
pointers are resynced before each throw because sm_add_many_grow updates
*map even on a partial grow-then-fail – freeing the pre-call pointer would be
a double free.) bm25_read_blob buffers are palloc’d and are left alone.
4. Reserved keywords are literal words inside a phrase or NEAR. The query
lexer recognized and/or/not/near as operators unconditionally, so
"the and clause" or NEAR(near y, 2) failed to parse. Keyword tokens now
carry their folded text and the phrase/NEAR operand loops accept them as terms,
matching to_tsquery (which lexes them as lexemes). The ambiguous BARE
top-level form (and & x) is deliberately unchanged.
Also: a genuine bug in the vendored sparsemap (an __sm_insert_data
offset/length convention mismatch – a latent buffer over-write currently masked
by compensating capacity slack) was found during this work and reported upstream;
it is not triggered by pg_fts today.
1.5.7
Concurrency crash-fix release: three pre-existing races surfaced by a read+insert+merge+vacuum soak, all present since at least 1.5.3. C-only, no SQL change, no on-disk format change (BM25_VERSION stays 4), no REINDEX.
Who is affected: any index under concurrent write + maintenance load
(ingestion plus fts_merge/fts_vacuum/autovacuum). 1.5.3 crashed into
recovery under such a soak; 1.5.7 stays up and error-free.
1. Merge crash (severe). Every operation that mutates the segment directory
or frees + recycles pages – bm25_flush_pending, the tiered
bm25_merge_segments, bm25_vacuum_compact, bulkdelete’s livedocs swap – ran
with no mutual exclusion: an INSERT’s tiered merge holds only RowExclusiveLock, a
user fts_merge/fts_vacuum holds ShareUpdateExclusiveLock/AccessExclusiveLock
on the INDEX, and autovacuum cleanup holds ShareUpdateExclusiveLock on the TABLE
– lock tags that do NOT conflict. Two of these running at once let one free +
recycle a segment’s pages while the other’s streaming merge was still reading
them -> SIGSEGV in merge_source_load_page. Fix: a per-index maintenance
serialization lock (a heavyweight page lock on the metapage block, the same
mechanism GIN uses to serialize pending-list cleanup) – blocking for explicit/
required maintenance, conditional (skip if busy) for the opportunistic
insert-time tiered merge.
2. Index-relkind assert / API misuse. The freed-page recycle gate called
GlobalVisCheckRemovableXid(index, xid) with the INDEX relation; that routine
expects a table (or NULL) and tripped an assertion under –enable-cassert (and
is latent API misuse otherwise). Fixed to pass NULL (the global visibility
horizon), a sound and slightly conservative bound.
3. Scan read past a concurrent truncation. fts_vacuum (and autovacuum
compaction) truncate the freed tail of the index file back to the OS. A scan
follows the segment directory it snapshotted (dict/posting chain heads by block
number), re-checking the metapage generation afterward and retrying if it moved
– but a block number from the pre-truncation snapshot points past EOF, and
ReadBuffer raised a hard “could not read blocks N: read only 0 of 8192” ERROR
before the generation re-check could discard the stale result (a transient query
error under heavy read+vacuum churn). Fix: the scan’s chain-following reads now
treat an out-of-range block as end-of-chain (a truncated block can only mean a
concurrent vacuum bumped the generation), so the existing generation guard
restarts the scan from a fresh directory and the count/ranked result stays exact.
Validation (assert build, on the exact concurrent workload):
t/006_concurrent_extend 45 consecutive runs with zero crashes (1.5.3 crashed
~13/15); a read+insert+merge+fts_merge+fts_vacuum+VACUUM soak across many
runs with zero “could not read blocks” errors, zero crashes, and the
concurrently-churned corpus’s fixed-term count staying exact throughout. The
nix tap-* check now runs the full t/003-t/008 set (was only t/005) so
this class of crash is caught by nix flake check locally.
1.5.6
Crash-fix release. C-only, no SQL change, no on-disk format change (BM25_VERSION stays 4), no REINDEX.
Who is affected: anyone on 1.5.4 or 1.5.5 with the default
doclen_sidecar=on. Upgrade to 1.5.6. (1.5.3 and earlier lack the doclen
cursor and are unaffected.)
The bug: the doclen-sidecar cursor added in 1.5.4 has an owned flag that
tells bm25_doclen_cursor_free whether the cursor allocated its own decoded
arrays (and must free them) or merely borrowed the scan cache’s (and must not).
bm25_doclen_cursor_init set owned only on the rarely-taken self-decode path
and left it UNINITIALIZED on the common borrow and v3-segment paths. The
cursor lives in a palloc’d (not zeroed) WandCursor, so owned held stale
heap bytes: when they were non-zero, cursor teardown pfree()d a pointer it did
not own – a borrowed interior/shared pointer – corrupting the allocator
(ERROR: could not find block containing chunk ... on a following multi-term
fts_search) or segfaulting a concurrent backend. Intermittent: it fired only
when the reused chunk’s bytes happened to be non-zero, which is why fresh
backends often looked fine but the CI regression + concurrent-extend suites
tripped it.
The fix: initialize owned = false at the top of bm25_doclen_cursor_init
so every path is defined; only the genuine self-decode path sets it true.
Hardening in the same release: the scan-time sidecar decode
(bm25_doclens_load) now bounds its page walk to the relation’s block count and
stops at any page that is no longer a BM25_DOCLEN page, so a chain broken by a
concurrent merge/vacuum recycling its pages (the A1 race, already retried via
the metapage-generation guard) cannot spin or read unrelated pages before the
retry.
Validation: clean under AddressSanitizer over the exact multi-term
fts_search sequence that regressed (200-iteration loop, plus a
garbage-owned poison test); installcheck (PG 17/18), the full TAP set
(t/003-008, including the concurrent-extend crasher), alloc/ascii guards, and
the block-fuzzer all pass. The nix tap-* check now runs the full t/003-008
set (previously only t/005) so this class of crash is caught by
nix flake check locally, not only in downstream CI.
1.5.5
Concurrency crash-fix release. C-only, no SQL change, no on-disk format change (BM25_VERSION stays 4), no REINDEX.
Who is affected: anyone running 1.5.4 with the default
doclen_sidecar=on under concurrent write + query load. Upgrade to 1.5.5.
(1.5.3 and earlier do not have the 1.5.4 doclen cache and are unaffected.)
The bug: 1.5.4 cached the decoded doclen sidecar in the index relcache entry
(rd_amcache). rd_amcache must be a single palloc’d chunk because
PostgreSQL pfree()s it wholesale on a relcache invalidation (e.g. one raised
by a concurrent fts_merge/segment extend via RelationReloadIndexInfo); the
1.5.4 cache was multi-chunk (a header plus per-segment decoded arrays), so the
invalidation freed only the header, corrupting the allocator and/or leaving a
concurrently-scanning backend’s cursor pointing at freed memory – an
intermittent backend crash under the exact ingest+merge+query overlap the
concurrent-extend TAP test drives.
The fix: the decoded-sidecar cache is now scan-local – decoded once per
scan into the scan’s own memory context and shared across that scan’s
per-(term,segment) cursors, freed when the scan ends. This keeps 1.5.4’s
read-locality win (a common term’s sidecar is decoded once per scan, then every
doclen lookup is an in-RAM binary search – no per-posting sidecar page reads)
while being invalidation-safe: nothing is stored in rd_amcache, so a
concurrent merge cannot free memory a live cursor borrows. (Cross-query caching
was dropped; if cold-scan decode ever dominates at scale, a persistent
build-time sidecar directory is the follow-up.) Verified by running the
concurrent-extend TAP test 20x with no crash (it reproduced ~1-in-10 on 1.5.4).
1.5.4
Performance + correctness release making the default doclen_sidecar=on (v4)
layout both fast and exact on multi-term AND. C-only, no SQL change,
no on-disk index format change (BM25_VERSION stays 4), no REINDEX.
1. Doclen-sidecar read locality (performance). Since 1.5.0 the per-doc
length lives in a per-segment sidecar instead of inline in each posting. That
saves ~35% index size but a ranked scan of a common term (whose postings are
scattered across the whole docid space) then read roughly one sidecar buffer
per scored posting – effectively the entire sidecar chain per query. Measured
on a 2.19M-doc corpus: a common term touched 16,887 buffers with the sidecar
vs 1,432 inline, and warm ranked latency was ~2x inline and far behind
competitors. 1.5.4 decodes a segment’s whole sidecar once per backend into
a sorted (docid, byte) array cached in the index relcache entry (rebuilt only
when the metapage generation moves), then answers every doclen lookup with an
in-RAM binary search – 0 buffer reads after the first build. The common
term now touches ~2,000 buffers (level with inline) and mid-frequency ranked
queries are ~5 ms. (An ultra-common term – e.g. one in a third of all docs –
remains dominated by the posting scan itself, the same cost inline pays.)
2. Block-max WAND multi-term AND recall (correctness). A pre-existing
soundness gap in the block-max block-skip: the fast path skipped a whole posting
block whenever a single term’s cursor sat at or before the WAND pivot – but a
cursor being past the pivot does not mean it is past the block, so a later
document in the skipped block that contained BOTH query terms (and thus scored
the SUM of their contributions, which the one-term block bound never covered)
could be dropped from the top-k. On a 2.19M Wikipedia corpus the ranked top-10
for slovakia & hungary missed the true #1 and admitted lower-scoring docs.
The fix only takes the whole-block-skip fast path when no other term’s cursor
falls within the skipped block’s docid range; otherwise it advances past the
pivot exactly. This was present on BOTH the sidecar and inline layouts (it is
in the shared WAND traversal), and single-term ranked was always exact.
3. Sidecar block-max bound vs quantized doclen (correctness). The v4 sidecar stores a quantized length byte (SmallFloat, rounded DOWN), so scoring can see a slightly shorter |D| – and thus a slightly higher score – than the exact minimum |D| the block header records. The block-max WAND bound now dequantizes the block’s min |D| through the same codec, so the bound stays a true upper bound for the quantized scores and cannot wrongly prune a top-k document.
Validation: ranked top-k now matches the exact fts_bm25 top-k for AND
queries on both layouts (new regression test and_sidecar_topk_exact /
and_inline_topk_exact), verified at 2.19M docs on the exact
slovakia & hungary case that first exhibited the gap; a concurrent soak
(readers + writer + merge/vacuum loop) on a sidecar-on index stayed correct and
bounded; installcheck (PG 17/18), TAP, alloc/ascii guards, and the block-fuzzer
all pass.
1.5.3
Correctness + performance bug-fix release: the segment MERGE path produced a broken v4 doclen sidecar (empty), so a merged v4 segment’s ranked scores were wrong and its ranked scan was pathologically slow. C-only, no SQL change.
Who is affected: any index built with the default doclen_sidecar=on (v4)
under 1.5.0-1.5.2 that has undergone a segment merge or fts_vacuum/fts_merge
compaction – i.e. essentially every non-trivial actively-used v4 index. A
freshly-built, never-merged single-segment v4 index was correct; the corruption
was introduced by merge. Indexes built with doclen_sidecar=off (inline) were
never affected.
The bug: the streaming merge wrote merged postings in the 2-column (no
inline doclen) layout but never populated the merged segment’s doclen collector,
so bm25_write_doclen_sidecar returned InvalidBlockNumber for the merged
segment. A segment with 2-column postings but doclenstart = Invalid is then
read as if doclen were inline – so scoring read a garbage “doclen” from past the
tf column. Wrong doclen corrupts the BM25 length-normalization (wrong ranking)
and defeats block-max WAND pruning (the ranked scan scores far more of the
posting list than the top-k needs – the multi-second Index Searches: 0 scans
reported from the field).
The fix: the merge now feeds each surviving posting’s (docid, doclen) into
the merged segment’s doclen collector, so the merged segment gets a correct,
populated sidecar and a valid doclenstart. A regression test builds several
segments, forces a merge, and asserts the merged sidecar index returns the same
ranked top-k as an inline-built twin.
Action for operators on 1.5.0-1.5.2 with a default (v4) index: REINDEX, or
rebuild under doclen_sidecar=off, to correct any already-merged segment’s
doclen. New merges under 1.5.3 are correct. (If you were already on
doclen_sidecar=off per the 1.5.2 note, you are unaffected and need do nothing.)
1.5.2
Bug-fix release: the native-v4 ranked-scan slowdown on large, many-segment
indexes (a field-reported 1.5.x adoption blocker), plus an escape hatch. C-only,
no REINDEX (ALTER EXTENSION pg_fts UPDATE TO '1.5.2').
- Doclen sidecar lookup is now random-access, not a forward walk. The v4 scoring path reads each scored doc’s quantized length from the per-segment sidecar. 1.5.1 read it through a forward cursor, which is O(1) amortized only when scored docids are dense; on an index with a long merge/delete history the surviving docids are sparse, so a term whose postings sit at high/scattered docids forced the cursor to decode the whole sidecar up to that docid – a ranked top-k that touched tens of thousands of buffers (even for a rare term). The cursor now builds a small per-segment page directory (first-docid per sidecar page, one buffer/page, no block decode) once and BINARY-SEARCHES it to jump straight to the covering page, so a lookup is O(log pages) and total sidecar reads are bounded by the pages actually covering scored docids.
- New
WITH (doclen_sidecar = on|off)reloption (escape hatch). Defaulton(the v4 quantized sidecar).offstores doclen inline in each posting (the pre-1.5 layout) – the same ranked-scan behavior as 1.4.x – for a workload that prefers it while keeping the 1.5.x crash fixes. Both layouts are read by the same self-describing decoder, so an index can mix sidecar and inline segments and the option can be changed without REINDEX (new segments follow the current setting). - Note. Block-max WAND still does not early-terminate a very-high-df term whose per-block score bounds cluster near the top-k threshold; that is pre-existing (1.4.x) and unchanged. This release fixes the v4-SPECIFIC cost (the sidecar walk) that made a many-segment v4 index slower than the same query on v3.
1.5.1
Bug-fix release: three field-reported regressions in the 1.5.0 v3->v4 doclen
sidecar upgrade (one caused a production search outage). C-only, no SQL change,
no REINDEX (ALTER EXTENSION pg_fts UPDATE TO '1.5.1'). A 1.5.0 index –
whether an upgraded-in-place v3 or a native v4 – is read correctly by 1.5.1
with no REINDEX.
- Fixed corrupt dual-read of a pre-existing v3 index (the outage). 1.5.0
added
doclenstartINSIDEBM25SegMeta, which grew that struct (48->56 bytes). Because segment descriptors are stored inline in the metapage’ssegs[]array, a v3 metapage laid them out at the old 48-byte stride; the 1.5.0 reader cast the page straight to the larger v4 struct and read everysegs[1..]field (andgeneration) from the wrong offset -> a garbagelivedocslenbecamepalloc(4294967295)and a garbagedictstartbecame an out-of-range block seek. The metapage read is now VERSION-AWARE: a v3 metapage is deserialized at the v3 stride into the in-memory v4 struct (doclenstart= Invalid, i.e. inline doclen), and a v3 metapage is upcast to v4 in place on the first metapage mutation. A compile-time assert now pins the v3/v4 head-layout contract so a future field insertion cannot silently reintroduce this. - Fixed the v4 ranked-scan slowdown on many-segment indexes. The per-segment doclen sidecar was BULK-LOADED (whole segment) at scan start, so a ranked/@@@ scan was O(segment docs) per query regardless of matches – on an index with a long merge history (many segments) this read tens of thousands of buffers for a small top-k. Scoring now reads the sidecar through a FORWARD CURSOR that touches only the pages covering the docids actually scored (bounded by the WAND’s ascending docid walk), matching the posting scan’s own block-skip.
- Note on high-df ranked latency. Block-max WAND does not early-terminate a high-frequency term whose per-block score bounds cluster near the top-k threshold; this is a pre-existing property of the docid-ordered index (present in 1.4.x, not introduced by v4 – measured v4 is faster than v3 on the same high-df term). It is unchanged here; a future impact-ordered format is the only lever and is not in this release.
1.5.0
Storage + performance release: per-document length moves out of the posting
lists into a per-segment quantized sidecar (on-disk format v3 -> v4). C-only,
no REINDEX (ALTER EXTENSION pg_fts UPDATE TO '1.5.0').
- Doclen sidecar (the size + common-term-latency win). BM25 needs each
document’s length for its length-normalization, but pg_fts stored it once per
posting (once per doc x term) – the widest posting column. 1.5.0 stores it
once per document as a single quantized byte (a Lucene/Tantivy-style
fieldnorm: 5-bit exponent + 3-bit mantissa) on a per-segment sidecar page
chain, and scoring reads that byte instead of decoding a per-posting column.
Measured on 2M high-vocabulary docs:
- index 37.5% smaller (954 MB -> 596 MB);
- common-term ranked top-10 7.7x faster (17.5 ms -> 2.3 ms), top-100 5.6x faster (18.4 ms -> 3.3 ms), because each posting block is ~56% smaller and length-normalization is now a byte lookup;
- rare/mid ranked, boolean AND, phrase, prefix, and count(*) unchanged.
avgdlstays EXACT (from the per-segment sumdoclen/ndocs); only the per-doc normalization denominator is quantized, which changes BM25 score ORDERING by at most a fraction of a percent at tie boundaries (the accepted fieldnorm tradeoff; verified <= 0.2% on the 2M rig).
- No REINDEX; dual-read + lazy migration. A v4 build reads existing v3 segments (inline doclen) and new v4 segments (sidecar) in the same index; the block’s column count is self-describing from its byte length, so decode is correct across mixed-version segments. Segments migrate to v4 as merge/vacuum rewrites them – an existing index keeps working untouched and converges to the smaller format over time with no operator action.
1.4.1
Bug-fix release: two field-reported robustness fixes on high-vocabulary corpora
under heavy churn. C-only, no index format change, no REINDEX
(ALTER EXTENSION pg_fts UPDATE TO '1.4.1').
Ranked scan no longer degrades under tombstone bloat. A ranked (
ORDER BY d <=> query) scan checks each candidate docid against the segment’s deleted-doc (tombstone) map. That check used an 8-way MRU chunk cache which degenerates to an O(chunks) head-walk per lookup once an ascending scan runs past its eight cached chunks – so a segment carrying millions of tombstones (e.g. after a full-tableUPDATE+VACUUM) turned a common-term top-k from ~30 ms into tens of seconds (measured 24 s for a 735k-df term at 2.2M docs; ~99.9% of the time was in the sparsemap walk). The tombstone check now uses a forward-resume cursor (sm_cursor_t) that resumes the walk from the last located chunk, restoring O(postings + chunks). Validated at 2M docs with ~4M tombstones: 24 s -> 2.5 ms.Fuzzy/regex/NOT candidate scan is memory-bounded. When no trigram acceleration is available (
trigrams = off, the default, or a pattern too short to yield trigrams), fuzzy/regex/NOT queries fall back to a whole-segment candidate scan. That fallback accumulated every posting (Sum of df across the dictionary – the whole expanded inverted index) before de-duplicating, so on a high-vocabulary corpus it could attempt a multi-gigabyte allocation and fail with “invalid memory alloc request size”. It now folds duplicates as it collects, keeping peak memory O(ndocs) regardless of Sum(df). Results are unchanged (the fallback still returns the exact set).
1.4.0
Feature release: field-targeted (weight-zone) search. No index format change,
no REINDEX (ALTER EXTENSION pg_fts UPDATE TO '1.4.0').
Field zones via tsvector-style weight labels A/B/C/D. Tag a sub-document with a weight and concatenate labelled parts so a query term can restrict itself to a field:
CREATE INDEX ... USING fts (( to_ftsdoc('english', subject, 'A') || to_ftsdoc('english', body, 'C'))) WITH (positions = on); ... WHERE d @@@ to_ftsquery('english', 'vacuum:A & tgl:B')New:
to_ftsdoc(regconfig, text, "char"),setftsweight(ftsdoc, "char"), theftsdoc || ftsdocconcatenation operator, and the query syntaxterm:A/term:AB(a term restricted to one or more zones), mirroring standardto_tsquery('english','term:A').to_ftsdoc(tsvector)now also carries the tsvector’s own A/B/C/D weights.- Upgrade is a no-op for existing indexes. Weight labels live only in the
ftsdoc VALUE (the top 2 bits of each token position); the on-disk index
posting format is unchanged. A field-restricted query is answered via the
heap recheck (like fuzzy/regex), so existing indexes keep working and queries
without a
:labelbehave exactly as before. To get field provenance, rebuild a table’s ftsdoc from labelledto_ftsdoc(...,weight) || ...documents (opt-in per table – NOT a global reindex). An unlabelled document reads as label D, soterm:Dmatches it andterm:Adoes not. - Field restriction requires the ftsdoc to carry positions (the labels ride on
positions); build the index
WITH (positions = on)for field-restricted ranked/count queries. BM25 scoring stays document-level – a zone filter changes which documents match, not how a matching document scores. - Limitation: weight labels apply to plain terms;
term:A*/term:A~k(weight- prefix/fuzzy/regex) are rejected as a syntax error.
1.3.2
Performance bug-fix release. No on-disk format change from 1.3.1; no
REINDEX required (ALTER EXTENSION pg_fts UPDATE TO '1.3.2').
- Fixed a ~5x common-term ranked-latency regression introduced in 1.3.0.
The 1.3.0 ranked-exactness hardening replaced the block-max WAND scan’s O(1)
per-block skip with a per-posting re-pivot on every prune, which on a common
term turned each block-max prune into ~128 re-pivots (measured on 2.19M
Wikipedia, PostgreSQL 18:
yeartop-10 rose 31.8ms -> 163ms; the slowdown scaled with document frequency – mid-frequency terms ~2.6x, rare terms unaffected). The exactness bug that hardening guarded against is latent (not reachable in practice – segments own contiguous, non-overlapping docid ranges), so the trade was a real regression for defense against an unreachable bug. The fix restores the O(1) block-skip in the common single-segment / few-term case while keeping the safe per-posting seek for the densely- interleaved multi-segment case, so ranked exactness is fully preserved. After the fix, common-term ranked latency is competitive with the fastest block-max-WAND BM25 extensions again (yeartop-10 ~28ms, top-100 ~29ms), and rare/mid terms lead. Anyone on 1.3.0 or 1.3.1 doing ranked (<=>) searches on common terms should upgrade.
1.3.1
Correctness bug-fix release. No on-disk format change from 1.3.0; no
REINDEX required (ALTER EXTENSION pg_fts UPDATE TO '1.3.1').
- Stopwords are now dropped from the query, not just the document.
to_ftsdoc(regconfig, text)removes a configuration’s stopwords (e.g.the,a,of), butto_ftsquery(regconfig, text)previously kept them verbatim, so a stopword query term was unsatisfiable and silently zeroed a boolean AND (the & postgresmatched nothing;the vacuum problemunder implicit-AND returned far fewer results thanvacuum problem).to_ftsquerynow runs each term through the same dictionary pipeline and elides stopword terms from the query tree – matching standardto_tsquery:to_ftsquery('english','the & postgres')reduces topostgres, and an all-stopword query becomes empty (matches nothing). Prefix/fuzzy/regex terms are matched literally and are never stopword-dropped. Reported against a 2.8M-document English email-body index in production. - Known limitation (tracked, not fixed here):
and/or/not/nearare reserved query operators and cannot be searched for as literal words; on natural-language corpora they are stopwords and dropped anyway.
1.3.0
Feature + hardening release. No on-disk format change from 1.2.2; no
REINDEX required (ALTER EXTENSION pg_fts UPDATE TO '1.3.0').
WITH (trigrams = on|off)reloption (default OFF). The per-segment trigram tier accelerates only regex and long fuzzy queries; the query side already falls back to a full dictionary scan when it is absent, so the default now omits it – a smaller index (~18% in a 2.19M-doc measurement) at no correctness cost. BuildWITH (trigrams = on)for regex- or long-fuzzy-heavy workloads. Results are identical either way.- Faster
count(*). A single plain term over a tombstone-free, pending- free, fully-all-visible index is now counted straight from the dictionary document frequency – no posting decode, no heap probe (measured: a common term at 2.19M docs, ~756 ms -> ~2 ms). Set-membership decodes skip the tf/ doclen columns they never use. Thecount(*)index pushdown now also fires for a plain-columnftsindex (the recommended stored-ftsdoc-column form), not only an expression index – previously a stored-columncount(*)fell back to a bitmap heap scan. - Managed-service hardening.
fts_merge()andfts_vacuum()now refuse to run during recovery (a hot standby is read-only) and require the caller to own the target index (they open it by OID and take heavy locks). Two functions that emit indexed content by index OID –fts_search()andfts_anomalous_docs()– are revoked fromPUBLIC(the index owner and superusers keep access; an owner may grant explicitly). Corpus statistics (BM25 IDF + length normalization) now exclude recently-dead tuples surfaced to the build withtupleIsAlive = false– such tuples are still indexed (an old snapshot may need them) but no longer inflate the document count / total length. - Ranked-exactness hardening (internal). The block-max WAND pivot skip now advances every cursor at or before the pivot rather than skipping one cursor’s whole block, removing a provably-unsound (though not field-reachable) over- skip. No behavior change on pg_fts’s contiguous-docid-range segments.
1.2.2
Bug-fix release. No on-disk format change from 1.2.1; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.2.2'). Two fts_vacuum fixes.
- Fixed
fts_vacuum()growing the index instead of shrinking it. The 1.2.1 deletion-XID recycle gate (which protects a concurrent scan from reading a just-freed page) also blockedfts_vacuum()’s compaction from repacking into the low pages it had itself just freed, so the vacate+pack phase extended the relation andfts_vacuum()GREW the index on every call and never truncated a tail. Compaction now reuses freed pages again andfts_vacuum()compacts to a stable floor (measured: a churned index 143 MB -> 60 MB, idempotent). If you ranfts_vacuum()on 1.2.1 and it did not shrink, re-run it on 1.2.2. - Fixed a rare crash from
fts_vacuum()(or autovacuum) concurrent with reads.fts_vacuum()ran under a lock that does not block scans, so a reader could still be copying a segment’s pages while compaction recycled and overwrote them, corrupting the read (a rare SIGSEGV under heavy simultaneous read + insert + merge + vacuum). Page recycling during compaction is now bypassed only under an exclusive lock, andfts_vacuum()takesAccessExclusiveLockon the index (likeREINDEX) so its in-place shrink is safe; autovacuum’s cleanup keeps the gate and reclaims space across passes without blocking or corrupting concurrent scans. (Root-caused with AddressSanitizer; a concurrent read+vacuum regression test now gates CI.)
1.2.1
Bug-fix release. No on-disk format change from 1.2.0; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.2.1'). Fixes three production
failures on a continuously-written index and hardens the scan-vs-merge path.
- Fixed the 128-segment cap becoming an unrecoverable outage. On an index
whose rows mostly exceed one page, every insert creates a segment; the live
count could reach the internal 128-segment maximum and then reject all further
writes – and neither
VACUUMnorfts_merge()could recover (merging needs a free slot to flush pending into, a chicken-and-egg deadlock; onlyREINDEXescaped). Adding a segment now merges to free a slot and retries instead of erroring, so a write is never refused because compaction fell behind. In addition, the index now compacts continuously on the write path (leveled LSM merge after each segment-creating flush), keeping the segment count bounded (O(log N) tiers) automatically – no periodicVACUUM/fts_merge()needed to stay healthy under continuous ingestion. - Fixed
ERROR: unexpected data beyond EOFwhenfts_merge()/VACUUMran concurrently with ingestion. Relation extension was only locked during a parallel build, so two ordinary backends extending the index at once (an insert flush and a merge/vacuum) could race. The extension is now always locked around the single page add, as heap and the core index AMs do; concurrent writers still proceed in parallel.fts_merge()is now safe to run while writes continue. - Fixed a crash / wrong count from
count(*)on an@@@query under plan caching or concurrency. The count-pushdown plan stored the query as a bare internal pointer, which dangled once its planning memory was freed (e.g. acount(*)in a PL/pgSQL loop, or concurrent re-execution), corrupting the query and crashing the backend. The query is now deep-copied into the plan. - Hardened concurrent scan vs. merge/vacuum with a deletion-XID page-recycle
gate (a freed page is not reused until no in-progress scan could still
reference it) plus bounds checks on all page-derived lengths, so a scan that
races page recycling degrades to a retry rather than a crash. Format-
preserving (no
REINDEX).
1.2.0
Minor release, re-numbered from the 1.1.6 and 1.1.7 patch releases. It contains exactly the 1.1.6 + 1.1.7 changes below.
Reindex recommended. 1.1.6 changed ranked-scan (ORDER BY <=>) results.
Because the query behavior over an index changes, we group this as a minor
release (not a patch) and recommend rebuilding every fts index after
upgrading so ranked results are consistent for anyone who observed the old
truncated output. (There is no on-disk format change in 1.1.6/1.1.7/1.2.0 –
the read-path fix is correct against an existing index without a rebuild – but
the re-numbering exists precisely so this class of behavior change is never
shipped as a silent patch again.) If you already upgraded to 1.1.6 or 1.1.7,
upgrade to 1.2.0 (ALTER EXTENSION pg_fts UPDATE TO '1.2.0').
Upgrade steps:
ALTER EXTENSION pg_fts UPDATE TO '1.2.0';
REINDEX INDEX CONCURRENTLY your_fts_index; -- recommended; repeat per fts index
- Fixed
ORDER BY doc <=> query(ranked) index scans silently returning fewer rows than match. A ranked query retrieved through the KNN/ordered index scan capped at ~4096 rows regardless of how many documents actually matched (e.g. 6057 or 19347 matches both returned ~4096), independent ofLIMIT– so ranked search dropped and mis-ordered results. The ordered scan had an internal top-k ceiling meant to bound worst-case latency, but a KNN index scan must return every matching row in score order (the query’sLIMITis the only bound). The ceiling is removed: the scan now returns the complete match set in order. A smallLIMIT(a page of results) is still served cheaply. The plain@@@match path was always complete and is unaffected. - Build progress logging. A build over a large corpus of long documents
(full email bodies, source code) is dominated by per-document text analysis
(tokenize + stem). A serial build (
max_parallel_maintenance_workers = 0) on a multi-gigabyte corpus can legitimately run for many minutes before the first segment is flushed – the in-memory buffer fills only after a whole budget’s worth of large documents, during which the segment count does not change and nothing is written yet, which is hard to tell apart from a hang. The build now emits aLOG-level line as documents are analyzed and at each segment flush (setlog_min_messages = logto see them), so a long build is distinguishable from a stuck one. Verified end to end on a 1.97M-document / 54 GB-of-text high-vocabulary corpus: a serialenglish-configuration build completes, collapses to a single segment, and is valid and queryable; the wall-clock cost is the inherent analysis cost, which parallel workers reduce proportionally. - Documentation: expanded the large-build guidance with a build-time/throughput section (analysis is the dominant cost and is embarrassingly parallel; how to read the new progress logs).
1.1.7
Bug-fix / observability release. Superseded by 1.2.0 (which relabels 1.1.6+1.1.7 as a minor release and documents the REINDEX requirement). No on-disk format change from 1.1.6.
- Build progress logging. A build over a large corpus of long documents
(full email bodies, source code) is dominated by per-document text analysis
(tokenize + stem). A serial build (
max_parallel_maintenance_workers = 0) on a multi-gigabyte corpus can legitimately run for many minutes before the first segment is flushed – the in-memory buffer fills only after a whole budget’s worth of large documents, during which the segment count does not change and nothing is written yet, which is hard to tell apart from a hang. The build now emits aLOG-level line as documents are analyzed and at each segment flush (setlog_min_messages = logto see them), so a long build is distinguishable from a stuck one. Verified end to end on a 1.97M-document / 54 GB-of-text high-vocabulary corpus: a serialenglish-configuration build completes, collapses to a single segment, and is valid and queryable; the wall-clock cost is the inherent analysis cost, which parallel workers reduce proportionally. - Documentation: expanded the large-build guidance with a build-time/throughput section (analysis is the dominant cost and is embarrassingly parallel; how to read the new progress logs).
1.1.6
Bug-fix release. No on-disk format change from 1.1.5; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.1.6').
- Fixed
ORDER BY doc <=> query(ranked) index scans silently returning fewer rows than match. A ranked query retrieved through the KNN/ordered index scan capped at ~4096 rows regardless of how many documents actually matched (e.g. 6057 or 19347 matches both returned ~4096), independent ofLIMIT– so ranked search dropped and mis-ordered results. The ordered scan had an internal top-k ceiling meant to bound worst-case latency, but a KNN index scan must return every matching row in score order (the query’sLIMITis the only bound). The ceiling is removed: the scan now returns the complete match set in order. A smallLIMIT(a page of results) is still served cheaply; only an explicit large/unbounded ranked scan does the deeper work. The plain@@@match path was always complete and is unaffected.
1.1.5
Bug-fix and usability release. No on-disk format change from 1.1.4; no
REINDEX required (ALTER EXTENSION pg_fts UPDATE TO '1.1.5').
- Fixed a very slow
CREATE INDEX CONCURRENTLYfinalization on a large, high-vocabulary index. After the merge converged, the CONCURRENTLY validation phase could run for a very long time at 100% CPU without completing. It built a per-segment live-document set one dictionary term at a time, which was quadratic on a segment with millions of low-frequency terms. It now builds that set in one linear pass. On a ~1.9M-document body corpus the validation phase drops from over an hour (not completing) to a few minutes. - New GUC
pg_fts.build_mem_ceiling_mb(default0= previous behavior): the per-participant flush-budget ceiling, in MB. Raising it lets a large build flush fewer, larger segments so the segment count stays well under the internal limit – useful when you have spare RAM and want less post-scan merging. Peak build memory is aboutshared_buffers + (max_parallel_maintenance_workers + 1) * pg_fts.build_mem_ceiling_mb.
1.1.4
Bug-fix release. No on-disk format change from 1.1.3; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.1.4').
- Fixed segment merges that could fail to converge, or crash, on a large,
high-vocabulary index. Two issues in the merge path, both exposed only at
scale (validated on a ~1.9M-doc / 40GB+ index):
- A merge discarded its finished output and retried if the segment directory changed at all while it ran (for example a concurrent flush appending a segment). On a big index each merge takes minutes, so it could read a great deal and never commit – the reported “reads hundreds of GB, segment count never drops” non-convergence. A merge now re-locates its inputs by content and commits alongside concurrent flushes.
- A committed merge freed its input pages, which the next merge could recycle for its output while still reading the previous chain – leading to a rare crash (SIGBUS) on very large merges. Merges now write to freshly extended pages during the merge loop and reclaim the freed space afterward. These are memory/scale-path fixes only; the on-disk format is unchanged.
1.1.3
Bug-fix release. No on-disk format change from 1.1.2; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.1.3').
- Fixed non-converging merges on a large, high-vocabulary index. On a
multi-tens-of-GB index with many similarly-sized segments (e.g. a ~1.9M-doc /
~45GB email-body corpus),
fts_mergeand the build’s compaction could run for hours without finishing: the segment merge was size-tiered with unbounded fan-in, so it tried to merge essentially all segments into one in a single pass over the whole index. The merge is now leveled (LSM/HanoiDB-style) with bounded fan-in: a segment’s level is derived from its size and no single merge combines more than a bounded number of segments, so compaction proceeds in small, discrete, observable steps (each merge logsmerging N of M segmentsatDEBUG1) with bounded write amplification and always converges. Validated on a 1.9M-doc / 26GB corpus: build to a bounded tiered set in ~17 minutes;fts_mergecollapse to a single segment in ~8 minutes; previously it did not complete. The segment level is computed from size, not stored, so the on-disk format is unchanged and no REINDEX is needed.
1.1.2
Bug-fix release. No on-disk format change from 1.1.1; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.1.2').
- Fixed a hang in the final phase of a large
CREATE INDEX CONCURRENTLY. On a large, high-vocabulary corpus, a build could get all the way through the merge (the 1.1.1 fix) and then wedge in finalization – the build leader parked waiting on parallel workers while another backend blocked on the relation-extension lock, with the index never becoming valid. The build finalization started a second parallel worker set to merge the segments; on a large index underCONCURRENTLYon a busy host, the participants could contend on the relation-extension lock and stall indefinitely. Finalization is now serial (the 1.1.1 O(N) merge made the parallel pass unnecessary for convergence), so no relation-extension contention arises and the build completes.fts_merge()still merges in parallel when run on its own.
1.1.1
Bug-fix release. No on-disk format change from 1.1.0; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.1.1').
- Fixed quadratic (O(N2)) time in
VACUUM/ bulk-delete on a large index. 1.1.0 fixed the same pattern in the build’s trigram writer; three more instances remained on the delete/vacuum path (building a segment’s live-docid set and the tombstone sets one member at a time re-walked the sparsemap from the start on every insert). Harmless on a small index, but on a large index aVACUUMor a largeDELETEcould spend a very long time in tombstone construction — the same “time explodes at scale” behavior the build had. All corpus-scale sparsemap builds now use the bulk O(N) path. Tombstone results are unchanged (verified against a delete +VACUUMcycle). - Docs: added a build time model and serial/low-parallelism recommendation
(the final single-segment collapse is a single-backend pass over the whole
index, so budget it in a maintenance window or leave the index tiered), and
noted that
fts_index_stats()(likefts_index_nsegments()) can be polled on an in-progress index.
1.1.0
Build-convergence and operability release. No on-disk format change from
1.0.8; no REINDEX required (ALTER EXTENSION pg_fts UPDATE TO '1.1.0').
- Fixed a non-converging index build on large, high-vocabulary corpora. Building an index over many long, high-vocabulary documents (full email bodies, source code, patches – many distinct low-frequency terms per doc) could enter a merge phase that ran for hours with no forward progress and never completed, even though memory stayed bounded. The cause was a quadratic (O(N2)) construction of each trigram’s term-set during the merge; it is now built with a bulk O(N) path. A build that previously did not finish in 8.5 hours completes in minutes.
- Large builds now always converge, in bounded steps. Instead of always
collapsing to a single segment at the end of a build (a single-backend pass
over the whole index), a build whose total size exceeds the new
pg_fts.build_collapse_max_mbGUC (default 4096 MB) stops at a bounded, size-tiered set of segments. The index is valid and fully queryable; ranked scans traverse a bounded handful of segments (a small fixed cost). Runfts_merge(index)to collapse to a single optimal segment in a maintenance window. Set the GUC to 0 to always collapse (the historical behavior), or raise it to collapse larger indexes during the build. - Build monitoring. Per-merge progress is logged at
DEBUG1(segments in, terms/docs written, elapsed), and aLOGline reports when a build stops at a tiered set.fts_index_nsegments()now works on an in-progress (indisvalid = f) index, so a build can be polled to watch its segment count fall as merges complete. - Docs: a new “Building indexes on large or high-vocabulary corpora” section
covers the build-memory formula (including
shared_buffers), the collapse GUC, monitoring, and partitioning.
1.0.8
Bug-fix release. No on-disk format change from 1.0.7; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.8').
- Fixed out-of-memory / worker SIGKILL during a large index build’s merge
phase. The 1.0.6 flush-budget ceiling bounds the scan phase, but the merge
phase held two vocabulary-proportional working sets that no setting bounded:
it loaded every input segment’s entire dictionary into memory (and the final
reduction opens all segments at once), and it accumulated the whole merged
vocabulary before writing the dictionary. On a large, high-vocabulary corpus
(tens of millions of distinct terms) each could reach multiple gigabytes
regardless of
maintenance_work_mem, so a big build could exhaust host memory in the merge phase even at a configuration whose scan phase fit comfortably. The merge now reads each input dictionary a page at a time and spills the output dictionary metadata to a temporary file, so its memory no longer scales with the corpus vocabulary. Measured peak build memory roughly halved on a high-vocabulary corpus, with a much flatter growth curve; build time did not regress. (Memory-only change: on-disk format is byte-identical, no REINDEX.)
1.0.7
Bug-fix and usability release. No on-disk format change from 1.0.6; no
REINDEX required (ALTER EXTENSION pg_fts UPDATE TO '1.0.7').
- Fixed a concurrent scan-vs-merge stale read (the A1 hazard). A scan
snapshotted the segment directory, released the metapage lock, then walked
segment pages holding only per-page share locks; a concurrent
fts_merge/fts_vacuum/ autovacuum could free those pages and a concurrent insert recycle them, so the scan followed a now-stale segment descriptor and returned a wrong (too-low) match count or ranking. The metapage now carries agenerationcounter bumped on every segment-directory change (segment add/merge/free and the bulkdelete livedocs-pointer swap); a scan records it at its snapshot and re-checks it before trusting the result, restarting from a fresh snapshot if it moved. The counter lives after the segment array, so existing indexes are unaffected – no format change, no REINDEX. Reproduced and verified deterministically (test/a1_recycle/). to_ftsdoc(tsvector): build anftsdocdirectly from an existingtsvector(lexemes and positions mapped straight across, no re-analysis). Lets you index a storedtsvectorcolumn, or migrate ato_tsvectorworkload, without re-parsing text. Positions are preserved only when every lexeme has them (a stripped tsvector indexes positionless).- Long-running scans,
CREATE INDEX CONCURRENTLYvalidation, andfts_countare now promptly cancellable. Added interrupt checks at lock-free points in the scan and trigram-scan page walks, so a query stuck on a large or pathological index responds to statement timeout / Ctrl-C instead of spinning to completion.
1.0.6
Bug-fix release. No on-disk format change from 1.0.5; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.6').
- Fixed runaway memory during a long index build over a large corpus
(the phase after the 1.0.5 fix). The per-participant build memory budget grew
geometrically with no ceiling, so a large build’s in-memory working set could
climb to tens of gigabytes before flushing; combined with one budget per build
participant (the leader plus each parallel worker), a big parallel build could
exhaust host memory and thrash into swap hours in. The budget is now capped at
2 x maintenance_work_mem, so peak build memory is bounded to(max_parallel_maintenance_workers + 1) x 2 x maintenance_work_memregardless of corpus size. (Peak build memory scales with the worker count – sizemaintenance_work_memwith that multiplier in mind.) - A freshly built or REINDEXed index now reclaims the free tail its final
merge leaves on disk, so it ships closer to its compacted size instead of
carrying the merge’s freed-input pages until the next VACUUM. (Full
compaction of interior free space still comes from
fts_vacuum/ VACUUM.)
1.0.5
Bug-fix release. No on-disk format change from 1.0.4; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.5').
- Fixed sustained, unbounded-looking memory growth during a long index build
over a high-vocabulary text column (e.g. email/message bodies with quoted
chains, patches, and code – millions of distinct terms). The build bounds its
memory by flushing a segment when the in-memory working set exceeds
maintenance_work_mem, but the size check did not count the term hash table, which lives in a child memory context – so on a vocabulary-dominant corpus the check undercounted the real working set and the flush fired far too late, letting a multi-hour build grow well pastmaintenance_work_mem(observed ~19 GB resident+swap on an 83 GB / 1.8M-row build before it was killed). The check now counts child contexts, so a build settles at roughlymaintenance_work_memregardless of vocabulary size. Affects both serial and parallel builds; no change to results or on-disk format.
1.0.4
Scale-hardening release. No on-disk format change from 1.0.3; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.4').
CREATE INDEXon a large table with a modestmaintenance_work_memno longer fails with “reached the maximum of 128 segments.” The build flushed one segment permaintenance_work_memof accumulation with no intermediate merge, so a large enough index overflowed the segment directory before the end-of-build merge. Each build participant now grows its own flush budget geometrically, so the flush count grows only logarithmically with corpus size (and stays far under the cap) – parallel-safe, with no on-disk or behavior change to the finished index.- Corrected integer-width overflows that only surface at extreme scale (wrong
results, not crashes): document-frequency sums used in IDF/BM25 scoring and in
fts_index_df()are now 64-bit (a term in more than ~4 billion documents no longer wraps); the anomaly-scan “skip common terms” filter no longer misfires on a term whose df exceeds ~2.1 billion. fts_index_stats’sntermsoutput is nowbigint(wasint), so an index with more than ~2.1 billion total distinct terms reports a correct count. This widens the function’s output column; the upgrade replaces the function definition (aDROP/CREATE, applied automatically byALTER EXTENSION ... UPDATE).
1.0.3
Bug-fix release. No on-disk format change from 1.0.2; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.3').
- Fixed an
invalid memory alloc request sizecrash in a parallel index-build worker (reported against a ~13-hour parallelCREATE INDEX CONCURRENTLYover a large table with a very large vocabulary). This was a different site from the 1.0.2 fix: the trigram inverted-index builder and the streaming segment merge sized several allocations from the merged-group vocabulary, which – unlike the per-segment build path – is not bounded bymaintenance_work_mem, so a hot trigram’s term list (and the merge output arrays) could exceed the 1 GB allocation limit on a large enough corpus. All corpus/vocabulary-scale allocations in the build, merge, and analyze paths now use a huge-safe allocation, closing this crash class off across the board. - A single document that would assemble into an
ftsdoclarger than 1 GB now reports a clear “document is too large” error instead of an opaque allocation failure. Anftsdocis a variable-length value limited to 1 GB.
1.0.2
Bug-fix release. No on-disk format change from 1.0.1; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.2').
- Fixed a read-path crash (
invalid memory alloc request size) when decoding a posting block with a very large per-block position count. The positions-decode path inbm25_decode_termsized its scratch buffers with an unguarded allocation; when a block’s summed term frequency pushed the buffer past the 1 GBMaxAllocSizelimit, the allocation threw and aborted whatever triggered the decode – aCREATE INDEX CONCURRENTLYvalidation scan in the reported case, but the same path is reached by ordinary scans (count, ranked, phrase), merges, and vacuum. A legitimately large position count now uses a huge-safe allocation; a corrupt or inflated on-disk term-frequency (a class the existing block-header and column-length corruption checks did not catch) is now detected and rejected with aWARNING(a bounded miss,REINDEXto rebuild), rather than reading past the block. This was the read-side counterpart of the build-time huge-allocation fix; both sides are now guarded. - The corruption/fuzz harness gained a dedicated planted-bug (“teeth”) build for this class, so a regression that removed the guard would fail CI.
1.0.1
Bug-fix release. No on-disk format change from 1.0.0; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.1').
- Fixed an out-of-memory crash when building or merging a large index. The
segment-merge phase (used by the final compaction of an index build, by
fts_merge, and by parallel builds) decoded every posting of every term from all merged segments into memory at once before writing the result, so compacting a large index could hold the entire index’s postings in RAM and OOM the server. Merging is now a bounded, streaming k-way merge that holds only one term’s postings at a time; peak merge memory is independent of index size. Measured on a 3M-document build: peak merge memory dropped from 2240 MB to ~1 MB, with the same result and no build-time regression. No on-disk format change and results are unchanged (index-vs-sequential-scan parity, with positions, tombstone drops, and phrase queries all preserved).
1.0.0
First stable release. No on-disk format change from 0.3.x; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '1.0.0'). The on-disk format
(BM25_VERSION 3, FTS_DOC_VERSION 3) and the SQL surface are now considered
stable; future 1.x releases keep backward compatibility.
fts_vacuumnow converges and reclaims space in a single call, never grows the index, and is interruptible. Compaction previously could oscillate (transiently grow the index before shrinking) and, in a first correctness pass, could stabilize without reclaiming dead space; and no pg_fts operation checked for interrupts, so a long build/merge/vacuum could not be cancelled.fts_vacuumnow compacts to the size floor in one call, is stable across repeated calls, and never returns larger than it started. It honorspg_cancel_backendandstatement_timeout(nine interrupt-check points along the merge/vacuum path); a cancelled or out-of-disk run leaves the index valid and correct, just not fully compacted. Because compaction rewrites live data before freeing the old copy (for crash safety), it transiently needs free disk space of roughly the live index size, likeVACUUM FULL/CLUSTER/pg_repack.- The transparent
count(*) ... WHERE col @@@ qfast path is now chosen at scale. TheFtsCountcustom-scan cost model was priced against the whole heap and lost to a bitmap index scan on large tables; it is now priced as the index-only visibility-map count it actually performs, so the planner uses the faster path automatically. - Supported versions. PostgreSQL 17 and 18 are fully supported and gated in
CI (regression + isolation + TAP). PostgreSQL 19 /
master-devel builds and is exercised best-effort (it is unreleased). - Testing. The TAP suite (crash recovery, replication, torn-page recovery, server-encoding install/parity) is now a gating part of CI on both forges, alongside regression + isolation, AddressSanitizer / UndefinedBehaviorSanitizer builds, a fuzz harness for the posting-decode path, property-based tests, and a line-coverage floor.
0.3.6
fts_snippetdefault ellipsis is now ASCII...(was the UTF-8…). The non-ASCII default madeCREATE EXTENSION pg_ftsFAIL on a non-UTF-8 server database (LATIN1, EUC_JP, …) with “invalid byte sequence for encoding” – pg_fts was uninstallable there. The install SQL is now pure ASCII (guarded bymake check-asciiin CI on both forges), so pg_fts installs on every server encoding. Callers who want the…glyph pass it explicitly:fts_snippet(doc, q, ellipsis => Eu2026).- Character-encoding / multi-script correctness is now permanently tested. A
UTF-8 regression block asserts pg_fts
@@@== nativeto_tsvector @@across 14 scripts (Latin, Windows-1252 punctuation, CJK Han, Japanese, Hangul, NFC vs NFD combining marks, emoji + 4-byte astral, CJK Ext-B, Arabic/Hebrew RTL, Turkish dotless-i, German sharp-s), plus a corner-case block (fold-length changes, multi-mark combining, ZWJ, BOM, fullwidth, Cyrillic) exercising the built-in analyzer; andt/004_encodings.plgates LATIN1 + EUC_JP server encodings (install + native parity on high/multibyte bytes).
0.3.5
Hardening + testing release. No on-disk format change; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '0.3.5').
- Further hardened the segment posting-decode path against corrupt/torn
pages (three issues found by the new fuzz harness, extending the 0.3.4 fix):
the per-block
countclamp was one-sided (auint32count >= 231 cast to a negativeintand slipped past> BM25_BLOCK_SIZE) and is now tested on the unsigned value; the three FOR columns' width-driven byte consumption is now bounded against the block’s declaredbytelenbefore decoding, so a corrupt width byte cannot read past the page; and a shift-by-64 undefined behavior inbm25_for_unpackon a corrupt width (> 64) is fixed (valid widths unaffected). A corrupt block remains a bounded miss with aWARNING, never a crash. - New testing regime (see
doc/testing.md), run in CI on both forges: an AddressSanitizer+UBSan build of the regression + isolation suite; a gating fuzz/corruption harness (test/fuzz/) over the FOR codec, the stored-document validator, and the block decoder, with planted-bug “teeth”; property-based tests (test/hegel/) for the codec + validator invariants; a torn-page crash-safety TAP test (t/003_corruption.pl); and a 90% line-coverage gate on the pg_fts sources.fts_doc_is_valid’s logic was factored into a pure header (pg_fts_docvalid.h) shared with the fuzzer (behavior-identical).
0.3.4
Crash-safety bug-fix release. No on-disk format change; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '0.3.4').
- Fixed an intermittent crash in the segment posting-decode path
(
fts_doc_matches<-bm25_collect_matches<-bm25_gettuple), a follow-on to the 0.3.3 pending-list fix that its validator did not cover.bm25_decode_termread a posting block header’scountfrom disk and unpacked that many values into fixed 128-element (BM25_BLOCK_SIZE) stack arrays (gaps/tfs/dls) with no bound check – a torn or corrupt block header withcount > BM25_BLOCK_SIZEoverflowed the stack (an AddressSanitizer heap/stack-buffer- overflow, reproduced against the FOR codec). The WAND block loader already clamped its count; this decoder (used by the boolean/@@@/count-pushdown/ ranked/anomaly/trigram scan paths – everybm25_decode_termcaller) did not. It now clamps the per-block count toBM25_BLOCK_SIZEand stops decoding a block whose declared column byte lengths (bytelen/posbytelen) run past the page, so a corrupt block is a bounded miss (with aWARNINGhintingREINDEX) instead of a crash. Fixing it insidebm25_decode_termprotects all callers at once. Valid indexes are unaffected; a regression test decodes a large multi-block posting list (df >> 128, with positions).
0.3.3
Crash-safety bug-fix release. No on-disk format change; no REINDEX
required (ALTER EXTENSION pg_fts UPDATE TO '0.3.3').
- Fixed two backend crashes on the pending-list path (reported on 0.3.2 /
PostgreSQL 18 under heavy concurrent write load): a
_FORTIFY_SOURCEbuffer overflow (SIGABRT) inadd_postingduring the autovacuum pending-list flush, and a SIGSEGV infts_doc_matcheswhile scanning pending documents. Both stemmed from the pending-list readers casting raw index-page bytes to anftsdocand trusting its term metadata (nterms, per-termlen/tf/posoff) without validation, so a malformed or torn page turned a bad length into a wildmemcpyor a bad offset into an out-of-bounds read. The flush (bm25_vacuumcleanup->bm25_flush_pending) and scan (bm25_gettuple->bm25_collect_matches) paths now validate each pending document’s structure against its own byte length (fts_doc_is_valid) before trusting any offset, and skip a malformed document with aWARNING(hintingREINDEX) instead of crashing the backend.add_posting’s fixed-size term key also clamps its length defensively as a last line of defense. Valid documents are unaffected. A crash-regression test covers the long-token pending -> scan -> flush cycle.
0.3.2
Additive release. No on-disk format change; no REINDEX required for the
extension upgrade (ALTER EXTENSION pg_fts UPDATE TO '0.3.2'). Indexes over
non-ASCII text should be REINDEXed to pick up the new Unicode lowercasing;
ASCII-only indexes are unaffected.
pg_stat_user_indexesnow reflects bm25 index usage (PR #5, dinesh-salve). Every query path that reads the index registers an index scan (pgstat_count_index_scan), soidx_scan/last_idx_scanare no longer stuck at 0: the bitmap scan, the plain + ranked (ORDER BY <=>) index scans, thecount(*)pushdown /fts_count(), and nativefts_search()top-k.idx_tup_readis reported on the bypass paths too (the AM scan paths already get it from the generic index layer). A bareORDER BY <=>with no@@@filter is a seq-scan+sort and correctly stays at 0.- Unicode lowercasing in the built-in analyzer (PR #4, dinesh-salve). The
built-in
to_ftsdoc(text)/to_ftsquery(text)analyzer folded only ASCIIA-Z, so accented text never matched case-insensitively ('CAFÉ'missed'café'). A sharedfold_token()(used by the document analyzer, the query lexer, and the aux tokenizer, so both sides fold identically) now lowercases non-ASCII tokens per Unicode code point viaunicode_lowercase_simple()in UTF-8 databases; non-UTF-8 databases keep byte-wise ASCII folding. Simple lowercasing, not full case folding ('ß'stays'ß'), matching pg_search’s default, andto_ftsdoc()staysIMMUTABLE. No on-disk format change; ASCII-only indexes are unaffected. Indexes over non-ASCII text built before this change should beREINDEXed (their stored terms are unfolded). - Build-time huge-allocation fix for very high-df terms. At large diverse
corpora (found while benchmarking at 20M docs – see
bench/RESULTS_20M.md) a single ultra-common token’s build-time posting arrays can exceedMaxAllocSize(1 GB), which plainpallocrejects, aborting the index build.add_posting,bm25_decode_term, andbm25_write_postingsnow use the...Hugeallocation variants past 1 GB. No format change.
0.3.1
Additive feature release. No on-disk format change (read-only over the
existing index) and no REINDEX required. ALTER EXTENSION pg_fts UPDATE TO
'0.3.1'.
- Lexical anomaly detection:
fts_anomalous_docs(index, k, max_df). A set-returning function that surfaces the top-kmost lexically-anomalous documents in an fts index – those containing globally rare terms. A document’s anomaly score is the maximum idf over its terms (driven by its single rarest term), using the same rarity value BM25 uses:idf = log(1 + (N - df + 0.5)/(df + 0.5))on the global df (a term’s df is summed across all segments before scoring, so a document split across two segments is not made to look artificially rare). Returns(ctid tid, score float8, rarest_term text, min_df int)ordered by score DESC, limitk.- Cheap because it walks only the low-df tail. The rarest terms have the
shortest posting lists, so the function walks the term dictionary and
skips any term whose global df exceeds
max_dfbefore decoding a single posting – the common, high-df bulk of the dictionary is never decoded. On a 1M-document corpus with a handful of injected unique tokens, the query returns those docs in well under a millisecond (measured 0.6 ms), not a full-corpus scan.max_dfdefaults tomax(N/1000, 1)when NULL, keeping the walk on the low-df tail. - The returned ctids are index-resident heap pointers (like
fts_search); this is an analytic/heuristic result, so no per-doc heap visibility check is done – joinctidback to the table and filter for visibility if needed. Per-segment tombstones are honored, so deleted documents are not reported as anomalies. - Lexical only: it catches rare/novel wording and tokens, not semantic
novelty (see
bench/NOTE_ANOMALY_DETECTION.md). Bench harness inbench/anomaly.sql.
- Cheap because it walks only the low-df tail. The rarest terms have the
shortest posting lists, so the function walks the term dictionary and
skips any term whose global df exceeds
- Fixed
ftsdoctext I/O round-trip (Codeberg #3).ftsdoc_outemitted the canonical'term':tfform butftsdoc_inre-tokenized that string as raw text, softsdoc_in(ftsdoc_out(x)) != x– textCOPY/pg_dump --insertsof storedftsdoccolumns corrupted the data.ftsdoc_innow parses the canonical grammar'term':tf[@p1,p2,...](falling back to raw-text analysis for the ergonomic'the quick brown fox'::ftsdoccast), andftsdoc_out,ftsdoc_send/ftsdoc_recvnow carry per-token positions so both text and binary I/O are faithful, position-preserving round-trips. Input is validated at the trust boundary (ascending/distinct terms,tf>=1, ascending positions,tfpositions per term; corrupt binary bounded before palloc). Theftsdocbinary wire version bumped 2 -> 3;ftsdoc_recvstill accepts v2 so apg_dump -Fctaken under an older pg_fts restores cleanly (v2 docs are position-free). No on-disk index format change.
0.3.0
Feature release with an on-disk index format change (BM25 v2 -> v3). Existing
bm25 indexes must be REINDEXed; the format guard rejects a v2 index with a
REINDEX hint. No ftsdoc/ftsquery type change.
- Token positions in the postings, gated by a new
positionsreloption.CREATE INDEX ... USING fts (...) WITH (positions = on)stores per-token positions in the posting blocks (a 4th, lazily-decoded frame-of-reference column after docid-gaps/tf/doclen). Phrase and NEAR queries are then answered directly from the posting lists – intersect on docid, verify adjacency from the stored positions via the samephrase_steplogic the heap recheck uses – with zero heap access and no recheck. This removes the phrase/NEAR count cliff (a common two-word phrase count over an expression index dropped from seconds to the AND-count range in local tests) for both the expression index (to_ftsdoc(col)) and the stored-ftsdoc-column shapes.- Default is
positions = off: positions roughly double the posting bytes on high-term-frequency corpora, so the size-sensitive majority who never phrase-search pay nothing. Phrase/NEAR is always correct either way; it is only fast (index-only, no recheck) withpositions = on. Withpositions = offit falls back to the correct-but-slower heap recheck. - Positions are decoded lazily: plain BM25 ranked / boolean AND / count
queries never read or decode the positions column (a
posbytelen-guided pointer skip, mirroring the existing tf/doclen skip), so a non-phrase query pays ~zero for positions existing (measured: no regression vs v2 on a common-term ranked/count query). fts_vacuum/ merge carry positions through the compaction rewrite and keep reclaiming space; a pathological per-(term,doc) term frequency whose positions would overflow a page drops that block’s positions and phrase falls back to recheck for those docids (correctness preserved).
- Default is
0.2.4
Bug-fix release. The fix is in the shared library; no SQL objects change and no
REINDEX is required. ALTER EXTENSION pg_fts UPDATE TO '0.2.4'.
- Phrase / NEAR queries silently returned wrong results on a stored
ftsdoccolumn. The positions[] region of a document was addressed asMAXALIGN(absolute-pointer), but the analyzers lay it out atbase + MAXALIGN(offset). For a heap-resident (detoasted) document whose base is not itself MAXALIGN’d,MAXALIGN(base+off) != base+MAXALIGN(off), so the position array was mis-addressed and phrase/NEAR degraded to a plain AND (matching any document containing the terms, ignoring adjacency). Fixed to the offset-based address. Expression indexes onto_ftsdoc(col)were unaffected by this bug (freshly-analyzed, always-aligned documents); short documents in the existing tests happened to remain aligned, which is why it was missed. - Note: phrase count over an expression index on a common two-word phrase is still slow (it rechecks the whole AND-set against the heap, re-analyzing each document). A positional-index format change in a later release removes that heap recheck; this release only fixes the stored-column correctness bug.
0.2.3
Performance release. The change is in the shared library; no SQL objects change,
results are unchanged, and no REINDEX is required.
ALTER EXTENSION pg_fts UPDATE TO '0.2.3' after installing the new library.
- Ranked
ORDER BY d <=> q LIMIT kover a boolean AND/NOT query is much faster. The 0.2.1 boolean-structure correctness fix pre-collected the entire exact@@@match set before the ranked scan filtered against it, which was slow on common terms (e.g.year & hungarytop-10 took ~37 ms at 2M docs because the wholeyearposting list was materialized). The ranked scan now evaluates the query’s boolean structure lazily during the WAND traversal (from which terms are present at each candidate), with no collect pass:year & hungarytop-10 drops to ~1 ms at 2M (measured), and a near-universe NOT likeyear & !hungaryfrom ~415 ms to ~42 ms. Results are byte-identical (the ground-truth ranked-parity test passes unchanged). Pure-OR / single-term queries were already on the fast path and are unchanged; phrase/NEAR/fuzzy/ regex keep the exact-recheck path.
0.2.2
Bug-fix release. The fix is in the shared library; no SQL objects change.
ALTER EXTENSION pg_fts UPDATE TO '0.2.2' after installing the new library.
- Phrase (
"a b c") and NEAR queries now enforce term adjacency on all query paths. They previously degraded to AND (matching any document containing the terms, regardless of order/adjacency) on the primaryto_ftsdoc(regconfig, text)path — e.g.to_ftsdoc('english', body)— because that analyzer did not store token positions; and the index candidate path did not request the heap recheck that would have enforced adjacency, so non-adjacent documents leaked through@@@, the bitmap scan, and the ranked<=>scan alike. Now: the config analyzer stores positions;@@@/bitmap enforce adjacency via the executor recheck; and the ranked<=>scan andfts_count()recheck the exact match set against the heap document (bm25_recheck_exact). Phrase / NEAR / boolean ranking is exact on all paths. - No REINDEX required (bm25 on-disk format unchanged). Phrase correctness on a
stored
ftsdoccolumn populated by the old analyzer requires re-analyzing those rows; expression indexes onto_ftsdoc(...)are correct immediately. - Known limitation (documented): ranked
<=>over fuzzy/prefix/regex returns a correct subset of the@@@matches (never a wrong document, but may be incomplete, since the ranked scan builds cursors from the literal term). Use@@@for exhaustive fuzzy/prefix/regex retrieval.
0.2.1
Bug-fix release. The fix is entirely in the shared library; no SQL objects
change and existing fts indexes need no REINDEX (on-disk format unchanged from
0.2.0). ALTER EXTENSION pg_fts UPDATE TO '0.2.1' after installing the new
library.
- Ranked
<=>scan now respects boolean AND/NOT/PHRASE structure. TheORDER BY d <=> q LIMIT kordering scan previously ranked the term disjunction (it flattened the query to its terms) and never intersected with the boolean match set that@@@uses, so AND/NOT/PHRASE queries could return documents that fail@@@— e.g.a & !branked documents that containb. The ranked scan now gates results by the exact@@@match set, so every row it returns satisfies@@@.@@@matching and pure-OR / single-term ranking were already correct and are unchanged.
0.2.0
Breaking: the index access method was renamed bm25 → fts
(CREATE INDEX ... USING fts (to_ftsdoc('english', body))). This lets pg_fts
coexist in the same database as Timescale pg_textsearch (whose AM is named
bm25), so a pg_textsearch workload can be migrated one index at a time rather
than in a single hard cutover. Existing USING bm25 indexes must be recreated
as USING fts. The BM25 scoring functions (fts_bm25, fts_bm25f,
fts_bm25_opts) are unchanged — BM25 is the ranking algorithm, fts is the
access method.
- On-disk format version check: opening an index whose stored format version
does not match the loaded shared library now raises a clear error
(
... has pg_fts on-disk format version N, but this build expects M) with aREINDEXhint, instead of silently misreading the index. - New
doc/MIGRATING_FROM_PG_TEXTSEARCH.md: query/DDL rewrite table, the multi-column → concatenated-to_ftsdocpattern, and index build sizing (CREATE INDEXbounds build memory tomaintenance_work_mem).
0.1.0 — initial public release
First public release. The extension was developed as an internal, qualified
feature series (each stage clean under --enable-cassert, regression-green)
that reached internal version 1.20 before being squashed to a single 0.1.0
install script for release. Versioning starts at 0.1.0 to signal that the
on-disk format and ranked-query performance will iterate before 1.0.
Included in 0.1.0:
ftsdoc/ftsquerytypes, the@@@match operator, and the<=>relevance-ordering operator (ORDER BY d <=> q LIMIT kplans as an index scan, no Sort).- The
bm25inverted-index access method: WAL-logged via GenericXLog (crash-safe, physical-replication safe), MVCC-correct (per-segment tombstones), segmented (Lucene/Tantivy-style) on-disk format with a size-tiered background merge, block-max WAND / MaxScore top-k with lazy per-column decode. - Okapi BM25 scoring with the lucene / robertson / atire / bm25+ / bm25l variants; BM25F multi-field weighting; index-maintained corpus statistics (N, avgdl, per-term df) so ranking needs no heap recheck.
- A rich query language over one operator: boolean, phrase
"a b c", NEAR, prefixterm*, fuzzyterm~k(Levenshtein DFA), and regex/re/, with a trigram pre-filter for fuzzy/regex. fts_highlight()/fts_snippet();tsquery_to_ftsquery()migration helper and cast.- Incremental maintenance (INSERT appends to a pending list, no REINDEX);
fts_merge()andfts_vacuum()(compact + truncate) for on-demand maintenance. fts_count()and a transparentcount(*) ... WHERE @@@CustomScan pushdown for MVCC-correct bulk counts from the index — a capability the specialist BM25 extensions do not expose.- Parallel index build/merge; standalone PGXS build plus Nix flake and a Windows/MSVC meson recipe; supported on PostgreSQL 17, 18, and 19/devel.
Known performance position (see bench/RESULTS_VS_VCHORD_PGTEXTSEARCH.md):
pg_fts is far faster than the built-in tsvector/GIN + ts_rank
stack on ranked retrieval (up to ~40×), but trails the specialist BM25
extensions (VectorChord-bm25, Timescale pg_textsearch) on raw ranked latency and
index size. Closing that
gap is a posting-codec rewrite tracked in ROADMAP.md; 0.1.0 ships on its
distinguishing strengths — query-language breadth, index-native COUNT, and
MVCC/crash correctness — and will iterate on ranked performance.