Cosine Top-k with a Bounded Heap
Write `top_k_cosine(query, corpus, k)` where `corpus` maps doc id to an embedding. Return the `k` most similar `(id, score)` pairs by cosine similarity, highest first, ties broken by smaller id. Skip zero vectors (cosine is undefined), raise on a zero query or a dimension mismatch, and use a size-k min-heap so memory stays O(k) for any corpus size.
Normalise once and cosine becomes a dot product; in production you store unit vectors so the index can use inner product. A size-k min-heap whose root is the current k-th best lets you reject most candidates with one comparison, giving O(n log k) instead of sorting all n. Zero vectors are a real data-quality signal (empty chunks, failed embedding calls) and must not silently score 0 and crowd the results. This brute-force scan is also the ground truth you use to measure an ANN index's recall.
Try it first.
tests.py · 7 passing
import random
corpus = {"a": [1.0, 0.0], "b": [0.0, 1.0], "c": [1.0, 1.0], "z": [0.0, 0.0], "d": [2.0, 0.0]}
res = top_k_cosine([1.0, 0.1], corpus, 2)
assert [d for d, _ in res] == ["a", "d"]
assert abs(res[0][1] - res[1][1]) < 1e-12
assert top_k_cosine([1.0, 0.0], corpus, 10)[-1][0] == "b"
assert len(top_k_cosine([1.0, 0.0], corpus, 10)) == 4
assert top_k_cosine([1.0, 0.0], corpus, 0) == []
for bad in ([0.0, 0.0], [1.0, 0.0, 0.0]):
try:
top_k_cosine(bad, corpus, 1)
assert False
except ValueError:
pass
rng = random.Random(1)
big = {f"d{i:03d}": [rng.gauss(0, 1) for _ in range(8)] for i in range(300)}
q = [rng.gauss(0, 1) for _ in range(8)]
cos = lambda u, v: sum(a * b for a, b in zip(u, v)) / math.sqrt(sum(a * a for a in u) * sum(b * b for b in v))
brute = sorted(big, key=lambda d: (-cos(q, big[d]), d))[:10]
assert [d for d, _ in top_k_cosine(q, big, 10)] == bruteAt 100M vectors brute force is too slow, so you move to HNSW or IVF-PQ and measure recall@k against exactly this function on a held-out query set. Discuss filtered search (tenant or ACL filters) and why post-filtering an ANN top-k can return fewer than k results; pre-filtered or partitioned indexes fix it.