Skip to content

pg_fts: a bm25 full-text search engine#27

Draft
gburd wants to merge 126 commits into
masterfrom
fts
Draft

pg_fts: a bm25 full-text search engine#27
gburd wants to merge 126 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 12 commits July 4, 2026 16:29
…t fix)

The previous fix missed the first gate-off site in __sm_map_unset (the
'no chunks in the map' branch still assigned SM_IDX_MAX to the size_t offset),
so the 32-bit tombstone VACUUM crash persisted.  Route that site through
SM_UNSET_NO_COALESCE too, and change __sm_chunk_rank's rank->rem 'infinity'
sentinel from UINT64_MAX to SIZE_MAX (rank->rem is size_t; the value is only
ever used as a large remaining-count, but the constant must not truncate).

Verified on genuine i686 (gcc -m32 on Fedora): the delete-all-then-reuse
coalescing reproducer passes and the build is free of -Woverflow/-Wuninitialized
in sm.c.  64-bit upstream suite still green (test_main 44/44, coverage 7801,
portability, RLE); pg_fts regression green.
Real-corpus testing exposed a correctness bug: after a document is deleted and
VACUUMed (tombstoned in its segment), if its heap slot is reused by a NEW
inserted document (same TID/docid), the new document was not found by @@@ /
fts_count.  Two causes, both fixed:

1. Pending docs were tombstone-filtered.  A doc in the pending write buffer is a
   live heap tuple by definition, but bm25_collect_matches OR'd pending matches
   into the accumulator BEFORE the tombstone filter, so a pending doc reusing a
   tombstoned TID was dropped.  Collect pending matches separately and union
   them AFTER filtering; pending docs are never tombstoned.

2. Tombstones were applied globally across segments.  bm25_docid_tombstoned
   checked a docid against EVERY segment's tombstone map, but a tombstone
   belongs to exactly one segment: a TID deleted in segment A may be legitimately
   reused by a live doc that a newer segment B indexes.  Filter each segment's
   own match contribution against only THAT segment's tombstone map
   (bm25_filter_tombstoned_seg), and in the WAND ranked path skip own-segment
   tombstoned docids inside each cursor (cursors are per-segment).  Removed the
   now-unused global bm25_filter_tombstoned / bm25_docid_tombstoned.

The regress tombstone test previously encoded the buggy 0 as expected output;
corrected to 60 and added an fts_count assertion so the count path is covered
too.  qualify PASS; regression green.
A document whose analyzed ftsdoc did not fit on a single pending page raised
'ftsdoc too large for a bm25 pending page' and failed the INSERT/UPDATE -- real
corpora (e.g. long Wikipedia articles) hit this routinely, so the index could
not be built over them.

When an ftsdoc exceeds the pending-page capacity, index it directly as its own
one-document segment (bm25_insert_oversized_as_segment) via the existing build
machinery: segment posting storage is a chain of FOR-packed pages with no
per-document size limit.  Such documents are rare, so building a small segment
per oversized insert is acceptable; corpus N/sumdoclen stay correct
(bm25_meta_add_segment accounts the doc, and the pending path is bypassed so
there is no double count).  Added a regression test with a ~4000-token document.\n\nqualify PASS; regression green.
Vendor sparsemap v5.2.0, which brings the MSVC portability shim (SM_ALIGNED /
ssize_t / guarded __builtin_*) and the O(N) coalescing performance fix upstream.
This replaces the local vendor/sm_compat.h shim (now redundant) and my earlier
one-off MSVC edits; sm.h is byte-identical to pristine v5.2.0 and sm.c differs
only by the __pg_bm25_ SPARSEMAP_PREFIX namespacing block plus the fix below.

v5.2.0 still has the 32-bit unset-coalesce truncation bug (its own change did
not touch the gate): in __sm_map_unset the size_t byte-offset 'offset' is set to
SM_IDX_MAX (== UINT64_MAX) as a 'gate coalescing off' sentinel, which truncates
to 0xFFFFFFFF on ILP32 so the '!= SM_IDX_MAX' gate (which promotes offset back
to 64 bits) never matches -> coalescing runs on an uninitialized chunk and
crashes.  Route the four gate-off sites and the gate test through a size_t-width
sentinel SM_UNSET_NO_COALESCE.  (Fixed in ~/ws/sparsemap for upstreaming;
vendored here.)

Consumer-side alignment fix: struct sparsemap is declared SM_ALIGNED(8), but
palloc only guarantees MAXALIGN (4 on ILP32), so palloc0(n*sizeof(sm_t)) placed
the per-segment tombstone maps at 4-aligned addresses -> -fsanitize=alignment
abort in the Linux Meson (32-bit) CI job.  Allocate that array with
palloc_aligned(..., 8, 0).  Verified on genuine i686 under
-m32 -fsanitize=undefined,alignment: the delete+coalesce+reopen path runs clean
(live=60) with an 8-aligned sm_t.  qualify PASS; 64-bit regression green;
upstream suite (test_main 44/44, coverage 7805, portability, RLE) green.
Indexing an oversized document creates a one-document segment
(bm25_insert_oversized_as_segment).  A bulk INSERT/UPDATE over a corpus with
many large documents (e.g. rebuilding the expression index over 2M Wikipedia
rows) could therefore create one segment per oversized row and hit the hard
BM25_MAX_SEGMENTS (128) cap before the next VACUUM merged anything -- 'bm25
index reached the maximum of 128 segments'.

After flushing an oversized segment, if the live segment count is within 16 of
the cap, run bm25_merge_segments() to coalesce.  Verified: 200 oversized
documents now index without overflow (merged to well under the cap) and all
remain searchable.  qualify PASS; regression green.
Head-to-head on 2,000,000 real English-Wikipedia articles (PG20devel,
r7i.8xlarge).  Match sets verified identical to the GIN path before timing.

Headline (median of 9, warm): ranked retrieval -- the actual FTS use case --
is where pg_fts wins, and the win grows with term frequency because GIN's
ts_rank must fetch+score+sort every match while pg_fts stops early via block-max
WAND: ranked top-10 common&mid 15 ms vs 65 ms (4.2x), top-100 common 75 ms vs
3029 ms (40x), top-10 two-common 50 ms vs 1641 ms (33x).  Plain counts / boolean
AND tie (both bitmap-scan); fts_count beats count(*) 3.7x.  Cost: ~2.5x larger
index (per-posting tf/|D|/positions), comparable single-threaded build.

Adds bench/get_wikipedia.py (HF parquet -> TSV loader) and bench/bench_fixed.sh
(pinned-term median-of-9 A/B runner).
…ch bench

sparsemap v5.2.1 upstreams the ILP32 gate fix I carried locally in the vendored
copy (SM_UNSET_NO_COALESCE, the size_t-width coalesce sentinel) and adds an
independent bug fix -- a multi-run RLE-chunk corruption in __sm_separate_rle_chunk
(wrong payload-vector placement and pivot-size accounting that desync'd the
sequential chunk walk).  Re-vendor v5.2.1: sm.h is byte-identical to pristine
v5.2.1 and sm.c now differs ONLY by the __pg_bm25_ SPARSEMAP_PREFIX block (no
local modifications remain).  Verified on i686 (gcc -m32 -fsanitize=undefined,
alignment): the delete+coalesce+reopen path is clean; 64-bit upstream suite
green (test_main 44/44, coverage 7820).  qualify PASS; regression green.

Also add bench/RESULTS_VS_PGSEARCH_WIKI.md: an honest pg_search 0.24.1 head-to-
head on 2M real Wikipedia articles (PG17.10).  pg_search wins ranked retrieval
(flat ~9ms; pg_fts's docid-ordered WAND degrades with term frequency, 26->70ms)
and common-term counts (aggregate pushdown); pg_fts's fts_count wins selective
counts (1.9-2.4ms) and its index is 1.55x smaller (3590 vs 5574 MB).  The gaps
are the documented codec investments (impact-ordered postings, COUNT/aggregate
Custom Scan pushdown) plus parallel scan -- architecture, not tuning.
…AIO)

Code-cited capability matrix and answers to adopter questions: concurrent
builds (CIC/REINDEX CONCURRENTLY work — aminsert routes concurrent writes to the
searchable pending list), index-only scans (no, non-covering by design; fts_count
is the fast-count path), compaction (VACUUM+fts_merge drops tombstones logically,
REINDEX reclaims physical space — no online REPACK), feature parity vs
pg_search/Tantivy, ZomboDB/ES and tsvector/GIN (has BM25/BM25F, phrase/NEAR/
prefix/fuzzy/regex, <=> ordering scan, fts_count, highlight/snippet, tombstone
deletes, WAL/GenericXLog safety; gaps: no parallel build/scan, no IOS, no
aggregation pushdown, no impact-ordered postings), logical-replication drop-in
(no — re-platform: different API, indexes provisioned per-subscriber; physical
replication + crash recovery ARE safe), and AIO (none of its own; build heap
scan gets core read_stream free; nextblk pointer-chains defeat prefetch and WAND
is anti-prefetch by design; only the cold merge full-scan could benefit, deferred
until measured).
Closes the ranked-retrieval gap vs Tantivy/pg_search, whose latency stays flat
as term frequency grows while pg_fts's docid-ordered block-max WAND degraded
(top-100 over a common term walked the whole ~5300-block posting list).

Format v3 adds a per-term impact-ordered block skip directory: for a term with
df >= BM25_SKIPDIR_MIN (2048), the writer records every posting block's
(blk, off, max_tf, min_doclen) and stores them sorted DESCENDING by an
avgdl-independent impact proxy (max_tf desc, min_doclen asc) in a BM25_SKIPDIR
page chain, referenced from three new BM25DictEntry fields (skipstart/skipoff/
nblocks).  The single-term ranked scan (fts_search_impact_single, dispatched
from fts_search_wand when nterms==1 and a directory exists) visits blocks
best-first and stops once k results beat the recomputed bound of the next
block -- Tantivy-style early termination.

Soundness (where the prior float-impact attempt failed on avgdl drift): only
raw integers are stored; the bound is RECOMPUTED at query time from
(max_tf, min_doclen) at the current avgdl, so it is always an exact upper bound
as the corpus grows.  The stored sort order is a visitation heuristic only --
exactness comes from the recomputed bound + the WAND stop condition.  Verified:
the index top-k set is identical to a brute-force seqscan+sort (regression test
with a >2048-doc term and distinct top scores).  Rare terms carry no directory
and use the existing exact docid-ordered scan; v2 indexes are read with that
scan too (version-gated), so no forced rewrite -- REINDEX gains the directory.

Format BM25_VERSION 2->3; extension 1.19->1.20 (migration is a marker, no SQL
surface change).  qualify PASS; regression green.
The skip directory is stored sorted by a max_tf proxy, but the true per-block
impact bound also depends on min_doclen (impact rises as min_doclen falls), so a
block with a smaller max_tf can have a higher bound than an earlier entry.
Stopping at the current entry's bound was therefore unsound -- it could halt
before a later, higher-bound block, returning a wrong/incomplete top-k (observed
at 2M scale: index top-k distances non-ascending and missing lower-distance
docs vs a seqscan).

Fix: after loading a term's directory, sort it by the EXACT impact bound
recomputed at the current avgdl (descending) before the scan.  Then the
front-of-remaining early-stop is a valid ceiling on every not-yet-visited block
and the returned top-k is exact.  Verified with distinct scores at 8000 docs:
index top-15 byte-identical to a brute-force seqscan+sort.  (Insertion sort;
near-O(n) since the stored max_tf order is already close to bound order.)
The k*4 (min 64) over-fetch forced the impact-directory single-term scan to
fetch far more than the LIMIT (a LIMIT 10 fetched 64), defeating its early
stop.  k*2 (min 32) still tolerates ~50% invisible top rows before the SRF
under-fills, and the amgettuple ordering scan grows k via its own retry, so
correctness holds while the early-stop can fire.  qualify PASS; regression green.
… real text)

The v3 impact-ordered block skip directory (commits f049f3a/ef637ba/68ce28f)
was correct and avgdl-drift-safe, but instrumented block-visit counts on 2M
real Wikipedia show it delivers NO early termination: within a term the
per-block impact bounds cluster in a razor-thin band just above the top-k
threshold (constant idf; a common term has some high-tf doc in nearly every
block), so best-first block ordering still visits ~99% of blocks before it can
stop.  For 'year' (df 678k, 5296 blocks) the scan visited 5282; for 'hungary'
170/173.  No measured latency win on any query band, at the cost of format v3,
a skip-page chain, ~3% larger index and a per-query sort.

Reverting to format v2 / extension 1.19.  pg_search's flat ranked latency comes
from a compact columnar segment codec (far less decode per candidate) plus
query parallelism, not an impact skip structure -- matching it is a codec
rewrite, out of scope here.  bench/NOTE_IMPACT_ORDERING.md records the attempt,
the measurements, and the conclusion so it is not re-tried blindly.

qualify PASS; regression green; index format unchanged (v2).
gburd added 10 commits July 4, 2026 17:27
…cation)

The regression suite covered functionality but not the harder committer bars.
Add:

- specs/bm25_concurrency.spec: MVCC/concurrency invariants under the isolation
  tester -- REPEATABLE READ snapshot stability across a concurrent committed
  INSERT, the pending list being live for a fresh snapshot, a concurrent
  VACUUM/fts_merge being MVCC-invisible to an open scan, and delete+VACUUM+
  heap-slot-reuse leaving no resurrected/hidden docs (per-segment tombstones).
- specs/bm25_cic.spec: CREATE INDEX CONCURRENTLY and REINDEX CONCURRENTLY
  complete and reflect a concurrently-committed INSERT (aminsert routes to the
  searchable pending list).
- t/001_crash_recovery.pl: an immediate (crash) stop + automatic recovery
  reproduces exact @@@/fts_count/ranked answers across the build, pending-list,
  and tombstone paths, and the index is writable afterward -- proving the
  GenericXLog WAL logging is crash-safe.
- t/002_replication.pl: the index replicates to a streaming standby and returns
  identical @@@/<=>/fts_count results there, including tombstoned deletes.

Wired into meson.build (isolation specs + tap tests) and Makefile (ISOLATION,
TAP_TESTS=1).  All green: isolation 2/2 specs, crash recovery 5/5, replication
5/5; qualify PASS.
…ADME

The extension had no user-facing reference docs -- a required gap for a contrib
module.  Add doc/src/sgml/pgfts.sgml (the 'pg_fts -- BM25 full-text search'
appendix section: data types, @@@ / <=> operators, the bm25 index, functions
to_ftsdoc/to_ftsquery/fts_count/fts_search/fts_merge/fts_bm25[f]/fts_index_*/
fts_highlight/fts_snippet/tsquery_to_ftsquery, an example, and limitations),
and wire it into filelist.sgml (entity) and contrib.sgml (alphabetical include).

Also add Documentation and Testing sections to README.pg_fts pointing at the
SGML docs, CAPABILITIES.md, and the regression/isolation/TAP/bench suites, so
the test and doc structure is discoverable.  SGML tags balanced, entities all
standard; qualify PASS; regression + isolation green.
v5.3.0 adds accelerated membership APIs; adopt the two that fit pg_fts's
tombstone lookups, preferring stack-allocated cache state to avoid palloc:

- bm25_filter_tombstoned_seg: replace the per-docid sm_contains loop with a
  single sm_contains_many() left-to-right sweep (O(chunks+n) instead of n
  head-walks).  The set's docids are already ascending (TidSet is TID-sorted,
  docid is monotonic in TID), satisfying sm_contains_many's precondition.  Uses
  a 256-entry stack buffer for the common small case, palloc only for larger
  sets.
- wand_cur_own_tombstoned (WAND ranked hot loop): use sm_contains_cached() with
  a stack-allocated sm_cursor_cached_t carried on the WandCursor (8-way MRU
  chunk cache), initialized once per cursor.  A cursor scans docids ascending
  into a small working set, so repeated lookups become cache hits.
- bm25_read_segment_into (merge): use a stack sm_cursor_cached_t across a term's
  docid-ascending postings.  (The merge's dead-map dedup check stays plain
  sm_contains -- that map is mutated in the loop, which the cached API forbids.)

sm.h is byte-identical to pristine v5.3.0; sm.c differs only by the __pg_bm25_
prefix block.  All tests green: regression, isolation 2/2, crash-recovery 5/5,
replication 5/5; qualify PASS.
pg_fts's fts_bm25_opts offered lucene/robertson/atire/bm25+.  The rank_bm25 /
plpgsql_bm25 reference set also includes BM25L, which shifts the LENGTH-
NORMALIZED term frequency by a delta before saturation
(ctd = tf/(1-b+b*dl/avgdl); sat = (k1+1)(ctd+d)/(k1+ctd+d), IDF ln((N+1)/(df+0.5)),
delta 0.5) so long documents are not over-penalized -- a distinct behavior from
BM25+ (which adds the delta to the whole tf-saturation term).  Add BM25_BM25L
to the scorer and accept the names 'bm25l' and 'l'.  With this the variant set
matches rank_bm25's ('', l, plus, robertson, luceneaccurate, atire, bm25l,
bm25plus map onto lucene/robertson/atire/bm25+/bm25l).  Regression test +
docs updated; qualify PASS, regression + isolation green.
The single-threaded CREATE INDEX was pg_fts's weakest axis (build is dominated
by to_ftsdoc tsearch analysis; 2M Wikipedia took ~34 min serial while pg_search
built in ~1 min with parallel workers).  Implement Level-1 parallel build,
modeled on BRIN's parallel-build scaffolding:

- amcanbuildparallel = true.  When the planner sets ii_ParallelWorkers > 0,
  bm25_build sets up a ParallelContext + DSM (BM25Shared: heap/index oids,
  isconcurrent, a parallel table scan, a done-counter, buffer/WAL usage), and
  registers bm25_parallel_build_main as the worker entry ('pg_fts' library).
- Each participant (leader + workers) runs table_index_build_scan over its
  slice of a shared parallel table scan, so the expensive heap-scan + analysis
  parallelizes near-linearly, and flushes its accumulated postings as segments
  straight into the index.  The leader then runs the size-tiered merge to
  compact the workers' segments.
- Segment WRITES are serialized: pg_fts appends pages via ReadBuffer(P_NEW),
  and overlapping appenders race on relation extension ('unexpected data beyond
  EOF').  bm25_build_flush_segment holds the relation extension lock around the
  whole segment write when IsInParallelMode(); the analysis stays fully
  parallel, only the cheap write phase serializes.  (Serial builds take no lock.)

Verified: a parallel-built index returns byte-identical counts and the same
ranked top-k as a serial build over the same data; corpus stats (N) match.
Regression test forces workers (min_parallel_table_scan_size=0); isolation +
crash + replication tests unaffected.  qualify PASS; regression + isolation
green.  Docs/capability matrix updated (build now parallel; query scan still
single-threaded).
…8+ (3)

The flags parameter to table_beginscan_parallel was added after PG17; gate the
SO_NONE argument behind PG_VERSION_NUM >= 180000 so the parallel build compiles
on PG17 as well as master.
Level-1 parallel build parallelized the heap scan + analysis, but the
build-time bm25_merge_segments (merge everything to one segment) is single-
threaded and O(index); with N workers each flushing several segments it
dominated, so the net build speedup was only ~15%.

After a PARALLEL build, do not force a full merge: leave the participants'
segments in place (the index is fully correct as N segments) and let the
size-tiered background merge (VACUUM / fts_merge) coalesce them lazily, as
Lucene/Tantivy parallel builds do.  Only run a single tiered-merge pass if the
count exceeds BM25_MERGE_THRESHOLD, to bound scan fan-out.  Serial builds keep
the cheap post-build compaction (they produce few segments).

Future (noted in code): Level-2 -- parallelize the merge itself (workers merge
disjoint segment groups) to also cut compaction cost; deferred.

qualify PASS; regression + isolation green.
fts_merge() only called bm25_flush_pending, which returns immediately when the
pending list is empty -- so it did nothing for an index that has many segments
but no pending docs (exactly the state a parallel build leaves, since that skips
the build-time merge).  Add bm25_merge_all() -- merge every live segment into
one, looping in bounded batches -- and have fts_merge() run it after flushing.
An on-demand fts_merge() now yields an optimal single-segment index; the tiered
auto-merge (which deliberately leaves same-size tiers) is unchanged for the
background path.  Verified: a 2-segment parallel-built index fts_merge()s to 1
with counts intact.  qualify PASS; regression + isolation green.
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 2 times, most recently from 9f8691a to 6a5068d Compare July 5, 2026 22:55
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