# Changelog All notable changes to pg_fts are documented here. ## Unreleased ## 1.15.2 - 2026-11-03 Two fixes for ranked queries. One is a reported 60-300x slowdown after a bulk delete (ROADMAP I25). The other is a wrong top-k from the block-max WAND path that every release since 1.3.0 has had (ROADMAP I26), found and fixed by Jim Dawdy in the jimdawdy-hub/pg_fts fork. **No on-disk format change; no REINDEX; no new or changed SQL objects** (`pg_fts--1.15.1--1.15.2.sql` is a no-op). Measured on PostgreSQL 18.6 and gated on 17.10 and 18.6 (`bench/RESULTS_CHURN_2026-11-02.md`, `bench/RESULTS_152_2026-11-03.md`). ### Fixed - **Ranked top-k became 60-300x slower after deletes scattered over a large heap, until a REINDEX** (reported 2026-11-02 from a production index, 1.88M rows, after moving 23,313 rows and an `fts_vacuum`). The heap VACUUM that follows tombstones the deleted rows on the packed segment, and `fts_vacuum` keeps a single packed segment as it is, so the tombstones stay until a merge rewrites it. Each scored posting is checked against the segment's tombstone set, a sparsemap that holds about one chunk per tombstone when the deleted rows are spread across the heap. Two lookup costs grew with the chunk count: - The sparsemap cursor ignored a cached chunk that starts after the looked-up index, which is where a lookup lands when it falls in the gap before that chunk -- nearly every live docid. Every such lookup walked the chunk list from the start (fixed in the vendored `sm.c`; the cursor now resumes from the chunk before, after checking it is that chunk's neighbour). - The best-first paths visit posting blocks out of docid order and reset the cursor per block, so each visited block walked the chunk list from the start. They now seek from per-scan checkpoints: up to 1,024 tombstones spaced evenly by docid, each with a cursor on it. - `fts_anomalous_docs` checked tombstones with no cursor at all (one walk per posting); it now keeps one per term. Measured on c6id.metal, PG 18.6, a 1.9M-row `doclen_sidecar = off` index (about 150 words a row) after moving every 80th row and the same fts_vacuum / VACUUM order (`bench/RESULTS_CHURN_2026-11-02.md`): ranked top-200 of a term in 6% of rows 3,640 -> 64 ms (fresh build 54 ms), top-200 of a 1% term 238 -> 14.5 ms (10.3), top-10 of a 0.5% term 62 -> 3.5 ms (0.35); the top-200 ids of the common term are identical across builds at every step. What is left of the top-10 cost is per scan: validating the tombstone blob (25% of its profile) and building the checkpoints (about a third). The reporter's own repro script (1.88M rows, 23,313 moves, the same order), run unchanged on c6id.metal: `fts_search(..., 'remov', 200)` after the VACUUM 7,719 -> 23 ms (fresh 14 ms), `'vacuum'` 3,308 -> 13 ms (7.0), with `pg_fts.bestfirst = off` 7,552 -> 10 ms (2.1), and with three segments and no pack 7,942 -> 23 ms; a 4-term OR top-200 through the executor 111.6 s -> 162 ms. Workaround on 1.15.1 and earlier: REINDEX after a bulk delete (measured). Known issue in this release too: `fts_vacuum` and `fts_merge` return false and rewrite nothing when the index is one packed segment, so its tombstones stay until a REINDEX or a later merge (ROADMAP I25); they now cost about 1.7x a fresh build rather than 300x. New regression test `ranked_tombstones`: scattered deletes, the freed slots reused by rows without the query terms, ranked single-term / AND / OR shapes against a seq-scan oracle and the docid-order walk (6 of its 12 shapes fail when the WAND paths skip the tombstone check). - **Ranked queries could return the wrong top-k from the block-max WAND path** (since 1.3.0). When the current blocks of the cursors at or before the pivot could not beat the threshold, the skip moved them past the pivot: 1.3.0-1.10.0 jumped to `pivot + 1`, and 1.11.0 jumped to the earliest current block end but clamped that to `pivot + 1` "to make progress". When a cursor's current block ends before the pivot, a later block of that term can score the pivot higher than the current bound, so the pivot was skipped unscored. The jump now stops at that block end; the cursor sits at or below it, so it still moves. Found and fixed by Jim Dawdy in the jimdawdy-hub/pg_fts fork (e4bba08, never submitted upstream); his regression test `bmw_block_end` returns id 1 as the top-1 of `aye | bee` (the exact answer is 5000) on 1.3.0, 1.10.0, 1.11.0, 1.13.0 and 1.15.1, and passes here (PG 17.10, 18.6). The path is taken by a ranked query whose WAND walk has two or three cursors (one per term per segment) and that takes none of the best-first or driven paths: for example a two- or three-term OR on one segment, or such a query with a phrase / prefix / fuzzy / regex filter. On a 640k-row index, pure 2- and 3-term ORs are 0.3-2.4% slower (A/A up to 1.7%); filtered queries unmeasured. ### Retracted - 1.13.0 said of its block-max WAND fixes: "No query found a wrong top-k on 1.12.1, whose best-first paths already avoid both cases." The docid-order block-max WAND path still skipped a top-k document in the `safe_until` case (above): 1.3.0 through 1.15.1 return a wrong top-1 for `bmw_block_end`. 1.3.0 described the skip that introduced it as "removing a provably-unsound (though not field-reachable) over-skip". ## 1.15.1 - 2026-10-29 One fix, for a defect in 1.15.0's new configured-analyzer path (ROADMAP I9a), found the day 1.15.0 shipped by comparing its output with an unchunked parser. **No on-disk format change; no new or changed SQL objects** (`pg_fts--1.15.0--1.15.1.sql` is a no-op). Probe comparison on PostgreSQL 17.10; gate on 17.10 and 18.6 (`bench/RESULTS_151_2026-10-29.md`). ### Fixed - **The chunked re-parse split HTML markup at chunk boundaries** (ROADMAP I9a). Affects `to_ftsdoc(config, text)` on documents over 16,383 positions, since 1.15.0. The re-parse cut the text into chunks of at most 16,383 bytes, each after a whitespace, so a tag, comment or `