Choose nlist for IVF
Measure your machineNeeds Python with FAISS
Problem
An IVF index clusters the vectors into nlist 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 recall 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
The best speed at a given recall appears near nlist ≈ √N for small nprobe; recall rises with nprobe while speed falls.
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
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)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
| Knob · default | What 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
Data table
| Recall@10 | series | Queries per second |
|---|---|---|
| 0.061 | 100 | 10.1K |
| 0.295 | 100 | 1,570 |
| 0.682 | 100 | 398 |
| 0.02 | 1000 | 75K |
| 0.101 | 1000 | 12.4K |
| 0.247 | 1000 | 3,480 |
| 0.012 | 4000 | 76.3K |
| 0.056 | 4000 | 30.5K |
| 0.143 | 4000 | 9,500 |
| 0.011 | 16000 | 27K |
| 0.044 | 16000 | 24.3K |
| 0.102 | 16000 | 15.6K |
IVF settings
| nlist | nprobe | Queries/s | Recall@10 |
|---|---|---|---|
| 100 | 1 | 10.1K | 0.061 |
| 100 | 8 | 1.57K | 0.295 |
| 100 | 32 | 398 | 0.682 |
| 1,000 | 1 | 75K | 0.02 |
| 1,000 | 8 | 12.4K | 0.101 |
| 1,000 | 32 | 3.48K | 0.247 |
| 4,000 | 1 | 76.3K | 0.012 |
| 4,000 | 8 | 30.5K | 0.056 |
| 4,000 | 32 | 9.5K | 0.143 |
| 16K | 1 | 27K | 0.011 |
| 16K | 8 | 24.3K | 0.044 |
| 16K | 32 | 15.6K | 0.102 |
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) = 1000What 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.