Inverted index intersection
Measure your machineNeeds Python
Problem
An inverted index maps each term to the sorted list of documents that contain it, and a query for several terms intersects their lists. A two-pointer merge walks both lists, so its cost is the sum of their lengths; when one list is short and the other long, skipping ahead in the long list (jumping over blocks of entries that cannot match) makes the cost depend on the short list. Processing terms smallest list first limits the intermediate results that later terms must be intersected with.
Build an inverted index in Python over a million short documents with a Zipf-like word distribution. Implement intersection with sorted lists and a two-pointer merge. Compare processing the terms smallest-list-first against query order, and add a skip step (jump every 128 entries). Queries pair a very common term with a rare one.
The question. How much does each strategy cut the work of intersecting a common term with a rare one?
Predict
Smallest-first is many times faster when one term is rare; skips help when one list is much longer than the other.
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
inverted-index-intersection.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Inverted index intersection: order and skips. An inverted index over a million short documents with a Zipf-like word distribution. Queries mixing a rare and a common term, intersected in query order (common first) and smallest list first, with and without skips every 128 entries. """ import argparse, bisect, json, time from collections import defaultdict import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--docs", type=int, default=1_000_000, help="Documents.") p.add_argument("--words-per-doc", type=int, default=8, help="Words per document.") p.add_argument("--vocab", type=int, default=50_000, help="Vocabulary size.") p.add_argument("--queries", type=int, default=200, help="Queries.") p.add_argument("--skip", type=int, default=128, help="Skip step.") 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) w = 1 / np.arange(1, args.vocab + 1) ** 1.1; w /= w.sum() words = rng.choice(args.vocab, size=(args.docs, args.words_per_doc), p=w) postings = defaultdict(list) for d, ws in enumerate(words.tolist()): for t in set(ws): postings[t].append(d) def intersect(a, b, skip=0): i = j = 0; out = []; steps = 0 while i < len(a) and j < len(b): steps += 1 if a[i] == b[j]: out.append(a[i]); i += 1; j += 1 elif a[i] < b[j]: if skip and i + skip < len(a) and a[i + skip] <= b[j]: i += skip else: i += 1 else: if skip and j + skip < len(b) and b[j + skip] <= a[i]: j += skip else: j += 1 return out, steps common = [t for t in range(5)] # the most frequent terms rare = [t for t in range(2000, 40000) if 20 <= len(postings[t]) <= 200][: args.queries] qs = [(common[i % 5], rare[i]) for i in range(len(rare))] rows = [] for label, order, skip in (("query order (common term first)", "query", 0), ("smallest list first", "small", 0), ("query order + skips", "query", args.skip), ("smallest first + skips", "small", args.skip)): t = time.perf_counter(); steps = 0 for c, r in qs: a, b = postings[c], postings[r] if order == "small": a, b = (a, b) if len(a) <= len(b) else (b, a) _, s = intersect(a, b, skip); steps += s dt = (time.perf_counter() - t) / len(qs) rows.append({"strategy": label, "ms_per_query": dt * 1000, "steps_per_query": steps / len(qs)}) print(f"{label:<34} {dt * 1000:8.2f} ms per query {steps / len(qs):10,.0f} comparison steps") print(f"common term lists: {len(postings[0]):,} docs; rare term lists: {min(len(postings[r]) for r in rare)}-{max(len(postings[r]) for r in rare)} docs") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"strategies": rows, "summary": [{"common_len": len(postings[0])}]}}, 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 inverted-index-intersection.py uv run inverted-index-intersection.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --docs1,000,000 | Documents. |
| --words-per-doc8 | Words per document. |
| --vocab50,000 | Vocabulary size. |
| --queries200 | Queries. |
| --skip128 | Skip step. |
| --seed0 | Random seed. |
Our run
Intersection strategies
| Strategy | ms per query | Comparison steps |
|---|---|---|
| query order (common term first) | 36.8 | 357K |
| smallest list first | 37.4 | 357K |
| query order + skips | 2.52 | 14.7K |
| smallest first + skips | 2.54 | 14.7K |
query order (common term first) 36.80 ms per query 357,085 comparison steps
smallest list first 37.36 ms per query 357,085 comparison steps
query order + skips 2.52 ms per query 14,694 comparison steps
smallest first + skips 2.54 ms per query 14,694 comparison steps
common term lists: 698,395 docs; rare term lists: 155-200 docsWhat we found
Ordering alone changed nothing: query order and smallest-first both took 357K comparison steps per query (36.8 and 37.4 ms). With two terms a merge walks both lists whichever comes first, so the expectation applies only to queries of three or more terms, where the order decides the size of the intermediate result. Skips were what helped: 14.7K steps and 2.52 ms, because the common term's list (698K documents) was crossed in jumps.
Read it
For two terms, what matters is not visiting every entry of the long list; skip pointers (or galloping search) make the cost follow the short list. For many terms, order matters as well: start from the rarest, so every later intersection works on a small candidate set.