Recall@k of an approximate index
Approximate vector indexes are fast because they skip most of the data. The price is that sometimes they skip a true neighbour. Recall@k measures that price, and it’s the number you tune an index against.
Write recall_at_k(queries, docs, approx, k):
queriesanddocsare lists of float vectors, all the same length.approx[i]is the list of doc indices an approximate index returned forqueries[i], best first. It can be shorter or longer thank.- For each query, compute the exact top
kdocs by cosine similarity. Ties go to the lower index. A zero vector has similarity0with everything. Ifk > len(docs), the exact list has every doc. - That query’s recall is the number of exact top-k indices that appear in the first
kentries ofapprox[i], divided by the length of the exact list. - Return the mean recall over all queries, as a float. With no queries, return
0.0.
>>> docs = [[1, 0], [0, 1], [1, 1]]
>>> recall_at_k([[1, 0.1]], docs, [[0, 1]], 2) # exact top-2 is [0, 2]
0.5
Hints get more specific as you go. Reveal one, try again, then come back.
Split the problem. One function returns the exact top-k indices for a query. The other compares two lists.
For the exact list, score every doc, then
sorted(range(len(docs)), key=lambda i: (-score[i], i))[:k]. Precompute doc lengths once; they’re
the same for every query.
Turn approx[i][:k] into a set and count how many exact indices are in it. Duplicates in the
approximate list then can’t count twice.
Try one more hint first? The solution is always here, but working it out is what makes it stick.
Approach
Brute-force the ground truth, then count overlaps.
import math
def recall_at_k(queries, docs, approx, k):
if not queries:
return 0.0
def norm(v):
return math.sqrt(sum(x * x for x in v))
dnorms = [norm(d) for d in docs]
total = 0.0
for q, got in zip(queries, approx):
qn = norm(q)
scores = []
for d, dn in zip(docs, dnorms):
if qn == 0 or dn == 0:
scores.append(0.0)
else:
scores.append(sum(a * b for a, b in zip(q, d)) / (qn * dn))
exact = sorted(range(len(docs)), key=lambda i: (-scores[i], i))[:k]
if not exact:
continue
found = set(got[:k])
total += sum(1 for i in exact if i in found) / len(exact)
return total / len(queries)
Only the first k approximate results count. Otherwise an index could score perfect recall by
returning everything. The set also means a duplicate in the approximate list can’t be counted twice.
Complexity
O(q·n·d) to score q queries against n docs of dimension d, plus O(q·n log n) for the sorts.
That is exactly the cost the approximate index exists to avoid, which is why you compute ground truth
once, offline, on a sample of queries.
Why this matters
Every vector index has knobs: ef_search in HNSW, nprobe in IVF. Turning them up raises recall and
latency together. You pick a setting by plotting recall@k against queries per second, and this
function gives you the recall axis. In a RAG system, recall at the retrieval step caps answer quality.
A chunk that was never retrieved can’t be cited, however good the model is.
Per-problem discussion threads are coming. Until then, questions and better solutions are welcome on LinkedIn or by email at hello@builtfrombits.com.
Press ⌘ ↵ or Run to check the visible tests. Submit runs the hidden ones too. Everything runs in your browser.