Skip to content
About
Main content

Measure recall against brute-force truth, and see the filtered-search collapse

Measure your machineNeeds Python with hnswlib, NumPy

Problem

An approximate index trades for speed, and recall can only be known against exact results: a brute-force search over the same vectors. '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 , so recall is harder than on real ). 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% : the top 10, post-filter 1,000 candidates, and by brute force over the matching vectors.

The question. How does recall trade against speed, and what does each filtering strategy return?


Predict

On random Gaussian vectors, what recall@10 does HNSW reach at efSearch 200?

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.

    measure-recall-against-brute-force-truth.py58 linesDownload
    View 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)
    
  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 measure-recall-against-brute-force-truth.py
    uv run measure-recall-against-brute-force-truth.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n200,000 Vectors.
--dim128 Dimension.
--queries1000 Queries.
--selectivity0.005 Share of vectors that pass the filter.

Our run

Recall against speedApple M2 · 8 cores · 8 GB
Data table
Recall against speed
Recall@10Queries per second
0.12618.3K
0.20710.7K
0.3435,240
0.4863,000
0.6371,580
macOS 26.6.2 · Python 3.13.3 · hnswlib 0.8.0 · numpy 2.5.4 · run 2026-10-20

efSearch sweep

efSearch sweep
efSearchRecall@10Queries/s
100.12618.3K
200.20710.7K
500.3435.24K
1000.4863K
2000.6371.58K

A filter keeping 0.5% of vectors

A filter keeping 0.5% of vectors
MethodResults (of 10)Recallms per query
post-filter the top 100.0530.0050.631
post-filter 1,000 candidates4.690.4592.91
pre-filter, brute force over 1,008 vectors1010.01
Output · uv run measure-recall-against-brute-force-truth.py
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 query

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