Skip to content
About
Main content

Inverted index intersection

Measure your machineNeeds Python

Problem

An 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

Intersecting a common term with a rare one by merging: how much does processing the smaller list first cut the work?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    inverted-index-intersection.py66 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Intersection strategies
Strategyms per queryComparison steps
query order (common term first)36.8357K
smallest list first37.4357K
query order + skips2.5214.7K
smallest first + skips2.5414.7K
Reference runApple M2 · 8 cores · 8 GB
Output · uv run inverted-index-intersection.py
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 docs
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What 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; (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.