Skip to content

pg_fts: a bm25 full-text search engine#27

Draft
gburd wants to merge 139 commits into
masterfrom
fts
Draft

pg_fts: a bm25 full-text search engine#27
gburd wants to merge 139 commits into
masterfrom
fts

Conversation

@gburd

@gburd gburd commented Jul 3, 2026

Copy link
Copy Markdown
Owner

No description provided.

@github-actions github-actions Bot force-pushed the master branch 5 times, most recently from 3b6f413 to 5c6cce4 Compare July 3, 2026 21:59
gburd added 3 commits July 4, 2026 16:04
  - Hourly upstream sync from postgres/postgres (24x daily)
  - AI-powered PR reviews using AWS Bedrock Claude Sonnet 4.5
  - Multi-platform CI via existing Cirrus CI configuration
  - Cost tracking and comprehensive documentation

  Features:
  - Automatic issue creation on sync conflicts
  - PostgreSQL-specific code review prompts (C, SQL, docs, build)
  - Cost limits: $15/PR, $200/month
  - Inline PR comments with security/performance labels
  - Skip draft PRs to save costs

  Documentation:
  - .github/SETUP_SUMMARY.md - Quick setup overview
  - .github/QUICKSTART.md - 15-minute setup guide
  - .github/PRE_COMMIT_CHECKLIST.md - Verification checklist
  - .github/docs/ - Detailed guides for sync, AI review, Bedrock

  See .github/README.md for complete overview

Complete Phase 3: Windows builds + fix sync for CI/CD commits

Phase 3: Windows Dependency Build System
- Implement full build workflow (OpenSSL, zlib, libxml2)
- Smart caching by version hash (80% cost reduction)
- Dependency bundling with manifest generation
- Weekly auto-refresh + manual triggers
- PowerShell download helper script
- Comprehensive usage documentation

Sync Workflow Fix:
- Allow .github/ commits (CI/CD config) on master
- Detect and reject code commits outside .github/
- Merge upstream while preserving .github/ changes
- Create issues only for actual pristine violations

Documentation:
- Complete Windows build usage guide
- Update all status docs to 100% complete
- Phase 3 completion summary

All three CI/CD phases complete (100%):
✅ Hourly upstream sync with .github/ preservation
✅ AI-powered PR reviews via Bedrock Claude 4.5
✅ Windows dependency builds with smart caching

Cost: $40-60/month total
See .github/PHASE3_COMPLETE.md for details

Fix sync to allow 'dev setup' commits on master

The sync workflow was failing because the 'dev setup v19' commit
modifies files outside .github/. Updated workflows to recognize
commits with messages starting with 'dev setup' as allowed on master.

Changes:
- Detect 'dev setup' commits by message pattern (case-insensitive)
- Allow merge if commits are .github/ OR dev setup OR both
- Update merge messages to reflect preserved changes
- Document pristine master policy with examples

This allows personal development environment commits (IDE configs,
debugging tools, shell aliases, Nix configs, etc.) on master without
violating the pristine mirror policy.

Future dev environment updates should start with 'dev setup' in the
commit message to be automatically recognized and preserved.

See .github/docs/pristine-master-policy.md for complete policy
See .github/DEV_SETUP_FIX.md for fix summary

Optimize CI/CD costs by skipping builds for pristine commits

Add cost optimization to Windows dependency builds to avoid expensive
builds when only pristine commits are pushed (dev setup commits or
.github/ configuration changes).

Changes:
- Add check-changes job to detect pristine-only pushes
- Skip Windows builds when all commits are dev setup or .github/ only
- Add comprehensive cost optimization documentation
- Update README with cost savings (~40% reduction)

Expected savings: ~$3-5/month on Windows builds, ~$40-47/month total
through combined optimizations.

Manual dispatch and scheduled builds always run regardless.
Review every PR (including drafts) with two jobs that authenticate to AWS
Bedrock (Claude Opus 4.8) via GitHub OIDC (vars.AWS_ROLE_ARN); no static
AWS credentials are stored in the repo.

- ocr-review: runs Alibaba Open Code Review through an ephemeral LiteLLM
  proxy bridging OCR's OpenAI protocol to Bedrock, and posts inline review
  comments. Uses output_config.effort=xhigh (Opus 4.8 adaptive thinking).
  Path-scoped rules (.github/ocr/rule.json) encode PostgreSQL community
  review standards plus reviewer discipline (verify against the diff, don't
  hallucinate, state confidence, be blunt, accuracy over approval).
- pg-history: OCR cannot call MCP, so a separate Bedrock tool-use agent
  (.github/ocr/pg-history.py) queries the Agora MCP server (pg.ddx.io) to
  tie the change to git + pgsql-hackers history, and upserts a comment
  linking threads as https://pg.ddx.io/m/pgsql-hackers/<message-id>.
The pg-history workflow job has been failing every run with 'Bedrock call
failed: The read operation timed out' -- botocore's default 60s read
timeout on bedrock-runtime is too short for a multi-round (MAX_ROUNDS=14)
tool-use loop against a large PR diff on a reasoning-capable model; a
single converse() call alone can take several minutes under load (the
sibling ocr-review job's own LLM pass over a similarly large diff took
30-40 minutes).  Confirmed via two consecutive live runs against PR #26.

Set read_timeout=900s (15 min) explicitly via botocore.config.Config;
leave connect_timeout short since a stuck TCP handshake is a different,
cheaper-to-detect failure mode that shouldn't wait as long.
gburd added 21 commits July 4, 2026 13:56
This is the first stage of a native full-text search subsystem intended to
provide true BM25/BM25F relevance ranking with index-only scoring and a
richer query language, addressing long-standing limitations of the
tsvector/tsquery + GIN stack: no corpus statistics (N, avgdl, df) are stored
anywhere, ts_rank is cover-density rather than BM25, and GIN posting lists
carry only TIDs so ranked queries must always recheck the heap.

Rather than land that as one large patch, the work is structured as a
reviewable series (see FTS_NEXTGEN_PLAN.md). This first commit introduces
only the SQL surface, evaluated by sequential scan, with no index access
method -- the same way tsvector/tsquery were originally introduced.

Adds two types:

  ftsdoc    an analyzed document (sorted, de-duplicated terms with term
            frequencies, plus the document length that BM25 will need)
  ftsquery  a parsed boolean query (AND/OR/NOT and grouping)

Both are varlena and TOAST-able with version-tagged binary send/recv formats.
A hand-written recursive-descent parser produces the query; the grammar is
small enough not to warrant a generator. Matching is a boolean stack machine
over the postfix item list, mirroring TS_execute.

The stage-1 tokenizer is deliberately minimal (ASCII case-fold, split on
non-alphanumerics). It is isolated behind fts_analyze_text() so that a later
stage can reuse PostgreSQL's existing text-search parser and dictionary
pipeline (snowball, ispell, synonyms, thesaurus, stopwords) without changing
the types, the operator, or the on-disk format.

Includes a regression test exercising analysis, query parsing and canonical
output, all boolean match cases, sequential-scan use in a WHERE clause, and
error handling for malformed queries.
Add to_ftsdoc(regconfig, text), which runs an installed text search
configuration's parser and dictionary chain via parsetext() and folds the
normalized lexemes into an ftsdoc.  This reuses the existing snowball/ispell/
synonym/thesaurus/stopword pipeline rather than reimplementing tokenization.
Shipped as extension upgrade 1.0 -> 1.1.
Add fts_bm25(doc, query, n_docs, avgdl, dfs), computing the Okapi BM25 score
with Lucene-style IDF and standard k1/b defaults.  Corpus statistics are
caller-supplied for now (the bm25 index AM will maintain them later), which is
enough to validate the scoring math by sequential scan.  Shipped as 1.1 -> 1.2.
Add a real index access method (USING bm25) over an ftsdoc column that answers
the @@@ operator via a bitmap scan.  The build scans the heap, collects
per-term postings (tid, tf), and writes a metapage (N, sum(doclen), nterms), a
sorted dictionary, and chained posting pages -- all through GenericXLog, so the
index is crash-safe and replicated without a custom resource manager.

The scan evaluates the boolean ftsquery by set algebra over posting lists
(AND=intersect, OR=union, NOT=complement against the indexed universe),
matching @@@ semantics exactly with no heap access.  Corpus statistics are
maintained for the coming index-only BM25 scoring.

The skeleton is build-once: aminsert raises an error directing REINDEX, and
incremental maintenance (pending list + background merge) is a later stage.
Shipped as extension upgrade 1.2 -> 1.3.
Add fts_bm25_opts(doc, query, n_docs, avgdl, k1, b, variant, dfs) supporting
lucene, robertson (classic), atire, and bm25+ IDF/scoring variants with
explicit k1/b, for reproducing reference implementations (Lucene/bm25s) in
conformance tests.  Shipped as 1.3 -> 1.4.
Add fts_highlight(text, query, pre, post) and fts_snippet(text, query, pre,
post, ellipsis, max_tokens), giving FTS5-parity result presentation.  Both
tokenize the source with the same folding as the analyzer and mark query-term
matches; snippet slides a token window and returns the densest match region.
Shipped as 1.4 -> 1.5.
Add tsquery_to_ftsquery() and an ASSIGNMENT cast so existing tsquery values and
queries port to the @@@ operator with minimal churn: &/|/! map to AND/OR/NOT.
The phrase operator <-> degrades to AND with a NOTICE (phrase support is a
later stage), preserving recall.  Shipped as 1.5 -> 1.6.
Add prefix matching to the query language: a trailing '*' on a term (e.g.
quick*) matches any document term with that prefix.  Implemented in the parser
(a per-item FTS_QF_PREFIX flag, carried through send/recv), the sequential
matcher (binary-search lower bound on the sorted term set), and the bm25 index
scan (union the posting lists of all dictionary terms sharing the prefix).

Phrase and NEAR need per-term positions, which the stage-1 ftsdoc format omits;
they follow as an ftsdoc v2 format addition.
Add fts_index_stats(regclass) -> (ndocs, avgdl, nterms) and fts_index_df(
regclass, ftsquery) -> float8[], reading N, avgdl and per-term document
frequency from the bm25 index metapage and dictionary.  BM25 can now be scored
from statistics the index maintains rather than caller guesses, closing the
loop between the AM and the scorer.  (Streaming index-only WAND top-K is a
further optimization.)  Shipped as 1.6 -> 1.7.
…partial)

Update the README to reflect the nine qualified stages (versions 1.0-1.7 plus
prefix queries) and to state honestly what remains: phrase/NEAR, WAND top-K,
incremental maintenance, contentless indexes, the parity gate, and the
fuzzy/regex stages.
The bm25 access method's aminsert no longer errors: it appends the new
document verbatim to an in-index chain of pending pages and bumps the metapage
N and sum(doclen).  The scan searches pending documents directly with the
per-document matcher, so newly inserted rows are immediately visible to @@@
without a REINDEX.  Per-term df in the dictionary stays stale until a merge
(REINDEX), matching GIN fastupdate's documented behavior.  All page writes go
through GenericXLog.  Shipped as 1.7 -> 1.8 (bm25 metapage format changed;
REINDEX required for pre-1.8 bm25 indexes).
Extend the ftsdoc format to v2, storing per-term token positions, and add
quoted-phrase query syntax ("a b c"): the parser emits an FTS_OP_PHRASE chain
(distance 1), and the matcher verifies adjacency by intersecting term position
lists.  The bm25 index treats a phrase as AND for candidate generation and now
requests a bitmap recheck, so @@@ re-evaluates adjacency exactly against the
heap ftsdoc.  Position-free v1 docs remain valid (phrase degrades to AND).

NEAR(a b, k) reuses the same distance-aware phrase_step and is a small parser
addition (comma + integer) on top of this.  Shipped as 1.8 -> 1.9 (ftsdoc
format v2).
The bm25 index stores only postings, never document text, so an expression
index on to_ftsdoc(text_column) is exactly FTS5's external-content model: the
text lives in the base table, the index is derived from it, and @@@ queries
(including phrases, via recheck) work against the expression.  Shipped as
1.9 -> 1.10 (documentation marker; no new SQL objects).
Add two ftsquery term forms:
  term~k  matches document terms within Levenshtein distance k (default 2),
          using core varstr_levenshtein_less_equal (bounded, no new dependency)
  /re/    matches document terms against a POSIX regex via core's cached
          regex engine

Both are evaluated per-document in the matcher.  The bm25 index returns all
indexed tuples as candidates for fuzzy/regex queries and the bitmap heap
recheck applies the exact test, so results are correct through the index.

This follows the plan's 'no new dependency for the common case' path; the
pg_tre trigram-formula pre-filter (with its Lime grammar converted to
Bison+Flex, and sparsemap v5.1.1 for posting compression) to narrow candidates
at scale is future work.  Shipped as 1.10 -> 1.11.
Add bench/bench.sql and bench/README: a reproducible A/B harness comparing the
bm25 stack against tsvector + GIN + ts_rank on a user-supplied corpus (index
size, ranked top-10 EXPLAIN ANALYZE).  The full parity gate (latency
percentiles, NDCG vs qrels, concurrent-ingest throughput, Lucene/bm25s score
conformance) is documented as a manual, reported measurement rather than a
make-check regression, since it needs an external corpus.

Update README.pg_fts to describe all implemented stages (1.0-1.11), the full
query language, a worked BM25 ranking example, and the remaining future work
(WAND top-K, trigram pre-filter for fuzzy/regex, NEAR, background merge,
BM25F).
Add fts_bm25f(docs ftsdoc[], query, weights[], n_docs, avgdls[], dfs[]): the
Robertson/Zaragoza BM25F, where per-field term frequencies are length-
normalized per field and combined by weight before the tf-saturation step
(not a naive sum of per-field BM25 scores).  This lets a term in a heavily
weighted field (e.g. title) outrank the same term in the body.  Shipped as
1.11 -> 1.12.
Add bm25_merge_pending(): read the existing dictionary + posting chains and all
pending documents back into a build state, rewrite the merged structure into
fresh blocks, and repoint the metapage -- no heap access.  Wired into
amvacuumcleanup (VACUUM now folds pending docs automatically) and exposed as
fts_merge(regclass) for on-demand merge.  Merging resolves the df staleness
that incremental inserts introduce (formerly-pending terms gain dictionary df).

Old blocks are left unreferenced and reclaimed by REINDEX; an FSM-based page
recycler is future work.  Shipped as 1.12 -> 1.13.
Add fts_search(index, query, k) -> setof(ctid, score): BM25 top-k computed
entirely from the index -- postings supply per-doc tf, the dictionary supplies
df and a per-term max-tf impact bound (now stored), the metapage supplies N and
avgdl -- with no heap access.  Per-document scores accumulate across query
terms and the top-k are returned by descending score; join on ctid to fetch
rows.  This is the index-only-scoring path (no heap fetch to rank), the core
performance win for ranked search.

Stored per-term max_tf in the dictionary provides the WAND upper bound for
document skipping.  Full executor integration via amcanorderbyop (an ORDER BY
score LIMIT k ordering scan with block-max WAND early termination) and exact
per-document |D| in postings are the remaining optimizations.  Shipped as
1.13 -> 1.14.
Add pg_fts_trgm.c: reduce a fuzzy term to its trigrams and test only document
terms sharing a trigram with it (Levenshtein is the expensive step).  This is
the pg_tre-style pruning that makes fuzzy matching viable on a large
vocabulary, applied at the term level.  Results are unchanged: the filter only
skips candidates that provably cannot match (pigeonhole: a match within k edits
shares a trigram when the term has more than k trigrams) and falls back to a
full scan for short terms.  A persistent on-disk trigram posting index in the
bm25 AM (the full three-tier funnel) is the remaining scale work.  Shipped as
1.14 -> 1.15.
fts_search() returned candidate ctids straight from the postings, which can
reference dead or updated tuples that the index has not yet merged out.  It now
opens the base table and checks each candidate against the active snapshot via
table_index_fetch_tuple, returning only visible tuples in score order and
stopping once k visible rows are found.  This makes the SRF correct under all
isolation levels, matching the visibility contract the @@@ bitmap path already
gets from the executor's bitmap heap scan + recheck.

(The @@@ operator path was already MVCC-correct: amgetbitmap sets recheck=true
and the bitmap heap scan applies snapshot visibility.  All page I/O uses the
buffer manager, and every writer is WAL-logged via GenericXLog, so the index is
correct on physical standbys and after crash recovery.)
gburd added 2 commits July 5, 2026 06:46
Compacting a many-segment index (e.g. the state a parallel build leaves) was a
single-threaded O(index) decode+re-encode.  Parallelize it: bm25_merge_all now
first runs bm25_merge_all_parallel, which partitions the live segments into
(workers+1) disjoint groups, has each participant merge ONE group into a new
segment via bm25_merge_group_to_seg (writes pages only, extension-lock-
serialized, no directory touch), and then performs a SINGLE atomic metapage
update -- dropping every consumed source (content-match, not index, so it is
robust to the directory having changed) and installing the per-group merged
segments, recycling the sources' pages.  A cheap final serial pass finishes to
one segment.  This confines the expensive per-segment decode/re-encode to
parallel workers and keeps the directory swap serial + atomic (no concurrent-
swap race).  Nested parallelism is avoided (skipped when IsInParallelMode()).

Worker entry bm25_parallel_merge_main registered under 'pg_fts'.  Verified: a
parallel-merged index returns byte-identical counts to a serial build and to the
pre-merge index (common/term/alpha counts across 100k-120k rows), merges to a
single segment, ranked top-k intact, no crash.

Future (noted): Level-2 recursive parallel merge (W->W/2->...->1) so the final
combine also parallelizes.  qualify PASS; regression + isolation green.
Record the enhancements discussed during development but not yet implemented so
they are not rediscovered: parallel-merge at-scale timing + worker-slot gating,
Level-2 recursive parallel merge, larger per-worker build segments, impact-
ordered postings / columnar codec (the ranked-latency gap vs pg_search), COUNT
Custom Scan pushdown, parallel scan, cold-merge AIO prefetch, benchmarking the
v5.3.0 batch/cached sparsemap APIs under churn, completing the 4-way
(pg_fts/pg_search/VectorChord-bm25/tsvector-GIN) comparison, fts_search SRF
under-fetch safety, sparsemap error-path leaks, and the release tag decision.
gburd added 4 commits July 5, 2026 10:50
…regression)

The parallel build (commit 019bd1f) skipped the final merge, leaving a freshly
built or REINDEXed index as N segments (~6-8, ~8-13 GB with unreclaimed pages).
A multi-segment index makes ranked scans traverse every segment's postings, so
common-term ranked top-k regressed ~2x (18 ms -> 38 ms at 2M Wikipedia) versus
the old serial build's single-segment index.

Fix: after bm25_end_parallel() (which has exited parallel mode), call
bm25_merge_all(), which now runs the PARALLEL merge (workers merge disjoint
segment groups) -- so the build ends with an optimal single segment WITHOUT the
single-threaded O(index) merge tail that made a naive build-time merge slow.
Verified locally: a 400k-row parallel build ends nseg=1 with 18 parallel-merge
worker launches, counts correct, no crash.  qualify PASS; regression + isolation
green.
Ensure BOTH build paths leave an optimal single-segment index: the serial
branch used the tiered bm25_merge_segments, which deliberately leaves same-size
tiers and can leave a multi-segment index (regressing ranked scans).  Use
bm25_merge_all in both branches so a fresh CREATE INDEX / REINDEX is always
compact regardless of whether the planner chose a parallel build.  qualify
PASS; regression + isolation green.
@github-actions github-actions Bot force-pushed the master branch 4 times, most recently from 12cc4a0 to 6bc21bb Compare July 6, 2026 08:13
…rd-bm25 vs GIN)

2.19M real Wikipedia, PG17.10, r7i.4xlarge. All four engines built and queried
on identical data. pg_fts wins ranked common&mid (19.3ms) and beats GIN+ts_rank
by up to 43x on ranked top-100; fts_count wins selective counts. VectorChord-bm25
(current tsvector HEAD) wins ranked retrieval; pg_search wins common-term count.
The ranked gap remains a posting-codec matter, not sparsemap/structure.
gburd added 4 commits July 6, 2026 08:50
…e extension lock)

The parallel merge launched workers correctly, but each participant wrapped its
ENTIRE segment write in LockRelationForExtension(ExclusiveLock).  At scale the
write phase (writing GB of merged postings) dominates, so the participants'
writes serialized on that one lock -- the merge was parallel in CPU but serial
on I/O, giving the ~serial merge wall time seen at 2M (a long tail with the
leader/one worker writing while the rest blocked on the extension lock).

Fix: hold the relation extension lock only around the single P_NEW extension
inside bm25_new_buffer() (the actual EOF race), not around the whole segment
write.  Participants now extend one page at a time under a brief lock and write
their pages concurrently.  The metapage update keeps its own metapage buffer
lock, so directory mutations stay serialized independently.

Verified: serial vs parallel builds are byte-equivalent (0 term-count mismatch
over 5000 terms, both nseg=1), no 'unexpected data beyond EOF', no crash;
regression + isolation green; qualify PASS.
…act/truncate)

Ordinary merges recycle freed pages to the FSM but never shrink the relation,
so a freshly built/merged index stays physically large (e.g. 200MB with ~35MB
live after a parallel build).  Three changes reclaim that space:

1. Low-page-biased allocation (bm25_alloc_begin/_end + bm25_new_buffer): during
   a compaction, gather all free blocks, sort ascending, and hand them out
   lowest-first so live pages pack at the FRONT of the file (leaving the dead
   pages as a contiguous tail).  This also delivers the 'merge into FSM-reused
   pages' behavior -- the rewrite reuses freed low blocks instead of extending.

2. bm25_vacuum_compact(): merge every live segment into one under the low-page
   allocator, then truncate the contiguous free tail back to the OS with
   RelationTruncate (FreeSpaceMapVacuumRange first).  Single-writer only.

3. Wired into amvacuumcleanup (runs when >=25% of the file is free, so routine
   autovacuum does not pay a rewrite every pass) and exposed as fts_vacuum(
   regclass) for on-demand shrink (extension 1.20).

Verified: a 300k-row parallel build (201MB, dead source pages) shrinks to 35MB
after fts_vacuum with identical term counts (5.7x); regression + isolation
green; qualify PASS.
bm25_for_unpack decoded each packed integer bit-by-bit (nested loop over n
values x width bits, one branch per bit).  Posting decode is the hot path in
every query -- count, ranked top-k, WAND cursor advance -- so this scalar
bit-twiddling was a first-order cost (the common-term count and ranked scans
are decode-bound, which is where pg_search/vchord's compact codecs win).

Replace it with a word-oriented extract: for each value, one unaligned load of
the covering bytes + shift + mask (with a 9-byte-window fallback for the rare
value that straddles a 64-bit boundary).  Byte-exact vs the reference across all
widths 0-63 and block sizes 1-128 (values round-trip), and 5.69x faster at the
typical docid-gap width (14 bits): 20M x 128-value blocks 67.8s -> 11.9s.

On-disk format unchanged (same packing, faster reader).  Regression + isolation
green; qualify PASS.  Standalone self-check: /tmp/unpack_check.c (asserts
new==reference==input for every width/size).
…rge tail)

bm25_merge_all ran ONE parallel merge pass (many segments -> workers+1 groups)
then finished serially.  At 2M Wikipedia that serial finish -- merging ~9
multi-GB segments to one on a single backend -- was ~20 min of the ~27 min
build.  Now iterate the parallel merge until <= 2 segments remain, so the
directory shrinks geometrically (e.g. 30 -> 9 -> 2) with every reduction done
by workers; only the final 2->1 falls to the serial loop.  Also cap ngroups at
nsrc/2 so every group merges at least two sources (no singleton groups that
would make the iteration spin without reducing the count).

Verified: serial vs parallel builds byte-equivalent (mismatch=0 over 8000
terms, par_nseg=1) across repeated runs, no crash; regression + isolation
green; qualify PASS.
gburd added 4 commits July 6, 2026 12:35
Iterating the parallel merge to one segment was measured at 2M Wikipedia to be
WORSE (32min vs 27min single-pass): each pass rewrites data (write
amplification) and the final reduction is still the write of one multi-GB
output segment by a single backend -- which no group-partition scheme
parallelizes.  Revert to a single parallel pass + serial finish, and record the
finding.  The merge tail is a single-output-write cost; cutting it needs a
streamed/columnar write path (DEFERRED.md), not more merge parallelism.

qualify PASS; regression + isolation green.
…ter decode (2M)

fts_vacuum: 15 GB -> 3.77 GB (4x) in 1.7s, counts identical (on par with
pg_search 4.1 GB). Word-oriented decode: common-term fts_count 305 -> 101 ms
(now below pg_search 123 ms). Iterated parallel merge measured worse (32 vs 27
min) and reverted -- the merge tail is a single-output-segment write, a codec
matter not a parallelism one.
…not the win)

AIO for parallel-merge writes: rejected with evidence -- no buffer-manager AIO
write path exists in this tree (reads only), using it would break the
GenericXLog invariant, and the merge tail is CPU-bound re-encode (not I/O wait)
so AIO would not help.  Recorded in CAPABILITIES.md.

Format-v3 codec: profiled the common-term ranked query (perf --no-children).
It is ~30% decode+block-load and ~70% scoring/heap/executor, and block-max WAND
cannot skip blocks on common English terms -- so a columnar-codec rewrite is
capped at ~30% and cannot enable pruning (the NOTE_IMPACT_ORDERING result, now
confirmed by profiling).  A reusable per-cursor block buffer was tried and
measured SLOWER (per-block palloc is only 1.2%), reverted.  Evidence-supported
levers are SIMD docid unpack (~5-8%, decode micro-opt) and a PARALLEL ranked
scan (the real lever, an executor/AM change).  bench/NOTE_FORMAT_V3_PROFILE.md;
DEFERRED.md item 4 updated.  No speculative codec rewrite shipped.
Add pg_fts_customscan.c with a _PG_init that installs create_upper_paths_hook.
A bare 'SELECT count(*) ... WHERE col @@@ q' over a single base rel with a bm25
index on col is now answered by a Custom Scan (FtsCount) that calls the index
bulk-count (bm25_count_visible_oid, VM-based) instead of a bitmap heap scan --
~3x faster on a common term (transparent count 240ms -> the fts_count 75ms path,
without the user having to call fts_count()).

Strictly additive and precisely gated: fires only for count(*) with no GROUP
BY/HAVING/DISTINCT/window/set-op, exactly one @@@ qual, and an FtsQuery Const;
any extra qual or grouping falls back to the ordinary plan (verified in the
regress test).  MVCC-correct (uses the active snapshot's visibility, same as
fts_count).  Establishes the CustomScan plumbing for the ranked stages.

qualify PASS; regression + isolation green.
gburd added 2 commits July 6, 2026 15:14
ORDER BY col <=> q [LIMIT k] on a bm25-indexed rel is now handled by a
Custom Scan (FtsRankedScan) installed via set_rel_pathlist_hook.  Under the hood
the WAND/MaxScore candidate generation is fanned across parallel workers by
docid range: bm25_topk_candidates_range(index, q, wantk, docid_lo, docid_hi)
scores a disjoint docid slice (each WandCursor seeks to docid_lo and reports
exhausted at docid_hi, so the existing WAND loops need no range awareness), the
leader merges the per-slice candidate lists, sorts by score, and applies MVCC
visibility once.  Gated on max_parallel_workers_per_gather and a >=50k-hit
estimate, so selective queries stay serial (no parallel setup overhead).

The docid ranges partition the corpus disjointly, so the parallel result is
byte-identical to the serial one -- verified in the regress test (parallel vs
serial top-k equal) and locally on 200k rows for single- and multi-term queries.

Refactor: bm25_topk_visible now calls the new range function with [0, MAX) then
does visibility (behavior unchanged for the SRF/amgettuple paths).

qualify PASS; regression + isolation green.
bm25_vacuum_compact only truncated after merging, so a freshly built index that
already merged to one segment (common: the build compacts to nseg=1) kept its
dead build/merge pages interleaved -- nothing was truncatable and fts_vacuum was
a no-op (12 GB stayed 12 GB at 2M).  Now always REWRITE the live segments through
the low-page allocator first (even a single segment), relocating live pages to
the front so the stale pages form a contiguous free tail that RelationTruncate
removes.  Verified: a parallel-built nseg=1 index shrinks 206 MB -> 70 MB (one
pass) -> 35 MB (second pass, the compacted floor), counts identical.  qualify
PASS; regression + isolation green.
gburd added 2 commits July 6, 2026 17:24
… COUNT pushdown

The ranked CustomScan (Option B stages 2+3) was built, verified byte-identical
to serial, and measured at 2M: no win (top-100 common 73 vs 66 ms; others within
noise).  Two reasons (bench/NOTE_PARALLEL_RANKED.md): the query is only ~30%
decode+WAND with a ~70% scoring/heap/visibility tail the leader runs serially
(Amdahl ceiling ~30%), and launching workers from inside ExecCustomScan fell
back to serial at scale.  The serial ranked CustomScan is also redundant with
the existing bm25 AM ordering scan.  Reverted per the project rule against
shipping non-paying optimizations.

Kept: the COUNT pushdown CustomScan (a real transparent ~3x win) and the
behavior-preserving bm25_topk_candidates_range refactor.  qualify PASS;
regression + isolation green.
SGML + README: add fts_vacuum() (compact + truncate to reclaim physical bloat
without REINDEX; auto during VACUUM), correct the stale fts_merge note that said
REINDEX was required, and note the transparent count(*) WHERE @@@ CustomScan
pushdown.  qualify PASS.
@github-actions github-actions Bot force-pushed the master branch 2 times, most recently from 96ac82d to ea640f1 Compare July 7, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant