Measure recall against brute-force truth, and see the filtered-search collapse
Measure your machineNeeds Python with hnswlib, NumPy
Problem
An approximate index trades recall for speed, and recall can only be known against exact results: a brute-force search over the same vectors. HNSW's efSearch sets how many candidates the graph walk keeps; more candidates, higher recall, fewer queries per second. Filtered search is the hard middle: a filter that keeps 0.5% of vectors leaves almost none of an unfiltered top 10, fetching many more candidates helps only partly, and pre-filtering by brute force over the matching vectors is exact and cheap only while they are few.
Use 200,000 random Gaussian vectors (they have high intrinsic dimension, so recall is harder than on real embeddings). Build an HNSW index. Take 1,000 queries and compute exact top 10 by brute force. For efSearch in 10, 20, 50, 100, 200 record recall@10 and queries per second. Then add a random attribute with 0.5% selectivity: post-filter the top 10, post-filter 1,000 candidates, and pre-filter by brute force over the matching vectors.
The question. How does recall trade against speed, and what does each filtering strategy return?
Predict
Recall rises with efSearch while queries per second fall. Post-filtering the top 10 leaves about 0.05 of 10 results, almost always zero; fetching 1,000 candidates helps; pre-filtering by brute force is exact.
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
measure-recall-against-brute-force-truth.py # /// script # requires-python = ">=3.10" # dependencies = ["hnswlib", "numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Measure recall against brute-force truth. An HNSW index (hnswlib) over 200,000 random Gaussian 128-dimensional vectors; exact top 10 by brute force for 1,000 queries; recall@10 and queries per second for efSearch 10 to 200. Then a filter that keeps 0.5% of vectors: post-filtering the top 10, post-filtering 1,000 candidates, and pre-filtering by brute force over the matching vectors. """ import argparse, json, time import hnswlib, numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=200_000, help="Vectors.") p.add_argument("--dim", type=int, default=128, help="Dimension.") p.add_argument("--queries", type=int, default=1000, help="Queries.") p.add_argument("--selectivity", type=float, default=0.005, help="Share of vectors that pass the filter.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(0) X = rng.standard_normal((args.n, args.dim), dtype=np.float32); Q = rng.standard_normal((args.queries, args.dim), dtype=np.float32) def exact(Qs, Xs, k=10): d = (Xs ** 2).sum(1)[None, :] - 2 * Qs @ Xs.T return np.argpartition(d, k, axis=1)[:, :k] truth = np.vstack([exact(Q[i:i + 100], X) for i in range(0, args.queries, 100)]) idx = hnswlib.Index(space="l2", dim=args.dim); idx.init_index(max_elements=args.n, ef_construction=200, M=16) t = time.perf_counter(); idx.add_items(X, num_threads=4); build_s = time.perf_counter() - t idx.set_num_threads(1) def recall(found, true): return np.mean([len(set(f) & set(tr)) / len(tr) for f, tr in zip(found, true)]) curve = [] for ef in (10, 20, 50, 100, 200): idx.set_ef(max(ef, 10)); t = time.perf_counter(); labels, _ = idx.knn_query(Q, k=10); el = time.perf_counter() - t curve.append({"ef_search": ef, "recall": float(recall(labels, truth)), "qps": args.queries / el}) allowed = rng.random(args.n) < args.selectivity; ids = np.flatnonzero(allowed) ftruth = np.vstack([ids[exact(Q[i:i + 100], X[ids])] for i in range(0, args.queries, 100)]) filt = [] idx.set_ef(200); t = time.perf_counter(); lab10, _ = idx.knn_query(Q, k=10); t10 = time.perf_counter() - t kept10 = [[x for x in row if allowed[x]] for row in lab10] filt.append({"method": "post-filter the top 10", "mean_results": float(np.mean([len(r) for r in kept10])), "recall": float(recall(kept10, ftruth)), "ms_per_query": t10 / args.queries * 1000}) idx.set_ef(1000); t = time.perf_counter(); lab1k, _ = idx.knn_query(Q, k=1000); t1k = time.perf_counter() - t kept1k = [[x for x in row if allowed[x]][:10] for row in lab1k] filt.append({"method": "post-filter 1,000 candidates", "mean_results": float(np.mean([len(r) for r in kept1k])), "recall": float(recall(kept1k, ftruth)), "ms_per_query": t1k / args.queries * 1000}) t = time.perf_counter(); pre = np.vstack([ids[exact(Q[i:i + 100], X[ids])] for i in range(0, args.queries, 100)]); tp = time.perf_counter() - t filt.append({"method": f"pre-filter, brute force over {len(ids):,} vectors", "mean_results": 10.0, "recall": float(recall(pre, ftruth)), "ms_per_query": tp / args.queries * 1000}) summary = {"build_s": build_s, "allowed": int(len(ids))} for r in curve: print(f"efSearch {r['ef_search']:>3}: recall@10 {r['recall']:.3f}, {r['qps']:8,.0f} queries/s") for r in filt: print(f"{r['method']:<44} results {r['mean_results']:5.2f} of 10, recall {r['recall']:.3f}, {r['ms_per_query']:.2f} ms per query") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"curve": curve, "filtered": filt}}, 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 measure-recall-against-brute-force-truth.py uv run measure-recall-against-brute-force-truth.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n200,000 | Vectors. |
| --dim128 | Dimension. |
| --queries1000 | Queries. |
| --selectivity0.005 | Share of vectors that pass the filter. |
Our run
Data table
| Recall@10 | Queries per second |
|---|---|
| 0.126 | 18.3K |
| 0.207 | 10.7K |
| 0.343 | 5,240 |
| 0.486 | 3,000 |
| 0.637 | 1,580 |
efSearch sweep
| efSearch | Recall@10 | Queries/s |
|---|---|---|
| 10 | 0.126 | 18.3K |
| 20 | 0.207 | 10.7K |
| 50 | 0.343 | 5.24K |
| 100 | 0.486 | 3K |
| 200 | 0.637 | 1.58K |
A filter keeping 0.5% of vectors
| Method | Results (of 10) | Recall | ms per query |
|---|---|---|---|
| post-filter the top 10 | 0.053 | 0.005 | 0.631 |
| post-filter 1,000 candidates | 4.69 | 0.459 | 2.91 |
| pre-filter, brute force over 1,008 vectors | 10 | 1 | 0.01 |
efSearch 10: recall@10 0.126, 18,261 queries/s
efSearch 20: recall@10 0.207, 10,656 queries/s
efSearch 50: recall@10 0.343, 5,243 queries/s
efSearch 100: recall@10 0.486, 3,000 queries/s
efSearch 200: recall@10 0.637, 1,579 queries/s
post-filter the top 10 results 0.05 of 10, recall 0.005, 0.63 ms per query
post-filter 1,000 candidates results 4.69 of 10, recall 0.459, 2.91 ms per query
pre-filter, brute force over 1,008 vectors results 10.00 of 10, recall 1.000, 0.01 ms per queryWhat we found
Recall rose with efSearch and speed fell: 0.126 at 18.3K queries per second with efSearch 10, 0.637 at 1.58K with 200, low throughout because random Gaussian vectors have no structure for the graph to exploit. With the 0.5% filter, post-filtering the top 10 returned 0.053 results on average; post-filtering 1,000 candidates returned 4.69 with recall 0.459; pre-filtering by brute force over the 1.01K matching vectors was exact and took 0.01 ms per query.
Read it
Report recall with every ANN latency number: speed without recall is not a measurement. For filters, the selectivity decides the strategy: very selective filters want pre-filtering (or an index per partition), loose ones post-filtering, and the middle needs engines that filter during the graph walk.