Skip to content
About
Main content

Choose nlist for IVF

Measure your machineNeeds Python with FAISS

Problem

An index clusters the vectors into cells around centroids and, at query time, searches only the nprobe cells nearest the query. More cells mean smaller cells, so each probed cell is cheaper but the true neighbours are more likely to sit in a cell that was not probed; more probes raise and cost time. The usual starting point is nlist ≈ √N.

Build an IVF index with FAISS over 1 million random 128-dimensional vectors for nlist = 100, 1,000, 4,000 and 16,000 and nprobe = 1, 8 and 32. Measure queries per second and recall@10 against an exact search on 1,000 queries.

The question. How do speed and recall trade off across nlist and nprobe, and is nlist ≈ √N the best place to be?


Predict

On uniformly random 128-dimensional vectors, what is the best recall@10 any tested IVF setting reaches?

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.

    choose-nlist-for-ivf.py45 linesDownload
    View the code
    choose-nlist-for-ivf.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy", "faiss-cpu"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Choose nlist for an IVF vector index.
    
    An IVF index (FAISS) over random 128-dimensional vectors for nlist from 100
    to 16,000 and nprobe 1, 8 and 32: queries per second and recall@10 against
    exact search on 1,000 queries.
    """
    import argparse, json, time
    import numpy as np, faiss
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--n", type=int, default=1_000_000, help="Vectors.")
    p.add_argument("--dim", type=int, default=128, help="Dimensions.")
    p.add_argument("--nlists", type=lambda s: [int(v) for v in s.split(",")], default=[100, 1000, 4000, 16000], help="nlist values.")
    p.add_argument("--nprobes", type=lambda s: [int(v) for v in s.split(",")], default=[1, 8, 32], help="nprobe values.")
    p.add_argument("--queries", type=int, default=1000, help="Queries.")
    p.add_argument("--k", type=int, default=10, help="Neighbours.")
    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)
    xb = rng.random((args.n, args.dim), dtype=np.float32); xq = rng.random((args.queries, args.dim), dtype=np.float32)
    flat = faiss.IndexFlatL2(args.dim); flat.add(xb)
    _, truth = flat.search(xq, args.k)
    rows = []
    for nl in args.nlists:
        q = faiss.IndexFlatL2(args.dim); idx = faiss.IndexIVFFlat(q, args.dim, nl)
        idx.train(xb[: max(nl * 40, 100_000)]); idx.add(xb)
        for npb in args.nprobes:
            idx.nprobe = npb
            t = time.perf_counter(); _, got = idx.search(xq, args.k); dt = time.perf_counter() - t
            recall = float(np.mean([len(set(g) & set(tr)) / args.k for g, tr in zip(got, truth)]))
            rows.append({"nlist": nl, "nprobe": npb, "qps": args.queries / dt, "recall": recall})
            print(f"nlist {nl:>6} nprobe {npb:>3}: {args.queries / dt:9,.0f} queries/s   recall@{args.k} {recall:.3f}")
    print(f"sqrt(N) = {args.n ** 0.5:.0f}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"settings": rows, "summary": [{"sqrt_n": args.n ** 0.5}]}}, 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 choose-nlist-for-ivf.py
    uv run choose-nlist-for-ivf.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n1,000,000 Vectors.
--dim128 Dimensions.
--nlists100, 1000, 4000, 16,000 nlist values.
--nprobes1, 8, 32 nprobe values.
--queries1000 Queries.
--k10 Neighbours.
--seed0 Random seed.

Our run

Speed against recallApple M2 · 8 cores · 8 GB
1001000400016000
Data table
Speed against recall
Recall@10seriesQueries per second
0.06110010.1K
0.2951001,570
0.682100398
0.02100075K
0.101100012.4K
0.24710003,480
0.012400076.3K
0.056400030.5K
0.14340009,500
0.0111600027K
0.0441600024.3K
0.1021600015.6K
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · faiss-cpu 1.15.1 · run 2026-10-20

IVF settings

IVF settings
nlistnprobeQueries/sRecall@10
100110.1K0.061
10081.57K0.295
100323980.682
1,000175K0.02
1,000812.4K0.101
1,000323.48K0.247
4,000176.3K0.012
4,000830.5K0.056
4,000329.5K0.143
16K127K0.011
16K824.3K0.044
16K3215.6K0.102
Output · uv run choose-nlist-for-ivf.py
nlist    100 nprobe   1:    10,141 queries/s   recall@10 0.061
nlist    100 nprobe   8:     1,573 queries/s   recall@10 0.295
nlist    100 nprobe  32:       398 queries/s   recall@10 0.682
nlist   1000 nprobe   1:    74,955 queries/s   recall@10 0.020
nlist   1000 nprobe   8:    12,389 queries/s   recall@10 0.101
nlist   1000 nprobe  32:     3,479 queries/s   recall@10 0.247
nlist   4000 nprobe   1:    76,286 queries/s   recall@10 0.012
nlist   4000 nprobe   8:    30,490 queries/s   recall@10 0.056
nlist   4000 nprobe  32:     9,496 queries/s   recall@10 0.143
nlist  16000 nprobe   1:    27,028 queries/s   recall@10 0.011
nlist  16000 nprobe   8:    24,259 queries/s   recall@10 0.044
nlist  16000 nprobe  32:    15,615 queries/s   recall@10 0.102
sqrt(N) = 1000

What we found

Recall rose with nprobe and speed fell, as expected, but recall was low everywhere: the best was 0.682 (nlist 100, nprobe 32). Uniformly random vectors in 128 dimensions have no cluster structure, so a query's true neighbours are spread over many cells. Near a recall of 0.1, nlist = 16,000 with nprobe 32 (15.6K queries/s at 0.102) beat nlist = √N = 1,000 with nprobe 8 (12.4K at 0.101), so √N was not the sweet spot on this data.


Read it

IVF works by betting that neighbours share a cell, and random vectors defeat the bet. On real embeddings, which cluster, recall at a given nprobe is far higher and the √N rule is a reasonable start. Benchmark on your own vectors: a synthetic random set measures the worst case, not your case.