LLD Dojo

Approximate Nearest Neighbour Search

A course of study, leading to the construction of a vector search index in C++


Chapter 0. How to use this document

This document teaches a subject and then supervises a project. The subject is approximate nearest neighbour search, which is the technique underlying the retrieval component of most modern artificial intelligence systems. The project is a working implementation of a particular algorithm for it, written in C++, without reference to any existing implementation.

Nothing specific to this field is assumed. Every term is defined at the point where it is first needed, and every claim of the form "this method is too slow" is supported by arithmetic that you can check yourself. What is assumed is ordinary programming competence in C++ and a working familiarity with elementary data structures such as arrays, graphs and priority queues. Where a structure is used in an unfamiliar way, that use is explained.

The document is arranged so that the theory precedes the work. Chapters 1 through 8 establish why the algorithm has the form it has. Chapters 9 through 13 direct the implementation. The temptation to begin coding at Chapter 10 should be resisted, because the implementation stages are written on the assumption that the reader understands what each component is for, and a component built without that understanding cannot be debugged when it misbehaves.

A remark on method. Each implementation stage is accompanied by a verification procedure. These are not suggestions. The characteristic way this project fails is that a defect is introduced early, is not detected because nothing tested for it, and is then diagnosed several stages later in the wrong component. A day spent confirming that stage three is correct is cheaper than a week spent searching stage six for a fault that is not there.

The reference paper for the algorithm is Y. Malkov and D. Yashunin, "Efficient and robust approximate nearest neighbour search using Hierarchical Navigable Small World graphs" (arXiv:1603.09320). It should be read after Chapter 7 of this document and not before. Read in that order, it will appear as a compact restatement of ideas already understood. Read first, it is difficult.


Chapter 1. The problem, developed from nothing

1.1 Points and coordinates

A list of numbers can be understood as the coordinates of a point. The pair of numbers (3, 4) describes a position on a plane: three units along the horizontal axis, four units along the vertical. The triple (3, 4, 2) describes a position in ordinary space, adding a height of two.

There is no reason to stop at three. A list of 128 numbers describes a point in what is called a 128-dimensional space. This cannot be visualised, and no attempt should be made to visualise it. It is nevertheless entirely well defined, because everything that will be done with such points is arithmetic on their coordinates, and arithmetic does not care how many coordinates there are.

Throughout this document, the number of coordinates is written D, and a point with D coordinates is called a vector of dimension D. For this project D is 128.

1.2 The distance between two points

On a plane, the distance between two points is obtained by the theorem of Pythagoras. Take the points x = (1, 2) and y = (4, 6). The difference along the first coordinate is 4 − 1 = 3, and along the second it is 6 − 2 = 4. The distance is the square root of the sum of the squares of these differences: the square root of 9 + 16, which is the square root of 25, which is 5.

In three dimensions the rule is identical with one more term. In D dimensions it is identical with D terms. To compute the distance between two vectors, subtract them coordinate by coordinate, square each of the D differences, add the squares together, and take the square root of the total.

This formula is the only piece of geometry required for the entire project. It will be evaluated several billion times before the project is finished, and consequently it deserves more attention than its simplicity suggests.

1.3 Why the square root is discarded

Consider two candidate points, one at squared-sum 25 from the query and another at squared-sum 36. Taking square roots gives distances of 5 and 6. Whether the square roots are taken or not, the first point is the nearer one.

This holds in general. The square root is a strictly increasing function on non-negative numbers, meaning that if a is less than b then the square root of a is less than the square root of b. Therefore taking square roots never changes which of two points is nearer. Since every use of distance in this system is a comparison, and no part of it requires the true geometric distance as a number, the square root can be omitted entirely.

The quantity actually computed, the sum of squared coordinate differences without the root, is called the squared Euclidean distance. From this point onward the word "distance" means this quantity.

The saving is not decorative. The square root is among the slower arithmetic operations available, and removing it from the innermost loop of a system that performs billions of distance evaluations is a measurable improvement obtained at no cost in correctness.

1.4 Embeddings: where the vectors come from

So far the vectors are abstract. Their origin is what makes the problem worth solving.

A neural network can be trained to accept an object of some kind, such as a paragraph of text or a photograph, and to emit a fixed-length list of numbers. Such a list is called an embedding of that object. The training procedure is arranged so that objects which resemble one another in meaning are assigned lists of numbers that are close together under the distance of Section 1.2, while objects unrelated in meaning are assigned lists that are far apart.

Consider two questions submitted to a support system: "how do I reset my password" and "I have forgotten my login details". These share almost no words. A system matching on words would not connect them. An embedding model, however, assigns them nearby positions, because it has been trained on the relationship between language and meaning rather than on the surface form of the words. A sentence about cellular biology would be placed somewhere else entirely.

The importance of this is that it converts a hard problem into an easier one. Judging whether two passages mean similar things is difficult. Measuring whether two points are close together is arithmetic. The embedding model performs the difficult conversion once, and thereafter the question "what do I have that resembles this" becomes the question "which stored points lie nearest to this point". That second question involves no machine learning whatsoever. It is a problem in data structures, and it is the subject of this course.

This explains where such systems appear. When a language model answers a question using a company's internal documents, some component first selected the relevant documents, and that component almost certainly performed a nearest neighbour search over embeddings. The same mechanism underlies semantic search, recommendation systems, detection of near-duplicate documents in training corpora, and the retrieval stage of nearly every retrieval-augmented generation system in production. In such systems the neural network typically runs on specialised hardware, while the search component runs on ordinary processors, is required to sustain thousands of queries per second over collections of millions to billions of points, and is usually written in C++.

1.5 Statement of the problem

A collection S contains N points, each of dimension D. A query point q is supplied, which need not belong to S. An integer k is given. The task is to report the k members of S whose distance to q is smallest.

For this project, N is one million, D is 128 and k is 10. These values correspond to a public benchmark dataset, which permits the finished system to be compared directly against published results for established software.

1.6 A worked example

The following six points lie on a plane, so that the situation can be followed concretely.

A = (1, 1)    B = (2, 3)    C = (8, 8)
D = (9, 7)    E = (1, 4)    F = (7, 9)

Let the query be q = (2, 2), and let k be 2.

The squared distance from q to A is (1 − 2)² + (1 − 2)² = 1 + 1 = 2. To B it is (2 − 2)² + (3 − 2)² = 0 + 1 = 1. To C it is 36 + 36 = 72. To D it is 49 + 25 = 74. To E it is 1 + 4 = 5. To F it is 25 + 49 = 74.

The two nearest points are therefore B, at distance 1, and A, at distance 2.

This is the entire problem. Everything that follows exists because performing this calculation for every one of a million points, ten thousand times a second, is not possible by direct means.


Chapter 2. Why the straightforward solutions fail

2.1 Exhaustive search and its exact cost

The method of Section 1.6, applied to the full collection, is called exhaustive search or linear scan. Compute the distance from the query to all N points and retain the k smallest.

It is correct, it requires perhaps fifteen lines of code, and it is the first component this project will construct, because it provides the reference answers against which everything else is checked. It is nevertheless unusable as a production method, and the arithmetic showing why is worth performing.

One distance evaluation between vectors of dimension 128 requires 128 subtractions, 128 multiplications and 127 additions, which is approximately 384 arithmetic operations. One query against one million points therefore requires approximately 3.84 × 10^8 arithmetic operations.

The memory traffic is more restrictive still. One million vectors of 128 coordinates, each coordinate occupying four bytes, is 512 million bytes. Every query must read all of it. Contemporary processors sustain memory bandwidth in the region of tens of gigabytes per second, which places a floor on the time for a single query in the region of ten to twenty milliseconds even if the arithmetic were free.

A service handling ten thousand queries per second would need to read five terabytes per second. The method is short of the requirement by roughly three orders of magnitude, and the shortfall grows in direct proportion to the size of the collection.

2.2 Why sorting does not help

In one dimension the problem is easy. Sort the numbers once, then locate the query by binary search and inspect its neighbours in the sorted order. The cost per query falls from N to the logarithm of N.

This approach does not extend beyond one dimension, and the reason is worth stating explicitly because the instinct to sort is strong. Sorting requires a total order, meaning an arrangement in which every element has a definite position relative to every other. Points on a plane admit no such arrangement consistent with distance. Sorting by the first coordinate places (0, 100) next to (0, 0) although they are far apart. Sorting by the second coordinate has the symmetric defect. No single ordering can preserve proximity in more than one dimension, because proximity in two dimensions is not a one-dimensional property.

2.3 Dividing the space: grids and trees

The next natural approach is to divide the space into regions. Overlay a grid on the plane and record which points fall in which cell. To answer a query, examine the cell containing the query and the cells immediately surrounding it. If the nearest point found is closer than the distance to the edge of the examined region, that point is the true nearest neighbour and the remaining cells can be ignored.

The tree-based refinements of this idea, of which the k-d tree is the best known, divide the space recursively rather than uniformly, but the principle is identical: partition the space, descend to the relevant region, and exclude the remaining regions without examining them. The efficiency of the whole family depends entirely on that exclusion. Regions must be ruled out without being looked at.

In two or three dimensions this works extremely well. In 128 dimensions it fails completely, and the failure has two independent causes.

2.4 First cause: the number of neighbouring regions

Consider the grid method. In two dimensions, a cell has eight neighbouring cells, so a query examines nine cells in total. The general formula is that a cell in D dimensions has 3^D − 1 neighbours, since along each of the D axes a neighbouring cell may be one step below, level, or one step above.

In two dimensions this is 3² − 1 = 8, which is manageable. In three dimensions it is 26, still manageable. In 128 dimensions it is 3^128 − 1, which is approximately 1.2 × 10^61.

This number exceeds the number of atoms in the observable universe by many orders of magnitude. There is no sense in which a query can examine the neighbouring cells. The method does not become slow; it ceases to be defined.

The same objection applies to trees. Each level of a k-d tree constrains one coordinate. To narrow a query to a small region of a 128-dimensional space, a substantial fraction of the 128 coordinates must be constrained, so the tree must be deep, and the number of leaf regions grows as a power of two in that depth. For the exclusion rule to eliminate most regions, the number of points N would have to greatly exceed 2^128. No dataset of that size can exist.

2.5 Second cause: distances stop distinguishing

The second cause is subtler and more damaging, because it undermines the exclusion rule itself rather than the bookkeeping around it.

Here is an illustration that can be checked by arithmetic. Take the unit cube in D dimensions, that is, the set of points all of whose coordinates lie between 0 and 1, and consider the shell consisting of points within 0.1 of the surface. The interior that remains, excluding the shell, is a smaller cube whose side is 0.8, so its volume is 0.8^D.

For D = 2, this interior volume is 0.64, so 36 per cent of the area lies in the shell. For D = 3, it is 0.512, so about 49 per cent lies in the shell. For D = 128, it is 0.8^128, which is approximately 4 × 10^-13.

That is to say, in 128 dimensions, essentially every point of the cube lies near its surface. This is characteristic of high-dimensional spaces: the volume is concentrated at the boundary, and intuitions formed in two or three dimensions are not merely imprecise but systematically wrong.

A related and more directly relevant effect is distance concentration. When points are distributed through a high-dimensional space, the distances from a query to all of them become increasingly similar as D grows. The nearest point is only slightly nearer than a typical point.

This is fatal for space partitioning. The rule that makes such methods fast has the form: "the closest possible point within this unexamined region is further away than the best candidate already found, therefore skip the region." When all distances are nearly equal, this condition is almost never satisfied, so almost no region is skipped. The search examines nearly everything, having added the overhead of tree traversal to the cost of exhaustive search. It is slower than the method it was intended to improve upon.

2.6 Conclusion

These observations are known collectively as the curse of dimensionality. Their joint consequence is that for high-dimensional data there is no known exact method substantially faster than examining everything, and there are strong reasons to believe none exists.

The field therefore abandoned exactness. That decision, and what is obtained in exchange, is the subject of the next chapter.


Chapter 3. Approximation, and how it is measured

3.1 The exchange

An approximate nearest neighbour method does not promise to return the true k nearest points. It returns k points which are usually the true ones, or very nearly. In exchange it can be faster than exhaustive search by a factor of a thousand or more.

Stated baldly this sounds like an evasion of the problem. It is defensible, and the reason lies in where the vectors came from. The embeddings are produced by a trained model whose notion of similarity is itself imperfect. The system is already approximate before the search begins. In a document retrieval application, the practical difference between returning the true tenth-nearest document and the thirteenth-nearest is generally invisible to the user, whereas the difference between thirty queries per second and thirty thousand determines whether the system can be built at all.

The guarantee being surrendered was never worth very much. The speed obtained in exchange is worth a great deal.

3.2 Recall

The accuracy of an approximate method is measured by a quantity called recall.

The measurement requires knowing the correct answers. These are computed once, in advance and without time pressure, by exhaustive search, and stored. This stored set is called the ground truth. For a benchmark dataset it is supplied along with the data.

For a single query, let T be the set of true k nearest neighbours and let A be the set of k results the method actually returned. The recall for that query is the number of points appearing in both sets, divided by k.

As an example, suppose k is 10 and the true ten nearest neighbours are the points numbered

17, 42, 103, 256, 301, 477, 512, 688, 701, 894

and the method returns

17, 42, 103, 256, 301, 477, 512, 688, 701, 905

Nine of the ten returned points are correct. The recall for this query is 9 / 10 = 0.9. The final figure, written recall@10, is this value averaged across every query in the benchmark, commonly ten thousand of them.

Systems in production typically operate between recall 0.90 and 0.99.

3.3 Why a single number is never adequate

Every approximate method has at least one parameter controlling how hard it searches. This has an important consequence for how results must be reported.

Suppose a method is described as achieving twenty thousand queries per second. This tells you nothing, because that rate could have been obtained by searching carelessly and returning poor answers. Suppose instead a method is described as achieving recall 0.99. This also tells you nothing, because that accuracy could have been obtained by exhaustive search.

Neither number is meaningful alone. They are meaningful only in combination, and since the controlling parameter can be set to any value, the honest description of a method is not a pair of numbers but a curve.

The curve is produced by varying the search-effort parameter across its useful range and recording, for each setting, both the recall achieved and the query rate sustained. Plotting rate against recall traces out the method's behaviour across its whole operating range. One method is superior to another when its curve lies above and to the right, meaning it achieves higher throughput at every level of accuracy.

3.4 The standard benchmark

This methodology is used by a public benchmark suite published at ann-benchmarks.com, which evaluates the established libraries on common datasets and publishes their curves. Producing such a curve for your own implementation, on the same dataset, is the final deliverable of this project. It permits your work to be placed directly alongside published results, which is a considerably stronger claim than a demonstration that the program runs.


Chapter 4. Searching by walking: the graph approach

4.1 The change of viewpoint

Space partitioning failed because it tried to reason about regions of a space whose geometry defeats such reasoning. The successful approach abandons the space and reasons about the points themselves.

Construct a graph. Each point of the collection becomes a vertex. Each vertex is joined by edges to a small number of other vertices which lie near it. A structure of this kind is called a proximity graph. Nothing is stored about the space between the points; only the points and the connections among them.

A query is then answered by walking. Begin at some vertex and move along edges, at each step choosing a vertex that lies nearer to the query than the current one, until no further improvement is available.

The significance of this reformulation lies in what determines the cost. At each step the algorithm computes the distance from the query to each neighbour of the current vertex. The number of such computations depends on how many edges a vertex has, which is a design choice, and not on how many points the collection contains. The total cost of a query is therefore the number of edges per vertex multiplied by the number of steps taken. The design problem becomes that of building a graph in which few steps are needed, and this is a problem about which something can be done.

4.2 The greedy walk

The procedure described informally above is called greedy routing, and it is stated precisely as follows.

Maintain a current vertex, initially some fixed starting vertex called the entry point. Compute the distance from the query to every neighbour of the current vertex. If the nearest of these neighbours is closer to the query than the current vertex is, move to that neighbour and repeat. Otherwise stop and report the current vertex.

A useful way to picture this is to assign to each vertex a height equal to its distance from the query. The walk then proceeds downhill, and stops at the bottom of a valley.

4.3 Where the greedy walk fails

The walk stops at the bottom of a valley, but not necessarily the deepest valley. A vertex may be nearer to the query than all of its own neighbours while still being far from the true nearest point, simply because it has no edge pointing in the direction that would improve matters.

The following arrangement illustrates the failure. Suppose the graph contains a vertex P whose four neighbours all lie further from the query than P does, and suppose that the true nearest neighbour is a vertex R which is not connected to P and is reachable only by first moving to one of P's neighbours, which is a step in the wrong direction. Greedy routing refuses to take that step, because the rule requires every step to be an improvement. It stops at P and reports it.

This situation is not rare. In a graph of a million vertices where each vertex has perhaps sixteen edges, there are many such local valleys, and greedy routing alone therefore produces poor recall. The method must be modified so that it can pass through a temporarily worse vertex in order to reach a better one.

4.4 Beam search: exploring several possibilities at once

The remedy is to stop committing to a single path, and instead to keep track of a number of promising vertices simultaneously, expanding them in order of promise. This procedure is the central component of the entire system, and the remainder of the algorithm is machinery surrounding it.

Two collections are maintained.

The first is the candidate set. It holds vertices that have been discovered but whose neighbours have not yet been examined. It must support extracting the vertex nearest to the query, which is the ordinary function of a priority queue arranged so that the smallest element is extracted first.

The second is the result set. It holds the best vertices found so far, and its size is capped at a fixed limit. It must support extracting the vertex furthest from the query, so that when the cap is exceeded the worst member can be discarded. This is a priority queue arranged in the opposite direction, so that the largest element is extracted first.

The procedure is then as follows. Extract the nearest vertex from the candidate set and examine each of its neighbours. For any neighbour not previously visited, mark it visited, compute its distance to the query, and if it is nearer than the furthest member of the result set, or if the result set is not yet full, add it to both the candidate set and the result set. Whenever the result set exceeds its cap, discard its furthest member. Repeat.

The visited marking matters. Without it the procedure would revisit vertices endlessly, since the graph contains cycles.

4.5 When to stop, and why that rule is correct

The procedure needs a termination condition, and the correct one deserves explanation because it is what separates this from an exhaustive traversal of the graph.

Stop when the nearest remaining candidate is further from the query than the furthest member of a full result set.

The justification is as follows. Candidates are extracted in increasing order of distance from the query. If the nearest remaining candidate is already worse than the worst accepted result, then every remaining candidate is also worse, since they are all further away still. Continuing would mean expanding vertices which are themselves not good enough to be kept, in the hope that their neighbours might be better. The algorithm gives up that hope, and this is precisely the approximation being made: it is possible that a good vertex lay just beyond a bad one and was missed. This is the source of the lost recall, and increasing the cap on the result set is what reduces the loss.

4.6 The control knob

The cap on the result set is written ef. It is the search-effort parameter promised in Section 3.3.

Setting ef to 1 reduces the procedure exactly to greedy routing, since only one result is ever kept and the search stops as soon as no improvement is found. This is fast and inaccurate. Increasing ef causes more alternatives to be retained, allows the search to escape more local valleys, raises recall and costs more time. Sweeping ef across a range and recording the resulting pairs of recall and throughput is what produces the curve described in Section 3.3.

A warning that will be repeated in Chapter 12. The two collections are ordered in opposite directions: the candidate set gives up its smallest element, the result set its largest. If one of them is built the wrong way round, the program will compile, run, terminate normally, and return results that are merely somewhat worse than they should be. Nothing will crash. There will be no obvious place to begin looking. Take care with this.


Chapter 5. Making the walk short

5.1 The difficulty with uniformly short edges

The cost of a query is the number of edges per vertex multiplied by the number of steps. Chapter 4 addressed the quality of the search. This chapter addresses the number of steps.

Suppose every edge of the proximity graph joins two genuinely close points. Each step of the walk then advances a short distance. If the walk begins at a vertex far from the query, it must cross the entire collection in short steps, and the number of steps is large. The search is accurate but slow.

Suppose instead that the graph also contains a small number of long edges joining distant regions. The walk can then use a few long edges to arrive in approximately the right region, and short edges to refine its position once there.

5.2 Small-world graphs

A graph possessing both properties, namely that most connections are local and a few are long-range, is called a small-world graph. The characteristic result about such graphs is that adding a small number of long-range edges to a locally connected graph reduces the typical path length between two vertices from proportional to N to proportional to the logarithm of N. The same effect accounts for the short chains of acquaintance observed between arbitrary pairs of people.

The predecessor of the algorithm treated here, called the Navigable Small World graph, obtains this structure without explicitly arranging for it. Vertices are inserted one at a time, and each new vertex is connected to the nearest vertices present at the moment of its insertion. A vertex inserted early, when the graph holds only a handful of points, is connected to whatever exists, which in absolute terms is far away. These early connections become the long-range edges. Vertices inserted later, into a populated graph, receive short edges. The required mixture arises from the construction order.

5.3 The remaining inefficiency

The Navigable Small World graph works, but it wastes effort. Long edges and short edges are mixed together in a single graph, so at every step of the walk the algorithm computes distances to long-range neighbours. This is useful at the beginning, when the search is crossing the space. It is useless at the end, when the search has already arrived in the correct region and is attempting to refine its answer, yet the cost continues to be paid at every step.

5.4 Separating edges into layers

The Hierarchical Navigable Small World structure separates edges by length, placing them in different layers.

Each vertex is assigned an integer called its height. Every vertex appears in layer zero. A vertex appears in layer l if and only if its height is at least l. Heights are assigned randomly from a distribution which decays geometrically, so that layer zero contains all N vertices, layer one contains roughly N/M of them, layer two roughly N/M², and so on, until the topmost layer contains a single vertex.

Within each layer, a proximity graph is built over only the vertices present in that layer. Because the upper layers contain few vertices scattered across the whole space, their edges are necessarily long. Because layer zero contains every vertex, its edges are short.

A search begins at the single vertex of the topmost layer. Within each layer above the bottom it performs greedy routing with ef set to 1, which is cheap, until no improvement is available. The vertex where it stops becomes the starting point for the layer below. On reaching layer zero, it performs the full beam search of Chapter 4 with the user's chosen ef.

The result is a search that is coarse at first and fine at the end. The upper layers place the search in the correct region using very few distance computations. The bottom layer performs the careful local search that determines the accuracy of the final answer.

5.5 The correspondence with skip lists

This structure is not new in its essentials. It is a skip list applied to a graph.

A skip list stores a sorted sequence as a linked list, together with additional lists above it. Each element appears in the bottom list; each element appears in the list above with some fixed probability, and so on upward. A search travels as far as it can along a sparse upper list, taking long strides, then drops to a denser list to refine, and repeats. The expected search cost is logarithmic, and the heights of elements are chosen by repeated random trials.

The hierarchical proximity graph applies exactly this mechanism, with ordering by geometric proximity replacing ordering by key. The random height assignment, the descent from sparse to dense, and the resulting logarithmic cost all correspond directly.

5.6 How heights are assigned, with the arithmetic

The height of a vertex is obtained as follows. Draw a random number u uniformly from the open interval between 0 and 1. Compute the negative of its natural logarithm, multiply by a constant written mL, and take the floor of the result. That is the height.

The constant mL is set to the reciprocal of the natural logarithm of M. It is worth confirming that this produces the intended layer occupancies, since the derivation is short and makes the choice of constant intelligible rather than arbitrary.

A vertex reaches height 1 or more when the computed value is at least 1, that is, when the negative logarithm of u multiplied by mL is at least 1. Rearranging, this requires that the negative logarithm of u be at least 1/mL, which with mL equal to 1/ln(M) means at least ln(M). Equivalently, u must be at most e raised to the power of minus ln(M), which is exactly 1/M.

Since u is uniform on the interval from 0 to 1, the probability that u is at most 1/M is exactly 1/M. Therefore precisely a fraction 1/M of vertices reach layer one, and by the same argument a fraction 1/M of those reach layer two, and so forth.

With M set to 16, one vertex in sixteen appears in layer one, one in two hundred and fifty six in layer two, and so on. A collection of one million vertices produces roughly five layers. This is why the search reaches the bottom quickly.


Chapter 6. Choosing which edges a vertex should have

6.1 The obvious rule, and why it is wrong

One question remains: given that a vertex is to receive M edges, which M vertices should they join it to?

The obvious answer is the M nearest. This answer is defective, it is the single most commonly omitted refinement in implementations of this algorithm, and understanding its failure is worth more than any other single insight in this document.

Consider a group of points packed tightly together and well separated from everything else. Under the nearest-M rule, every vertex inside the group spends all M of its edges on other members of the group, because those are the nearest vertices available. The group therefore has almost no edges connecting it to the rest of the graph.

Two consequences follow, and both are damaging. A search that has not entered the group cannot find its way in, because scarcely any edge from outside points into it. A search that has entered cannot leave, because scarcely any edge from inside points out. Portions of the collection become unreachable from the entry point, and no increase in ef can repair this, because ef governs how widely the search explores along existing edges and cannot create an edge that was never built.

The defect is structural. It is present in the graph, not in the search.

6.2 The diversity rule

The correct rule prefers variety of direction over nearness.

Consider the candidates in increasing order of distance from the vertex being connected. Accept a candidate only if it is nearer to that vertex than it is to any candidate already accepted. If a candidate is nearer to some already-accepted neighbour than it is to the vertex itself, reject it.

The reasoning behind the rejection is this. If candidate c lies nearer to an accepted neighbour r than to the vertex v, then c occupies a direction that r already covers. A search arriving at v can reach c by stepping to r and then onward, so a direct edge from v to c would be largely redundant. The edge budget is better spent on a direction not yet represented.

The effect is that a vertex's M edges are distributed among distinct directions, rather than being concentrated wherever the points happen to be densest. Dense groups therefore retain edges leading out of them, and remain reachable from outside.

6.3 A small illustration

Suppose vertex v is to receive two edges, and there are three candidates: a and b lying close together to the north of v, and c lying to the south, slightly further away than either.

Under the nearest-M rule, v connects to a and b. Both edges point north. Nothing to the south of v is reachable from it, and the search cannot travel southward through v at all.

Under the diversity rule, a is accepted first, being nearest. Then b is considered: b is nearer to a than it is to v, since a and b lie close together and both are some distance from v, so b is rejected as covering a direction already served. Then c is considered: c is further from a than it is from v, since they are on opposite sides, so c is accepted. Vertex v ends with one edge north and one edge south, and the graph remains navigable in both directions.

This rule appears as Algorithm 4 in the reference paper. Two optional refinements are described there: extending the candidate pool with the neighbours of the candidates before applying the rule, which helps when the data is strongly clustered, and retaining some rejected candidates when the rule proves so restrictive that a vertex would be left with too few edges.


Chapter 7. The parameters

Four quantities control the system. Each represents a definite trade, and being able to state each trade precisely is a fair test of whether the preceding chapters have been understood.

M is the number of edges each vertex receives in the layers above zero. A larger M produces a better connected graph in which higher recall is attainable, at the cost of more memory, since each vertex stores M neighbour identifiers per layer, and slower steps, since each step computes the distance to every neighbour. Values between 8 and 48 are usual.

Mmax0 is the same quantity for layer zero, conventionally set to twice M. Layer zero is where the final and most accuracy-determining phase of every search takes place, and where a failure of connectivity does the most damage, so it is given a larger budget.

efConstruction is the value of ef used during insertion, when the algorithm is searching for good neighbours for a new vertex. A larger value produces a better graph and a slower build. It is paid once.

ef is the value used during a query. It is supplied per query rather than fixed when the index is built, which means one index can serve both a fast approximate query and a slow accurate one. This distinction between build-time and query-time parameters has direct operational consequences and is a common subject of examination.

mL is the height-distribution constant of Section 5.6, set to the reciprocal of the natural logarithm of M.


Chapter 8. What must be understood about the hardware

The algorithm as described is complete. An implementation following it faithfully will nevertheless be several times slower than it should be unless a few facts about memory are taken into account. These facts are not usually covered in an algorithms course, so they are set out here.

8.1 Memory is not uniformly fast

A processor does not read individual bytes from main memory. It reads fixed-size blocks called cache lines, which on current hardware are 64 bytes, and holds recently used lines in small fast memories called caches.

Reading a value already held in cache takes on the order of one to twenty processor cycles. Reading a value that must be fetched from main memory takes on the order of two to three hundred cycles. The difference is roughly two orders of magnitude, and it is the dominant consideration in the performance of this system.

Sixty-four bytes holds sixteen four-byte floating-point numbers. A vector of dimension 128 therefore occupies eight cache lines.

8.2 The consequence for layout

Two arrangements of the same one million vectors behave very differently.

In the first, all vectors are stored in one continuous block of memory, with vector number i beginning at offset i × 128. Reading a vector fetches eight consecutive cache lines. Consecutive access is a pattern the hardware recognises, and it will fetch subsequent lines before they are requested, a mechanism called prefetching.

In the second, each vector is allocated separately, as occurs when a container of containers is used. The vectors are scattered across memory at unrelated addresses. Reading one requires first reading a pointer, then following it to an arbitrary location, which the hardware cannot predict and therefore cannot prefetch.

The second arrangement is straightforward to write and is the natural thing to reach for. It costs somewhere between five and ten times the performance of the first. The cost does not appear in any one place that profiling will identify as the culprit; it is distributed across every operation that touches a vector. Correcting it once the system is complete requires revising nearly everything.

The same reasoning applies to the adjacency lists of the graph, which are read constantly during search and should likewise occupy short continuous runs.

This is the reason Stage 0 of the implementation, which appears to be trivial file-reading work, is treated as a design decision.

8.3 Performing several operations at once

Processors provide instructions operating on several values simultaneously, a facility called SIMD, for single instruction, multiple data. The AVX2 instruction set available on current x86 processors operates on eight four-byte floating-point values per instruction.

Since the distance computation performs the same operation on each of 128 coordinates independently, it is well suited to this treatment, and the compiler will often apply it automatically when the loop is written simply and the data is laid out continuously.

One point is not obvious. A direct implementation accumulates the running total into a single variable, which means each addition must wait for the previous addition to finish before it can begin. The processor is then limited by the time an addition takes to complete rather than by how many it can start. Keeping two to four separate running totals, and adding them together only at the end, allows these chains to proceed in parallel and yields a further substantial improvement.


Chapter 9. The data

The standard benchmark is the SIFT collection published by the TEXMEX research group. Two subsets are required, and both should be downloaded before implementation begins.

The smaller subset, siftsmall, contains 10,000 vectors, 100 queries and the correct 100 nearest neighbours of each query. All development should be carried out on this subset. Building an index over it completes almost instantly, which permits a rate of experimentation that is impossible with the full collection. Attempting to develop against a million vectors means waiting minutes to discover faults that a ten-thousand-vector run would have revealed in a second.

The larger subset, ANN_SIFT1M, contains 1,000,000 vectors of dimension 128, 10,000 queries and ground truth. It occupies approximately 516 megabytes. All reported results should come from this collection.

The file format is called .fvecs and is deliberately simple. Each vector is stored as a four-byte integer giving the dimension, followed by that many four-byte floating-point values, and the vectors are placed one after another with no header. The ground truth files use the extension .ivecs and have exactly the same layout with four-byte integers as the payload. Reading these files requires no library of any kind, which is why this format is preferred here over the HDF5 packaging used by the public benchmark suite.


Chapter 10. The implementation, stage by stage

Each stage states what is to be built, the decisions it involves, and how to establish that it is correct before proceeding.

Stage 0. Reading the data, and the layout decision

Objective. Read the vector and ground-truth files into memory.

Decision. Store all vectors in a single continuous allocation, with the vector of index i beginning at offset i × D, aligned to a 64-byte boundary. The reasoning is given in Section 8.2 and should be re-read if it is not fresh.

Verification. On siftsmall, confirm that exactly 10,000 vectors, 100 queries and 100 ground-truth records are read, and that every vector has dimension 128.

Stage 1. Exhaustive search, which becomes your reference

Objective. Implement exact k-nearest-neighbour search by scanning the whole collection.

This component is not scaffolding to be discarded. It produces the correct answers against which every approximate result will be measured, and it is the only component whose correctness can be established without reference to anything else.

Verification, which must not be skipped. Your exhaustive search must reproduce the supplied ground truth for siftsmall exactly, allowing for arbitrary ordering among points at equal distance. If it does not, the fault lies in the file reader or in the distance function. Both must be eliminated before a single line of graph code is written. If you proceed without this check, you will later observe poor recall and begin searching for the fault in your graph construction, where it is not.

Record. Queries per second on the full million-vector collection. This is the baseline against which the final result will be expressed, and you should expect to improve on it by roughly a factor of a thousand.

Stage 2. The distance function

Objective. Implement squared Euclidean distance, first plainly and then using the vector instructions described in Section 8.3.

Verification. The plain and vectorised versions must agree to within floating-point tolerance across at least a million randomly generated pairs. They will not agree exactly, because floating point addition is not associative and the two versions add the terms in different orders. A small tolerance is correct here; exact equality is not the right test.

Record. Repeat the Stage 1 measurement. The improvement obtained from layout and vectorisation alone is worth documenting, since it is a real result and demonstrates the material of Chapter 8.

Stage 3. The beam search, built and tested alone

Objective. Implement the procedure of Sections 4.4 and 4.5, operating on a single graph layer, with no graph construction code present.

This one procedure accounts for most of the algorithm's behaviour and most of its running time. It is to be built and proven correct before anything is built on top of it.

A necessary technique. The procedure must record which vertices it has already examined. The direct approach, allocating a boolean array of size N per query or clearing one between queries, will dominate the running time completely, since a single search examines only a few hundred vertices out of a million and would be paying for a million-element operation each time.

The standard solution is to keep one array of 32-bit integers of length N, together with a counter that is incremented once per query. A vertex counts as visited when its stored value equals the current counter value. Marking a vertex means storing the counter. Nothing is ever cleared, because incrementing the counter invalidates all previous marks at a stroke. Both marking and testing are single operations.

Verification. Build a temporary graph in which every vertex is joined to its exact k nearest neighbours, computed using your Stage 1 implementation. Such a graph is about as easy to navigate as a graph can be. Running your beam search on it with a generous ef must produce recall@10 of at least 0.99. If it does not, the fault is in the procedure itself, and finding it now is far easier than finding it after construction code exists to obscure the diagnosis.

Stage 4. Building a single-layer graph

Objective. Construct a Navigable Small World graph by inserting vertices one at a time, with no hierarchy yet.

Procedure. For each new vertex, run the Stage 3 search from a fixed entry point with ef set to efConstruction. This yields a set of candidate neighbours. Select M of them and connect. Add each edge in both directions, since a neighbour that cannot reach back to the new vertex provides no help in navigating toward it. Adding the reverse edge may push an existing vertex over its edge budget, in which case that vertex's neighbour set must be reduced.

Decision. Store the adjacency as a flat array giving a fixed number of neighbour slots per vertex, together with a count of how many are used. The reasoning parallels Stage 0: a vertex's neighbour list is read at every step of every search and should arrive in one or two cache lines.

A frequent error. When a vertex exceeds its budget, the tempting remedy is to discard its single furthest edge. This is not the same as re-applying the neighbour selection procedure to its complete set of neighbours, and the difference degrades recall in a way that looks like a parameter tuning problem and is not one. Apply the selection procedure properly.

Verification. Plot recall@10 against ef on siftsmall. The curve should rise and then level off below the accuracy you want. That ceiling is the deficiency that Stages 5 and 6 exist to remove, and seeing it for yourself is the point of this stage.

Stage 5. Adding the hierarchy

Objective. Extend Stage 4 to multiple layers as described in Section 5.4.

Procedure. Assign each vertex a height by the method of Section 5.6. The vertex of greatest height is the global entry point. When inserting a vertex of height l, first descend from the top layer down to layer l + 1 using greedy routing with ef set to 1, which serves only to find a good starting position. Then, from layer l down to layer zero, run the full search with ef set to efConstruction and establish edges. Layer zero uses Mmax0 rather than M.

Verification. Produce a histogram of vertex heights and confirm that the population falls by a factor of about M at each successive layer, as derived in Section 5.6. If a large fraction of your vertices reach layer one, either mL is wrong or your random number generator is being reset, most commonly by constructing it inside the insertion function so that every vertex receives the same random value. This check takes a few minutes and catches a class of fault that is otherwise very hard to notice, because the index still works and is merely slow.

Record. At equal recall, both the query rate and the average number of distance computations per query should improve relative to Stage 4.

Stage 6. The diversity rule

Objective. Replace nearest-M selection with the procedure of Section 6.2.

Verification. Run both selection rules and plot the two recall-against-throughput curves on the same axes. The diversity rule should show a clear advantage in the high-recall region, which is the region in which such systems are actually deployed.

Keep this comparison. It is the clearest evidence you will produce that you understood the algorithm rather than transcribed it, and it belongs at the top of whatever you write about the project.

Stage 7. The parameter study and the final result

Objective. Characterise the finished index across its operating range on the full collection.

Procedure. Vary M over 8, 16, 32 and 48; efConstruction over 100, 200 and 500; and ef over a range from roughly 10 to 800. For each configuration record recall@10 and sustained throughput, and plot the curve.

Comparison. Install the hnswlib library and run it on the same machine, the same data and the same thread count, and plot its curve alongside yours. Reaching throughput within roughly a factor of two of it at recall 0.95 is a strong result for an independent implementation.

Measuring honestly. Accumulate your search results into a value that is printed at the end. Without this, an optimising compiler may observe that the results are never used and remove the search entirely, and you will report an extraordinary figure that measures nothing. Report the minimum across several runs rather than the average, since you are trying to measure the computation rather than the interference from the rest of the operating system. State the thread count and whether the caches were warm.

Stage 8. Deletion

Objective. Support removing vertices from a built index.

This stage is included precisely because the algorithm handles deletion badly. Being able to explain a genuine weakness of a structure is worth more in conversation than another feature that works without incident.

Implement and compare two approaches. The first marks a vertex as deleted and filters it out of results while leaving it in the graph. This remains correct, but deleted vertices continue to consume steps during every search, and as deletions accumulate an increasing proportion of each vertex's edges lead to vertices that can never be returned, so navigability degrades. The second repairs the graph when a vertex is removed by connecting its former neighbours to one another.

Verification. Delete 10 per cent, then 30, then 50, and plot recall over the surviving points for both strategies.

Stage 9. Saving the index

Objective. Write the index to a file arranged so that it can be memory-mapped, that is, made available to the program by the operating system without being copied or parsed.

Decision. The file should contain a header recording the dimension, the vertex count, M, the entry point and the per-layer offsets, followed by the adjacency data and then the vectors. It must contain offsets rather than pointers, since the operating system may place the file at any address and stored pointers would be meaningless there.

Verification. Save, load and confirm that queries return results identical to those from the in-memory index.

Record. Time to map the file compared with time to rebuild the index from scratch.

Stage 10. Optional extensions, if you want more

Product quantisation reduces the memory the vectors occupy. Each 128-dimensional vector is cut into, say, eight pieces of 16 dimensions. For each piece position, the k-means clustering algorithm is run across the whole collection to find 256 representative values, and each piece is then replaced by the single byte identifying which representative it is closest to. A vector of 512 bytes becomes 8 bytes. Distances are computed approximately from small precomputed tables. This is the technique that allows indexes of a billion vectors to fit in memory, and combining it with a graph index is standard practice in production systems.

Filtered search answers queries of the form "the nearest neighbours among those items satisfying some condition", such as being in stock or belonging to a particular user. This appears to be a small addition and is not. The graph's navigability depends on edges that the filter may forbid the search to follow, so a restrictive condition can leave the search stranded in a region from which no permitted edge leads anywhere useful. This remains a subject of active research rather than a solved problem, which makes it interesting ground if you want to work on something unsettled.

Parallel construction, using one lock per vertex, should be attempted only after correctness is fully established.


Chapter 11. Suggested order of work

Your current machine is available until 5 October. Both datasets should be downloaded immediately. Stages 0 through 2 should be finished and Stage 3 verified before that date.

The reason for choosing that particular stopping point is that Stage 3 is the structural core of the project. Once the beam search has been proven correct against an exact nearest-neighbour graph, every later stage adds to it without modifying it, and the work can be resumed on any machine without needing to re-establish context.

Stages 4 through 6 contain the real algorithmic content and should be expected to take the longest. Stage 7 produces the result you show to other people.


Chapter 12. Errors that will cost you days

These are listed roughly in order of how much time they consume when committed.

Storing vectors or adjacency lists in a container of containers. The cost is large, spread across the whole program, and awkward to correct once everything is built. See Section 8.2.

Allocating or clearing the visited set on every query, instead of using the counter technique described in Stage 3.

Ordering one of the two priority queues the wrong way round. The search still runs, still terminates, and still returns plausible results that are quietly worse than they should be. Nothing fails, so there is no natural point at which to start investigating. The only symptom is a recall figure lower than you expected, which is easily mistaken for a tuning problem.

Leaving the square root in the distance function. See Section 1.3.

Looking for the fault in the wrong component. Investigating graph construction when the defect is in the file reader. This is exactly what the Stage 1 verification exists to prevent.

Discarding the furthest edge when pruning rather than re-applying the selection procedure.

Measuring recall against your own earlier output rather than against ground truth. This establishes only that your program agrees with itself.

Benchmarking a loop the compiler removed because nothing consumed its results.

Developing against the full collection. Every experiment then costs minutes that ought to cost seconds. The small subset exists for this purpose.


Chapter 13. Questions to test yourself

You should be able to answer each of these without notes once the project is finished. They are close to what someone familiar with the field would ask.

Why do grid and tree methods fail in 128 dimensions? Give both arguments: the one about the number of neighbouring regions, and the one about distances ceasing to distinguish.

What ordinary data structure is the layer hierarchy an instance of, and in what respects does the correspondence hold?

What exactly goes wrong when neighbours are chosen purely by nearness? Describe the failure in terms of the connectivity of the graph, not in terms of a measured recall figure.

Why does layer zero receive twice the edge budget of the other layers?

Which parameters are fixed when the index is built, and which are chosen per query? What practical flexibility follows from that distinction?

How many bytes does each vector cost in your index, counting the vector itself and its edges separately? If you had to halve that, which would you attack first and by what method?

Why is deletion difficult in this structure, and what are the two available strategies?

Which part of a query is limited by memory access rather than by arithmetic? Explain how you established that by measurement rather than by reasoning about it.