Skip to content

BM25 & Block-WeakAnd Internals

Mathematical formulation and index architecture of Okapi BM25, 1-byte fieldnorm quantization, delta-bitpacking, and Block-WeakAnd in vchord_bm25.

This page details the exact scoring equations and data structures implemented in vchord_bm25 (crates/bm25/src/bm25.rs and src/algorithm/block_wand.rs).

1. Okapi BM25 Scoring Formula

Given a document DD and a query Q=q1,q2,dots,qmQ = \\{q_1, q_2, \\dots, q_m\\} with query term frequencies c(qi,Q)c(q_i, Q) and document term frequencies f(qi,D)f(q_i, D), vchord_bm25 computes:

textBM25(D,Q)=sumqiinQcapDc(qi,Q)cdottextIDF(qi)cdottextTF(qi,D)\\text{BM25}(D, Q) = \\sum_{q_i \\in Q \\cap D} c(q_i, Q) \\cdot \\text{IDF}(q_i) \\cdot \\text{TF}(q_i, D)

Inverse Document Frequency (IDF\text{IDF})

textIDF(qi)=lnleft(fracN+1.0n(qi)+0.5right)\\text{IDF}(q_i) = \\ln\\left(\\frac{N + 1.0}{n(q_i) + 0.5}\\right)

where:

  • NN is the total number of indexed documents in the table.
  • n(qi)n(q_i) is the number of documents containing token qiq_i (document frequency).

Saturated Term Frequency (TF\text{TF})

textTF(qi,D)=fracf(qi,D)cdot(k1+1)f(qi,D)+k1cdotleft(1−b+bcdotfrachat∣D∣textavgdlright)\\text{TF}(q_i, D) = \\frac{f(q_i, D) \\cdot (k_1 + 1)}{f(q_i, D) + k_1 \\cdot \\left(1 - b + b \\cdot \\frac{\\hat{|D|}}{\\text{avgdl}}\\right)}

where:

  • f(qi,D)f(q_i, D) is the term frequency of token qiq_i in document DD.
  • hat∣D∣\\hat{|D|} is the quantized document length (total token count in DD) decoded from its 1-byte fieldnorm ID.
  • textavgdl\\text{avgdl} is the average document length across all indexed documents.
  • k1in[1.2,2.0]k_1 \\in [1.2, 2.0] (default 1.2) controls term frequency saturation.
  • bin[0.0,1.0]b \\in [0.0, 1.0] (default 0.75) controls document length normalization.

2. 1-Byte Fieldnorm Quantization (FIELDNORM_TO_LENGTH)

Storing a 32-bit float or integer length for every document and computing floating-point division for every scored posting would consume excessive memory bandwidth and CPU cycles. Instead, vchord_bm25 quantizes document length ∣D∣|D| into a single byte (u8, 0..=255) using the 256-entry FIELDNORM_TO_LENGTH lookup table:

  • Exact for short documents (0 – 40 tokens): Values 0..=40 map 1:1 to exact token counts.
  • Logarithmically stepped for longer documents (41 – 255): Steps widen progressively (e.g., 42, 44, 46, ... up to 2,013,265,944 tokens).
  • 256-Entry Precomputed Score Cache: Because fieldnorm has only 256 possible values, vchord_bm25 precomputes the denominator term for all 256 possible document lengths once per query term:
s0(qi)=textIDF(qi)cdot(k1+1),qquads1[textfn]=k1cdotleft(1−b+bcdotfractextFIELDNORMTOLENGTH[textfn]textavgdlright)s_0(q_i) = \\text{IDF}(q_i) \\cdot (k_1 + 1), \\qquad s_1[\\text{fn}] = k_1 \\cdot \\left(1 - b + b \\cdot \\frac{\\text{FIELDNORM\\_TO\\_LENGTH}[\\text{fn}]}{\\text{avgdl}}\\right)

Scoring a candidate posting during scan execution reduces to a single table lookup and one division:

textscore(qi,D)=fracf(qi,D)cdots0(qi)f(qi,D)+s1[textfieldnorm(D)]\\text{score}(q_i, D) = \\frac{f(q_i, D) \\cdot s_0(q_i)}{f(q_i, D) + s_1[\\text{fieldnorm}(D)]}

3. Posting List Compression & Block-WeakAnd (Block-Max WAND)

Fixed 128-Document Blocks with Delta-Bitpacking

For each term in a sealed segment, the inverted list of (doc_id, term_frequency) pairs is partitioned into fixed blocks of 128 documents and compressed with Delta-Bitpacking:

  1. Document IDs within a block are sorted in ascending order and stored as deltas (Δi=doci−doci−1\Delta_i = \text{doc}_i - \text{doc}_{i-1}), bitpacked using the minimum bit width required by the largest delta in the block.
  2. Each block header stores the last document ID in the block (for fast shallow seeking) and the Block-Max TF / Fieldnorm pair $(\text{tf}{\max}, \text{fn}{\min})$ that achieves the highest possible BM25 contribution inside that 128-document block.

Block-WeakAnd Top-K Pruning Loop

During an ORDER BY vec <&> to_bm25query(...) LIMIT K scan:

flowchart TD
    A["Sort query term cursors by current doc_id"] --> B["Accumulate upper-bound max_score across cursors\\nuntil sum > current Top-K threshold θ (Pivot Document)"]
    B --> C{"Sum of Block-Max scores\\nat Pivot > θ ?"}
    C -- "No (Prune Block!)" --> D["Skip entire 128-doc block\\nwithout decoding postings"]
    C -- "Yes" --> E{"All leading cursors\\naligned at Pivot doc_id?"}
    E -- "No" --> F["Advance leading cursor to Pivot doc_id"]
    E -- "Yes" --> G["Decode block & evaluate exact BM25 score\\nUpdate Top-K min-heap and raise θ"]
    D --> A
    F --> A
    G --> A
  1. Pivot Selection: Query term cursors are ordered by their current doc_id. Starting from the first cursor, vchord_bm25 sums each term’s global upper-bound max_score until the cumulative sum exceeds the current KK-th best score threshold θ\theta in the Top-K min-heap. The cursor where the sum crosses θ\theta defines the pivot document.
  2. Block-Max Refinement: Without decompressing any posting block, vchord_bm25 shallow-seeks each cursor up to the pivot’s 128-document block and sums the tighter per-block upper bounds (block_max_score).
  3. Fast Block Skipping: If the sum of block_max_score values is ≤θ\le \theta, no document in the current block intersection can possibly enter the Top-K results—the scanner immediately jumps past the block without decoding document IDs or frequencies.
  4. Exact Evaluation: Only when the block upper bound exceeds θ\theta and cursors align on pivot_doc does vchord_bm25 decode the block, verify visibility/deletion bitmap (and optional prefilter), compute the exact BM25 score, and update the Top-K heap.