Variable-width per-tuple blooms — v1.3 design spec
Status: draft, targeting pg_tre 1.3.
Owner: unassigned.
Prerequisite: multi-leaf chain-rank repair — fully
resolved in 1.2.3. The struct-vs-bytes bloom-header fix
(1.2.2) plus the pending-overlay positional-filter fix
(1.2.3) close out the long-running tier-3 bypass. Tier-3
is on by default and works correctly across single-leaf,
multi-leaf, and pending-overlay code paths. Variable-width
blooms are now an incremental size optimization on top of
working tier-3.
Problem
The tier-3 per-tuple bloom is a fixed pg_tre.bloom_tuple_bits = 128 bits per heap row, sized to roughly hold one row's distinct
trigrams at a tolerable false-positive rate. Two issues:
- Wasteful for short rows. A row with 5 distinct trigrams needs <8 bits to keep FP rate below 1%; we burn 128.
- Saturated for long rows. A row with 200 distinct trigrams in a 128-bit bloom has FP rate ≈ 1.0 — the bloom is useless, and tier-3 still has to read and check it.
Both extremes pay full cost in storage and in the per-row recheck phase.
Proposal
Encode the bloom width per row, sized from the row's distinct trigram count at index-build time. Three categories:
Row trigram count k | Storage | FP rate target |
|---|---|---|
k = 0 | flag bit only, no bloom | n/a |
1 ≤ k ≤ 32 | 16-bit bloom + 4-bit hash count | <0.1% |
33 ≤ k ≤ 256 | 64-bit bloom + 5-bit hash count | <1% |
k > 256 | flag bit "always passes" + no bloom | accept the recheck cost |
Width is decided once at build time per row. Stored in the posting-leaf payload as:
struct PerRowBloom {
uint8 width_class; // 0=empty, 1=16, 2=64, 3=always-pass
uint8 nhashes; // hash function count
union {
uint16 b16;
uint64 b64;
// width_class=0 or 3 carries no payload
} bits;
}
Average payload:
| Width class | Bytes per row |
|---|---|
| 0 | 1 |
| 1 | 4 (1 + 1 + 2 padded to 4) |
| 2 | 16 (1 + 1 + 8 padded to 16) |
| 3 | 1 |
For a corpus where most rows have <32 distinct trigrams (typical for code identifiers, log lines, SKUs), payload averages ≈4-6 bytes per row vs 16 today. 70-80% payload reduction.
For a corpus of long prose (paragraphs, articles), most rows land in width_class 2 or 3 — comparable to today's flat 128-bit, or smaller.
Read path
pg_tre_posting_lookup_tuple_bloom reads width_class first,
then dispatches:
- 0: row matches every query trigram trivially (unlikely; only empty rows).
- 1, 2: dispatch to width-specific bloom check using the right
nhashesvalue. - 3: skip the bloom check, return "candidate, must recheck".
Why this depends on chain-rank repair
The current chain-rank lookup (in
pg_tre_posting_lookup_tuple_bloom) is broken for multi-leaf
posting trees: the per-row payload offset is computed from a
per-leaf rank that doesn't accumulate across right-link chains.
Today this is bypassed entirely via
pg_tre.tuple_bloom_enable=false.
Fixing chain-rank means:
- The chain walker maintains a running TID-count as it traverses leaves.
sm_rankon a per-leaf basis returns the local rank; the walker adds it to the running count.- The payload offset table (currently per-leaf) becomes a per-leaf-with-base offset, indexed by the global rank.
Once chain-rank works, switching tier-3 default back to true
becomes safe. At that point variable-width blooms become a
size optimization on top of a working tier-3.
On-disk format compatibility
Adding a width_class byte changes the per-row payload layout.
Two options:
Option A (simpler): Bump PG_TRE_FORMAT_VERSION to 4.
Existing 1.x indexes need REINDEX. Easy to test, simple
upgrade-tests matrix update.
Option B (compatible): Encode width_class in the existing payload region by reserving a sentinel value. Existing fixed-128-bit blooms continue to read with the old code path; new builds emit variable-width. No format bump.
Option B requires careful sentinel-value choice and audit; Option A is cleaner. Since posting-page coalescing (v2.0) is also a format bump, this work might bundle with that and ship in v2.0 instead of v1.3. Decision deferred until chain-rank repair lands and we measure the actual size improvement on a real corpus.
Implementation phases
- Phase 1 — chain-rank repair.
pg_tre.tuple_bloom_enablebecomes safe to set true; default still false. Regression tests for the multi-leaf 'the' case (currently masked). - Phase 2 — width_class encode/decode. Build path picks width based on row's distinct trigram count. Read path dispatches. No size change yet (still emits 16-byte payload, just with a width tag).
- Phase 3 — variable storage. Width-1/2 actually emit 4 / 16-byte payloads. Format-version bump or sentinel compatibility shim per the decision above.
- Phase 4 — re-default tier-3 to true. Once chain-rank
and variable-width are correct and measured, flip
pg_tre.tuple_bloom_enabledefault back to true.
Risks
- The chain-rank repair is non-trivial. Touches the scan path's hottest loop (per-row tier-3 lookup). Needs a benchmark before/after; risk of slowdown on the unaffected single-leaf case if the chain accumulator adds branches.
- Build CPU cost. Counting distinct trigrams per row is free at build time (we already accumulate them); width selection is one comparison. Negligible.
- Adversarial inputs. A row with crafted trigram count exactly at the width-class boundary (32, 256) could cause thrashing on rebuild after VACUUM removes tids; pin the width on the highest historical count to avoid oscillation.