From Brute Force to HNSW: How Vector Search Gets Fast

Barakaeli Lawuo, Aug 22, 2026

In February 2017 three researchers at Facebook AI Research reported a search over Deep1B, a set of one billion image descriptors. Using 4 GPUs, their system found the true nearest neighbor as its first answer for 45.17% of queries, at 0.0133 ms per query when queries were sent in batches of 10,000.1 The paper that introduced Deep1B had reported about the same accuracy (0.45) at 20 ms per query on a single CPU thread. The FAISS authors point out that the hardware differs, but call GPU search "a game-changer in terms of speed achievable on a single machine."1

The setup behind that number is worth reading slowly. Each vector went through a learned linear transform called OPQ that cut it to 80 dimensions, was compressed to a 20 byte code, and filed into one of 2182^{18} buckets. The whole index took about 20 GB.1 Each of those choices fixes a cost left by a simpler method. This post starts at the simplest method, exact search, and adds them back one at a time.

Embedding
A list of numbers that a model produces to represent an item such as an image or a passage of text. The FAISS paper describes these as usually real-valued vectors with 50 to more than 1,000 dimensions.1
Nearest neighbor search
Given a query vector, find the kk stored vectors closest to it, usually by Euclidean (L2) distance or cosine similarity.1
Approximate nearest neighbor (ANN)
A search that finds the true neighbors with high probability "only," in the words of the product quantization paper, instead of with probability 1, in exchange for being much faster.2
Recall
The share of true nearest neighbors that the search actually returns. R@1 is the fraction of queries whose true nearest neighbor comes back as the top result.1,4
QPS
Queries per second, the speed measure used by ANN-Benchmarks.4

Exact search costs N times d for every query

A vector database stores embeddings so it can answer one question: which stored vectors sit closest to this query? The direct answer is to compute the distance from the query to every stored vector and keep the smallest few. The product quantization paper gives the cost of this exhaustive search as O(nD)O(nD) for nn vectors of dimension DD.2 The FAISS paper notes that the expensive part is the dot product between query and database vector. With many queries at once, that becomes one large matrix multiplication, which is the kind of data-parallel work GPUs are good at.1

Here is my own arithmetic for Deep1B. Its vectors have 96 dimensions: they are GoogLeNet image features, reduced by PCA and normalized to unit length.5 One exact query means 109×96=9.6×101010^9 \times 96 = 9.6 \times 10^{10} multiply-adds, about 96 billion. A text search system with 10 million chunks and 768-dimensional embeddings would need 107×768≈7.7×10910^7 \times 768 \approx 7.7 \times 10^9 per query. It gets worse as the collection grows, because the cost rises in a straight line with NN.

Measured numbers agree. The HNSW paper timed brute force on one CPU thread: 94 ms per query on 1 million 128-dimensional SIFT vectors and 60 ms on a 1 million vector slice of Deep1B.3 If that scales linearly, which is my assumption, a full billion would take about a minute per query. Memory is the second bill. The FAISS authors put Deep1B's raw vectors at 384 GB.1 Neither the minute nor the 384 GB works for a live service, and that is the problem every idea below is trying to fix.

Inverted lists: only scan the cells near the query

The first fix is to stop visiting every vector. Jégou, Douze and Schmid borrowed the inverted file, a structure already used for large-scale image search. Run k-means on the data to get k′k' centroids, called the coarse quantizer. File each database vector in the list that belongs to its nearest centroid. At query time, find the query's nearest centroid and scan only that list.2

There is an obvious flaw: a query and its true neighbor often fall into different but adjacent cells. So the query is assigned to its ww nearest centroids, and all ww lists are scanned. The paper calls this multiple assignment. If the lists are roughly balanced, a query parses about n×w/k′n \times w / k' entries, plus the k′k' distance computations needed to pick the lists.2 FAISS calls ww the multi-probe parameter τ\tau and typically sets the number of lists near N\sqrt{N}.1 In my arithmetic, a billion vectors would then give about 31,600 lists, and probing 8 of them touches roughly 250,000 vectors instead of a billion.

Every neighbor outside the probed cells is simply gone. The paper is blunt about it: when ww is too small, the nearest neighbors not assigned to one of the ww centroids "are definitely lost," and no amount of extra precision in the stored vectors brings them back.2 The index also still holds full vectors, so the 384 GB is untouched. Inverted lists cut compute and do nothing for memory.

Product quantization: store 8 bytes instead of 512

To shrink the vectors you can quantize them: replace each vector with the ID of its nearest centroid from a codebook. A 64-bit code would need 2642^{64} centroids, far too many to learn or store.2 Product quantization gets around this. It splits each DD-dimensional vector into mm subvectors of D/mD/m dimensions and runs a small k-means with k∗k^* centroids on each part separately.2 A vector's code is the list of mm centroid IDs. The combined codebook has (k∗)m(k^*)^m centroids, but you only store m×k∗m \times k^* small ones. The paper calls m=8m = 8 and k∗=256k^* = 256 "often a reasonable choice": 8 bytes per vector.2 A 128-dimensional float vector is 512 bytes, so that is 64 times smaller (my arithmetic).

The clever part is how distances are computed. The query is never compressed. Only the database vectors are. The paper calls this asymmetric distance computation (ADC):2

d~(x,y)2=∑j=1md(uj(x), qj(uj(y)))2\tilde{d}(x, y)^2 = \sum_{j=1}^{m} d\big(u_j(x),\ q_j(u_j(y))\big)^2
The asymmetric distance estimate, Equation 13 of Jégou et al., 2011,2 written squared. Search ranks by squared distance, so the square root is never taken.

Here xx is the query at full precision, and yy is a stored vector known only by its code. uj(⋅)u_j(\cdot) takes the jjth slice of a vector. qjq_j is the jjth sub-quantizer, which maps a slice of yy to one of its k∗k^* centroids. dd is Euclidean distance. For each query you compute, once, the squared distance from each query slice to all k∗k^* centroids of that slice. With m=8m = 8 and k∗=256k^* = 256, that is a table of 2,048 numbers (my count). After that, estimating the distance to any stored vector takes mm table lookups and additions, with no multiplications.2

Not compressing the query pays off. The authors prove that the expected squared error of the ADC estimate is bounded by the quantizer's mean squared error. If the query is compressed too, the bound is twice that.2 In practice, with m=8m = 8, ADC using 64 centroids per slice matched the accuracy of the symmetric method using 256.2 The estimate is also biased low on average. The paper derives a correction, but finds it made neighbor search worse and recommends leaving it out unless you need the distance values themselves.2

Put the two ideas together and you get IVFADC. The inverted lists pick which cells to visit. Each list entry holds a vector ID plus a PQ code of the residual, the difference between the vector and its cell's centroid. Residuals are small, so they compress more accurately than the raw vectors would.2 FAISS adds a practical note: IDs take 4 or 8 bytes, so a PQ code shorter than the ID gains nothing.1 This is the structure behind the opening result.

What the lists and the codes buy, measured on GIST

The PQ paper's Table V puts real timings on this. The dataset is about one million 960-dimensional GIST image descriptors, queried 500 times with 64-bit codes. The quality measure is recall@100, the fraction of queries whose true nearest neighbor ranks somewhere in the top 100.2

Redrawn from Table V of Jégou et al., 2011.2 GIST, about 1M vectors, 64-bit codes (m=8m = 8, k∗=256k^* = 256). Left to right, the IVF bars are k′=1024,w=1k' = 1024, w = 1; k′=8192,w=1k' = 8192, w = 1; k′=1024,w=8k' = 1024, w = 8; k′=8192,w=8k' = 8192, w = 8; then k′=8192,w=64k' = 8192, w = 64 and k′=1024,w=64k' = 1024, w = 64. Full scan is ADC over all 1,000,991 codes.

Scanning every code with ADC took 17.2 ms and reached 0.652. IVFADC with 1,024 lists and 8 probes compared only 27,818 codes instead of 1,000,991, finished in 8.8 ms, and reached 0.682, beating the full scan on both speed and recall.2 The authors explain why it can be more accurate as well as faster: coding the residual is more precise than coding the vector itself.2 The chart also shows the tuning trap. With one probe, recall drops to 0.308 or 0.240. With 64 probes it climbs to 0.744, but the search takes 65.9 ms.2

The 8,192-list rows have lower recall than the 1,024-list rows at every ww, and they are no faster. Their cells are smaller, so the same number of probes compares fewer codes: 361 instead of 1,947 at one probe.2 The paper also warns that on small datasets the coarse quantizer itself can become the bottleneck, when k′×Dk' \times D exceeds n/k′n/k'.2 My reading is that this is where the time went. Matching a 960-dimensional query against 8,192 centroids is about 7.9 million multiply-adds, far more than the list scan. The same paper's SIFT experiments found that for a fixed fraction of the data visited, more lists gave higher accuracy.2 Which way it goes depends on the size of the dataset.

Memory is where PQ earns its place. On one million SIFT vectors, the IVFADC index took under 25 MB, while FLANN, a tree-based library that re-ranks with full vectors, needed more than 250 MB.2 PQ was tested on a set of two billion SIFT vectors.2 In the opening Deep1B run, 20 byte codes fit a billion vectors in about 20 GB, compared with 384 GB as raw floats.1

HNSW: a skip list built from graphs

IVFADC leaves two costs: vectors lost at cell borders, and the accuracy ceiling set by the codes. Graph methods take a different route. Store each vector as a node and link it to nearby nodes. To search, start somewhere, keep moving to whichever neighbor is closest to the query, and stop when no neighbor is closer.3 Malkov and Yashunin note that on plain nearest-neighbor graphs, the number of steps grows as a power of the dataset size, and clustered data can split the graph into disconnected parts. Their earlier Navigable Small World (NSW) graphs improved this to polylogarithmic growth, but still slowed badly on low-dimensional data.3

Hierarchical NSW (HNSW) separates links by length into layers. When a node is inserted, it gets a random top layer l=⌊−ln⁡(unif(0,1))⋅mL⌋l = \lfloor -\ln(\mathrm{unif}(0,1)) \cdot m_L \rfloor, so each layer up holds exponentially fewer nodes.3 A search starts at a fixed entry point in the top layer, where the few nodes are joined by long links. It walks greedily until it hits a local minimum, drops to the next layer from that node, and repeats. At layer 0, which holds every node, it keeps a candidate list of size efef, and efef is the knob that trades speed for recall.3 The authors describe it as a probabilistic skip list with the linked lists replaced by proximity graphs, and recommend mL=1/ln⁡(M)m_L = 1/\ln(M), which matches a skip list with p=1/Mp = 1/M.3

Two more design choices matter. New links come from a heuristic, not simply the MM closest nodes: a candidate is linked only if it is closer to the new node than to any node already linked. This keeps links pointing in different directions and keeps separate clusters connected.3 And layer 0 allows up to Mmax0=2MM_{max0} = 2M links per node. The paper suggests MM between 5 and 48, larger for high recall and high-dimensional data.3 Under the assumption of exact Delaunay graphs, the authors show the expected number of steps per layer is bounded by a constant. Since the number of layers grows as O(log⁡N)O(\log N), so does search cost.3

Where the benchmark put each approach

ANN-Benchmarks, by Aumüller, Bernhardsson and Faithfull, runs every library in its own Docker container on one CPU thread, with a five-hour limit per setting, on Amazon c5.4xlarge machines. Each library is tested across many parameter settings, which traces out its curve of recall against QPS.4 Two of its datasets are GLOVE (1,183,514 word vectors, 100 dimensions, cosine similarity) and SIFT (1,000,000 vectors, 128 dimensions, Euclidean distance), each with 10,000 queries.4

Four log-scale plots of queries per second against recall for nine ANN libraries. Top row GLOVE, bottom row SIFT; left column 10 nearest neighbors, right column 100. In every panel the orange HNSW curve sits highest across most of the recall range, with all curves falling steeply as recall approaches 1.
Recall against queries per second, up and to the right is better. Top: GLOVE; bottom: SIFT; left: 10-NN; right: 100-NN. HNSW is orange and FAISS-IVF is green triangles. Figure 4 from Aumüller et al., 2018,4 reproduced under CC BY 4.0.

On GLOVE, HNSW was the fastest at every recall level, closely matched by KGraph (another graph method) at high recall, with FAISS-IVF third.4 On SIFT, all the methods could get close to perfect recall, and the graph methods were fastest.4 Note the vertical axis. It is logarithmic, and most curves drop by an order of magnitude or more over the last stretch toward recall 1. The authors observe that all the algorithms take a performance hit at high recall, with HNSW hit least.4

Batching changes the ranking. On a GPU with batched queries, FAISS's inverted file index answered about 655,000 queries per second at recall 0.7 and 61,000 at 0.99 on SIFT, 20 to 30 times its CPU speed. FAISS brute force on the same GPU managed about 24,000 queries per second.4 At a million vectors, then, exact search on a GPU is a realistic option. The index structures matter more as NN grows toward the billions in the opening.

What the graph costs: memory, rebuilds, and dimension

HNSW gets its speed by keeping full vectors plus a graph in RAM. The paper puts the link storage alone at about 60 to 450 bytes per object, not counting the data.3 With M=16M = 16, Mmax0=32M_{max0} = 32, and upper layers capped at MM links (my assumption), the formula (Mmax0+mLMmax)(M_{max0} + m_L M_{max}) times 4 bytes gives about 150 bytes per vector. At a billion vectors, that is roughly 150 GB of links before any vector is stored (my arithmetic). In the paper's own comparison on 200 million SIFT vectors, HNSW peaked at 64 GB of RAM, against 30 GB and 23.5 GB for two FAISS PQ configurations. HNSW built faster, 42 minutes to 5.6 hours against 11 to 12 hours, and searched with much higher accuracy, but in the authors' words it "requires significantly more RAM."3

Changing data is the second weak spot. On GLOVE, the fastest HNSW index to reach recall 0.9 took almost 5 hours to build with the NMSlib implementation (FAISS's own HNSW did it in 1,700 seconds), against about 2 seconds for FAISS-IVF. The ANN-Benchmarks authors conclude that graph methods "might not be the preferred choice if the dataset changes regularly."4 HNSW does support incremental insertion, but its paper lists support for element updates and removal as future work.3 On the benchmark's Rand-Euclidean dataset, built so that each query's neighbors are easy to find locally but hard to reach through the data's overall structure, no HNSW setting got above 0.86 recall.4

The logarithmic scaling result also comes with a caveat about dimension. The proof assumes each node's number of neighbors in the Delaunay graph stays bounded, but that number grows exponentially with dimension. Checking the assumption empirically at d=128d = 128 would take extremely large datasets, so the authors say more analytical evidence is needed to know whether it holds in high dimensions.3 Their own 200 million vector SIFT run showed query time growing faster than a pure logarithm, which they attribute, possibly, to the data's relatively high dimensionality.3 Embeddings, which the FAISS paper puts at 50 to more than 1,000 dimensions,1 can sit far above the 128 of that test.

Sources

  1. Johnson, Douze, and Jégou, Billion-scale similarity search with GPUs, 2017
  2. Jégou, Douze, and Schmid, Product Quantization for Nearest Neighbor Search, IEEE TPAMI 33(1), 2011
  3. Malkov and Yashunin, Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs, 2016
  4. Aumüller, Bernhardsson, and Faithfull, ANN-Benchmarks: A Benchmarking Tool for Approximate Nearest Neighbor Algorithms, 2018
  5. Babenko and Lempitsky, Efficient Indexing of Billion-Scale Datasets of Deep Descriptors, CVPR 2016