Block-max skipping on a toy engine
Simulate a modelNeeds Python with NumPy
Problem
A search engine scores documents as the sum of per-term contributions and keeps only the top 10, so most postings cannot change the answer. Block-max skipping stores, for each block of 128 postings, the largest contribution any document in it can get; a block whose maximum, plus whatever the document already has, cannot beat the current tenth-best score is skipped without being decoded. It is exact as long as the score is decomposable into bounded per-term parts.
Build postings lists for 50,000 documents and 5 terms, where term frequencies are Zipfian. Implement two query functions: one scores every document that matches any term, the other keeps the best 10 and skips blocks that cannot reach them. Count how many postings each reads and check that both return the same top 10. Then add a per-document boost (about 1% of documents) that is not part of any term's stored maximum.
The question. How many postings does skipping save, is the top 10 still exact, and what does an unbounded boost do?
Predict
Identical results, and the skipping version reads a small fraction (often 5 to 20 percent) of the postings when the query mixes a rare term with a common one. With a score that has no per-term bound, skipping stops being safe. This is decomposability: the algebra of the score decides what can be skipped.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
block-max-skipping-on-a-toy.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Block-max skipping on a toy index. Posting lists for 50,000 documents and 5 terms with Zipfian frequencies; a document's score is the sum of per-term contributions. Exhaustive scoring against top-10 with block-max skipping (blocks of 128 postings whose stored maximum, plus the other terms' maximums, cannot beat the tenth-best score are skipped). Postings read and whether the top 10 agree; then a score with a per-document boost (about 1% of documents) that is not part of any term's stored maximum, so the block bounds no longer hold. """ import argparse, heapq, json import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--docs", type=int, default=50_000, help="Documents.") p.add_argument("--block", type=int, default=128, help="Postings per block.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(args.seed) shares = [0.6, 0.2, 0.05, 0.01, 0.002] # Zipf-like document frequencies of the 5 terms post = {} for t, s in enumerate(shares): docs = np.sort(rng.choice(args.docs, int(args.docs * s), replace=False)) post[t] = (docs, rng.random(len(docs)) * (1 + 3 * (1 - s))) # rarer terms weigh more, like idf def exhaustive(terms, bonus=False): score = {}; read = 0 for t in terms: d, w = post[t]; read += len(d) for doc, val in zip(d.tolist(), w.tolist()): score[doc] = score.get(doc, 0) + val if bonus: for doc in score: if doc % 97 == 0: score[doc] += 4 # a boost that is not part of any term's stored maximum return [d for _, d in heapq.nlargest(10, ((v, d) for d, v in score.items()))], read def blockmax(terms, bonus=False): """Term at a time, rarest (highest-impact) term first. A later term's block is decoded only if its stored maximum, plus the best partial score any document in the block's id range already has, could beat the current tenth-best score.""" order = sorted(terms, key=lambda t: len(post[t][0])); score, seen, read = {}, {}, 0 for n, t in enumerate(order): d, w = post[t]; tenth = sorted(score.values(), reverse=True)[9] if len(score) >= 10 else -1 remaining = sum(float(post[o][1].max()) for o in order[n + 1:]) # what terms still to come could add known = np.array(sorted(score)) if score else np.array([], dtype=np.int64) for lo in range(0, len(d), args.block): hi = min(lo + args.block, len(d)); bmax = float(w[lo:hi].max()) a, b = np.searchsorted(known, d[lo]), np.searchsorted(known, d[hi - 1], side="right") best = max((score[k] for k in known[a:b].tolist()), default=0.0) if n > 0 and len(score) >= 10 and bmax + best + remaining <= tenth: continue read += hi - lo for doc, val in zip(d[lo:hi].tolist(), w[lo:hi].tolist()): score[doc] = score.get(doc, 0) + val; seen[doc] = seen.get(doc, 0) + 1 if bonus: for doc in score: if doc % 97 == 0: score[doc] += 4 # applied only to documents the reader decoded return [d for _, d in heapq.nlargest(10, ((v, d) for d, v in score.items()))], read rows = [] for name, terms in (("rare + common (terms 0, 4)", [0, 4]), ("all five terms", [0, 1, 2, 3, 4])): ex, r_ex = exhaustive(terms); bm, r_bm = blockmax(terms) rows.append({"query": name, "score": "sum of per-term parts", "postings_exhaustive": r_ex, "postings_blockmax": r_bm, "read_pct": r_bm / r_ex * 100, "same_top10": "yes" if set(ex) == set(bm) else "no"}) ex, r_ex = exhaustive([0, 4], bonus=True); bm, r_bm = blockmax([0, 4], bonus=True) rows.append({"query": "rare + common (terms 0, 4)", "score": "plus a boost outside the bounds", "postings_exhaustive": r_ex, "postings_blockmax": r_bm, "read_pct": r_bm / r_ex * 100, "same_top10": "yes" if set(ex) == set(bm) else "no"}) for r in rows: print(f"{r['query']:<28} {r['score']:<28} exhaustive {r['postings_exhaustive']:>6,} block-max {r['postings_blockmax']:>6,} ({r['read_pct']:5.1f}%) same top 10: {r['same_top10']}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"queries": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run block-max-skipping-on-a-toy.py uv run block-max-skipping-on-a-toy.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --docs50,000 | Documents. |
| --block128 | Postings per block. |
| --seed0 | Random seed. |
Our run
Exhaustive against block-max
| Query | Score | Exhaustive reads | Block-max reads | Share read | Same top 10 |
|---|---|---|---|---|---|
| rare + common (terms 0, 4) | sum of per-term parts | 30.1K | 7.27K | 24.1% | yes |
| all five terms | sum of per-term parts | 43.1K | 29.1K | 67.5% | yes |
| rare + common (terms 0, 4) | plus a boost outside the bounds | 30.1K | 7.27K | 24.1% | no |
rare + common (terms 0, 4) sum of per-term parts exhaustive 30,100 block-max 7,268 ( 24.1%) same top 10: yes
all five terms sum of per-term parts exhaustive 43,100 block-max 29,100 ( 67.5%) same top 10: yes
rare + common (terms 0, 4) plus a boost outside the bounds exhaustive 30,100 block-max 7,268 ( 24.1%) same top 10: noWhat we found
For a rare and a common term, block-max skipping read 7.27K of 30.1K postings (24.1%) and returned the same top 10 (yes); with all five terms it read 67.5%, since several common terms leave few blocks that can be ruled out. With a per-document boost outside the stored bounds, it read the same 24.1% and returned a different top 10 (no): documents in skipped blocks would have won with their boost.
Read it
Skipping is a proof that some documents cannot win, and the proof needs an upper bound for every part of the score. Anything added to the score without a stored bound (a recency boost, a personalization term) must either get one or be applied after retrieval to a candidate set large enough to absorb it.