Skip to content
AI-grafen
EUniversityComputer science· about 60 min· evolving, reviewed regularly· verified 2026-09-20· EN

Storing and finding vectors efficiently

Be able to explain why exact nearest neighbour search is expensive and how approximate indexes work.

Prerequisites

Intuition

Finding the nearest vectors to a query is simple if you have a thousand vectors: compare with all of them.

With ten million vectors at 768 dimensions that becomes 7.7 billion multiplications per query. It works, but not a hundred times a second.

Approximate indexes trade exactness for speed. They usually find the right neighbours, sometimes not, and do it a thousand times faster.

The indexThe ideaGood at
Flatcompare with allexact, small collections
IVFsplit into clusters, search the nearestlarge collections, adjustable
HNSWa navigable graph of neighbourslow latency, high recall
PQcompress the vectorsa memory limit

The measure that counts is called recall@k: of the kk true nearest neighbours, how many did the index find? 0.95 means that one in twenty is missed — which in a RAG system is usually entirely acceptable, since several documents are fetched anyway.

Formal

HNSW (hierarchical navigable small world) builds a layered neighbourhood network. The top layer has few nodes with long hops; the bottom has all the nodes with short hops. The search starts at the top, walks greedily towards the query, and goes down one layer at a time.

It is the same idea as a skip list: long hops first, then fine adjustment.

The parameterMeansThe effect
Mneighbours per nodehigher → better recall, more memory
efConstructionthe search width when buildinghigher → a better index, a longer build time
efSearchthe search width at query timeadjusted in operation: higher → better recall, higher latency

The last is the most important in practice: it lets you move along the recall/latency curve without rebuilding the index.

IVF clusters the vectors (k-means) and searches only the nprobe nearest clusters. Simple, memory-thrifty and easy to understand — but the recall falls at cluster boundaries, since a neighbour can lie just on the other side.

Product quantisation (PQ) splits the vector into parts and replaces every part with the nearest codebook entry. A 768-dimensional float32 vector of 3 072 bytes becomes 96 bytes — 32× smaller. The cost is precision, and PQ is therefore nearly always combined with a rerank: fetch 10× more candidates with PQ, compute the exact distances on them.

The distance measure has to match the embedding model:

The measureUsed when
Cosinetext embeddings (nearly always)
The inner productwhen the vectors are normalised — the same as cosine
Euclideanimage and audio embeddings sometimes

Using the Euclidean distance on unnormalised text embeddings is a common and hard-to-detect error: it works «almost», and the recall is lower without anything breaking.

Always measure recall against an exact search on a selection of queries before you trust an index. It is half an hour's work and the only way of knowing where on the curve you are.

Code

import numpy as np, time

rng = np.random.default_rng(0)
N, D = 200_000, 128
X = rng.normal(size=(N, D)).astype("float32")
X /= np.linalg.norm(X, axis=1, keepdims=True)      # normalise → the inner product = cosine
queries = X[:100] + 0.1 * rng.normal(size=(100, D)).astype("float32")
queries /= np.linalg.norm(queries, axis=1, keepdims=True)

def exact(q, k=10):
    return np.argsort(-(X @ q))[:k]

t = time.perf_counter()
key = [exact(q) for q in queries]
print(f"exact: {(time.perf_counter() - t) / len(queries) * 1000:.1f} ms/query")

# IVF by hand: cluster, search only the nprobe nearest clusters
from collections import defaultdict

K = 256
centroids = X[rng.choice(N, K, replace=False)]
for _ in range(8):                                  # simple k-means
    belongs = np.argmax(X @ centroids.T, axis=1)
    for c in range(K):
        m = belongs == c
        if m.any():
            centroids[c] = X[m].mean(0)
    centroids /= np.linalg.norm(centroids, axis=1, keepdims=True)

lists = defaultdict(list)
for i, c in enumerate(np.argmax(X @ centroids.T, axis=1)):
    lists[int(c)].append(i)

def ivf_search(q, k=10, nprobe=8):
    nearest = np.argsort(-(centroids @ q))[:nprobe]
    candidates = np.array([i for c in nearest for i in lists[int(c)]])
    if len(candidates) == 0:
        return np.array([], dtype=int)
    scores = X[candidates] @ q
    return candidates[np.argsort(-scores)[:k]]

def recall(nprobe):
    hits = 0
    t0 = time.perf_counter()
    for q, f in zip(queries, key):
        hits += len(set(ivf_search(q, 10, nprobe)) & set(f))
    ms = (time.perf_counter() - t0) / len(queries) * 1000
    return round(hits / (10 * len(queries)), 3), round(ms, 2)

for nprobe in (1, 4, 16, 64, 256):
    r, ms = recall(nprobe)
    print(f"  nprobe={nprobe:>3}: recall@10 {r:.3f}  {ms:>6.2f} ms/query")
#  ↑ the curve between recall and latency — choose a point to suit the requirements, always measure yourself

The loop at the bottom is the most important thing in the whole node: recall and latency are a curve, not a value. Choosing nprobe or efSearch without having drawn that curve for your own data is guessing.

Mastery means

  • Explains the cost of an exact search
  • Describes how HNSW and IVF work
  • Chooses an index and parameters to suit the requirements

Sign in to do the exercises and build your mastery up.

Sources

All the sources and licences