Welcome back. In our last lesson, we designed the essential in-memory data structures for our HNSW graph. We settled on a cache-friendly layout using a hnswNode struct with a flexible array member to hold hnswNodeLayer structs, ensuring that a node and its layer connection information are stored contiguously.
With our data structures defined, we can now build the engine that will run on them. This lesson moves to the algorithmic heart of HNSW search: implementing the SEARCH-LAYER algorithm. This function is the workhorse responsible for finding the k nearest neighbors to a query within a single layer. Understanding this algorithm is crucial, as it's the fundamental building block for both the overall search process and the graph construction process we'll tackle later.
The Search Within a Layer
Imagine the multi-layer HNSW graph. The full search process involves starting at the top layer and "drilling down." The SEARCH-LAYER algorithm is the procedure that executes the horizontal search on each of those flat planes.

At its core, SEARCH-LAYER is a best-first or greedy search algorithm. Given one or more starting points (entry points), it iteratively explores the graph, always moving toward the nodes that are closest to our query vector.
The following video provides an excellent high-level intuition for this greedy traversal, which the author calls "greedy routing."
HNSW for Vector Search Explained and Implemented with Faiss (Python)
This video explains the core concept of the search strategy within a single layer.
Watch the short segment explaining greedy routing. The key idea is simple: from your current position, look at all your connected neighbors and jump to the one that is closest to your target query. You stop when no neighbor is closer than your current position—a "local minimum."
While this explains the basic principle, the actual SEARCH-LAYER algorithm is more robust. It doesn't just explore a single path; it maintains a pool of candidates to avoid getting stuck too early in a poor local minimum.
Algorithm 2: The Blueprint for SEARCH-LAYER
The original HNSW paper provides the definitive pseudocode for this procedure in "Algorithm 2." This will be our blueprint for implementation.
[PDF] Efficient and Robust Approximate Nearest Neighbor Search Using ...
This is the formal definition of the algorithm we are about to implement. The variable names in the pseudocode correspond to data structures we will need.
Focus on Algorithm 2, titled SEARCH-LAYER. Pay close attention to the three main sets: v (visited elements), C (candidates), and W (results). Try to trace the while loop for a couple of iterations in your head.
Let's dissect this algorithm. It requires three key data structures to manage its state:
-
v(Visited Set): A set to keep track of nodes we have already processed. This prevents us from getting stuck in cycles and doing redundant work. A hash set is a viable option, but for maximum performance, we'll discuss a common systems-level optimization for this shortly. -
C(Candidates): A collection of candidate nodes to visit, ordered by their distance to the query vectorq. The node with the smallest distance should be processed next. This is a perfect use case for a min-priority queue (or min-heap). -
W(Working Set / Results): A collection of theefnearest neighbors found so far, ordered by their distance to the query. To efficiently check if a new node is better than the worst node currently in our results, we need to quickly access the element with the largest distance. This calls for a max-priority queue (or max-heap).
The Algorithm Step-by-Step
Let's walk through the logic of SEARCH-LAYER(q, ep, ef, lc):
-
Initialization (Lines 1-3):
- Initialize
v,C, andWwith the entry point(s)ep. The distances from the entry points to the queryqare calculated. vnow containsep.- The min-heap
Ccontainsep, prioritized by its distance toq. - The max-heap
Wcontainsep, also prioritized by its distance toq.
- Initialize
-
Main Loop (Line 4): The search continues as long as there are candidates in
C. -
Candidate Selection (Line 5):
- Extract the best candidate
cfrom the min-heapC. This is the node closest toqthat we haven't explored yet.
- Extract the best candidate
-
Early Termination Condition (Lines 6-8):
- Get the furthest element
fcurrently in our resultsW. This is simply a peek at the top of the max-heap. if distance(c, q) > distance(f, q): This is a critical performance optimization. It means the best unexplored candidate (c) is already further away than the worst node in our result set (f). Since all other candidates inCare even further thanc(it's a min-heap), we know that no further exploration can improve our result setW. We can safely terminate the search for this layer.
- Get the furthest element
-
Neighbor Exploration (Lines 9-17):
- For each neighbor
eof the current nodec(on the specified layerlc):- If
ehas not been visited:- Mark
eas visited. - Get the furthest element
ffromW. if distance(e, q) < distance(f, q) or |W| < ef: This is the condition for adding a neighbor to our sets. We are interested ineif either:- Our result set
Wis not yet full (i.e., we have found fewer thanefneighbors). - The neighbor
eis closer to the queryqthan the furthest neighborfinW.
- Our result set
- If the condition passes, add
eto both the candidate min-heapCand the result max-heapW. - If adding
emakesWexceed the sizeef, remove the furthest element fromW(i.e., pop from the max-heap).
- Mark
- If
- For each neighbor
-
Return (Line 18):
- Return the final set of nearest neighbors,
W.
- Return the final set of nearest neighbors,
Implementation Notes for High Performance
Your background in systems programming will appreciate that the choice of data structures here is paramount for performance.
-
Heaps for
CandW: A textbook implementation in C would involve creating a binary heap backed by a dynamic array. You would implementheap_pushandheap_popfunctions that maintain the heap property. This avoids the overhead of more complex standard library containers. ForC, it's a min-heap; forW, a max-heap. -
The "Visited" Set (
v): A hash set can have non-trivial overhead (hashing, memory allocations). A far more efficient technique used in production systems is the epoch trick.- Add a global
uint64_t search_epoch;counter to your main HNSW index struct. - Add a
uint64_t visited_epoch;field to yourhnswNodestruct. - When a new search starts (the top-level
k-NN-SEARCHcall, notSEARCH-LAYER), increment the globalsearch_epoch. - To "mark a node as visited" within
SEARCH-LAYER, you setnode->visited_epoch = global_search_epoch;. - To "check if visited," you simply compare
node->visited_epoch == global_search_epoch;.
This approach is incredibly fast. It requires no dynamic memory allocation per search and has excellent data locality. You avoid the need to clear a large hash set or bitfield between every single search query.
- Add a global
C Pseudocode for search_layer
Here is a C-style pseudocode structure for the SEARCH-LAYER function, incorporating the data structures we've discussed.
// Assumes you have min_heap and max_heap data structures defined.
// They would store pairs of {hnswNode*, float distance}.
// The function returns the set of results W. For simplicity, we can
// represent it as a struct containing an array of nodes and their distances.
SearchResults search_layer(HNSW* hnsw, const void* query,
const HnswNodeList* entry_points,
int ef, int layer_lc)
{
// 1. Initialize data structures
MinHeap candidates; // C
MaxHeap results; // W
// `visited` check is done via epoch inside the loop
// 2. Add entry points to start the search
for (int i = 0; i < entry_points->count; ++i) {
hnswNode* ep = entry_points->nodes[i];
if (ep->visited_epoch != hnsw->search_epoch) {
ep->visited_epoch = hnsw->search_epoch;
float dist = distance(query, ep->vector);
min_heap_push(&candidates, ep, dist);
max_heap_push(&results, ep, dist);
}
}
// 3. Main search loop
while (!min_heap_is_empty(&candidates)) {
// 4. Check early termination condition
HeapNode* best_candidate = min_heap_peek(&candidates);
if (max_heap_size(&results) >= ef) {
HeapNode* worst_result = max_heap_peek(&results);
if (best_candidate->distance > worst_result->distance) {
break; // All remaining candidates are worse than our worst result
}
}
// 5. Get next candidate to explore
HeapNode current = min_heap_pop(&candidates);
hnswNode* current_node = current.node;
// 6. Explore neighbors
hnswNodeLayer* layer = ¤t_node->layers[layer_lc];
for (int i = 0; i < layer->num_links; ++i) {
hnswNode* neighbor = layer->links[i];
if (neighbor->visited_epoch != hnsw->search_epoch) {
neighbor->visited_epoch = hnsw->search_epoch;
float dist = distance(query, neighbor->vector);
HeapNode* worst_result = max_heap_peek(&results);
if (max_heap_size(&results) < ef || dist < worst_result->distance) {
min_heap_push(&candidates, neighbor, dist);
max_heap_push(&results, neighbor, dist);
if (max_heap_size(&results) > ef) {
max_heap_pop(&results); // Prune the worst result
}
}
}
}
}
// 7. Return the final set of results
return convert_max_heap_to_search_results(&results);
}
This implementation directly translates Algorithm 2 into a practical, high-performance C structure. The ef parameter, which stands for "efSearch," controls the size of the dynamic candidate list, giving us a knob to tune the trade-off between search speed and accuracy (recall). A larger ef explores more candidates and is more likely to find the true nearest neighbors, but at the cost of more computation.
Conclusion
In this lesson, you've dissected and planned the implementation of SEARCH-LAYER, the core greedy search algorithm at the heart of HNSW. This algorithm is not just a theoretical concept but a concrete procedure that operates directly on the data structures we designed in the previous lesson.
Key Takeaways:
- Greedy Search:
SEARCH-LAYERis a best-first search that explores a single graph layer to find theefnodes closest to a query. - Essential Data Structures: The algorithm's performance hinges on the efficient implementation of three components: a min-heap for candidates (
C), a max-heap for results (W), and a visited set (v). - Performance Optimizations: The early termination condition (best candidate > worst result) and the visited epoch trick are crucial for building a high-performance system.
efSearchParameter: This parameter controls the size of the candidate list, allowing you to tune the balance between search quality and latency.
We now have the tool to navigate a single layer of our hierarchy. The next logical step is to orchestrate calls to SEARCH-LAYER to perform a complete search across all layers. In the next lesson, we will implement the top-level search algorithm that starts from the highest layer, uses SEARCH-LAYER to find a good entry point for the level below, and repeats this process all the way down to the base layer.