barrel_vectordb_hnsw (barrel_vectordb v2.1.2)
View SourceHNSW (Hierarchical Navigable Small World) Implementation.
Based on the paper by Yu. A. Malkov, D. A. Yashunin: "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs" (2016).
Many implementation details not covered in the original paper were derived from antirez's practical optimizations for Redis vector-sets. Reference: https://antirez.com/news/156
Algorithm Overview
HNSW builds a multi-layer graph where each layer is a navigable small-world graph. Higher layers have fewer nodes and act as "express lanes" for fast navigation, while layer 0 contains all nodes.
Search proceeds top-down: starting from the entry point at the highest layer, we greedily descend until reaching layer 0, then perform a more thorough beam search to find the k nearest neighbors.
Insert assigns each new node to a random layer L (exponentially distributed), then connects it to neighbors at layers 0..L using the same search mechanism.
Implementation Features
1. Bidirectional links only. When a new node is inserted, we add connections from the new node to its neighbors AND from each neighbor back to the new node. This maintains graph navigability and enables proper deletion.
2. 8-bit scalar quantization. Vectors are quantized to signed int8 values using per-vector scaling:
quantized[i] = round(vector[i] * 127 / max_abs_value)
The scale factor (max_abs_value) is stored with each quantized vector. This provides 8x memory reduction compared to float64 with minimal recall loss (~1-2% for typical embeddings).
Storage format: <<Scale:32/float-little, Components/binary>> where each component is a signed 8-bit integer.
3. Norm caching. The L2 norm of each vector is computed once at insertion time and stored in the node record. This eliminates redundant norm calculations during cosine distance computation:
cosine_distance = 1 - (A·B) / (||A|| * ||B||)
Without caching: 3 dot products per comparison (A·B, A·A, B·B) With caching: 1 dot product + 2 lookups (A·B, cached norms)
4. Integer domain dot product. Distance calculations between quantized vectors use integer arithmetic:
int_dot = sum(a[i] * b[i]) -- integer multiplication real_dot = int_dot * scale_a * scale_b / (127 * 127)
This is faster than floating-point operations and SIMD-friendly.
5. Priority queue using gb_trees. The search algorithm maintains candidates in a balanced tree (Erlang's gb_trees) providing O(log N) insert and extract-min operations, versus O(N log N) for repeated lists:sort calls.
6. Direct graph serialization. The full graph structure (nodes, vectors, neighbors, metadata) is serialized to binary format for persistence. On startup, the index is deserialized directly without rebuilding the graph, providing ~100x faster loading compared to re-inserting all vectors.
Serialization format (version 2): <<Version:8, Size:32, MaxLayer:8, Dim:16, EntryPoint/binary, Config/binary, Nodes/binary>>
7. True deletion. Nodes are actually removed from the graph, not just marked as deleted. The deleted node is removed from all its neighbors' adjacency lists. A new entry point is selected if the deleted node was the entry point.
Configuration Parameters
- M: Maximum number of connections per node per layer (default: 16). Higher values improve recall but increase memory and build time.
- M_max0: Maximum connections at layer 0 (default: 2*M). Layer 0 typically needs more connections for good recall.
- ef_construction: Beam width during index construction (default: 200). Higher values improve index quality but slow down insertion.
- ef_search: Beam width during search (default: max(K, 50)). Higher values improve recall at the cost of search latency.
- ml: Level multiplier = 1/ln(M). Controls the probability distribution for assigning nodes to layers.
Complexity
- Insert: O(log N) average, O(ef_construction * M * log N) work - Search: O(log N) average for finding entry, O(ef_search * M) at layer 0 - Delete: O(M * layers) to update neighbor lists - Memory: O(N * M * layers) for graph + O(N * dim) for vectors
Summary
Functions
Compute L2 norm of a float vector
Cosine distance between two float vectors
Cosine similarity between two float vectors
Delete a node from the index
Dequantize back to float vector (scalar quantization)
Dequantize using the method specified in config
Deserialize index from binary
Deserialize a single node
Euclidean distance between two float vectors
Get a node by ID
Get index info
Insert a vector with given ID into the index Vector is quantized to 8-bit and norm is cached
Create a new empty HNSW index with default configuration
Create a new empty HNSW index with custom configuration Options: - m: Max connections per layer (default: 16) - m_max0: Max connections at layer 0 (default: 2*M) - ef_construction: Build-time ef (default: 200) - distance_fn: cosine | euclidean (default: cosine) - dimension: Vector dimension (default: 768) - quantization: scalar | turboquant | subspace_turboquant | none (default: scalar) - tq_bits: TurboQuant bits per component (default: 3, when quantization=turboquant or subspace_turboquant) - tq_seed: TurboQuant random seed (default: 42) - tq_m: Number of subspaces for subspace_turboquant (default: auto, based on dimension)
Quantize a float vector to 8-bit signed integers (scalar quantization). Format: <<Scale:32/float-little, Components/binary>> This is the default/legacy function for backward compatibility.
Quantize a vector using the method specified in config
8-bit scalar quantization
Search for k nearest neighbors
Search for k nearest neighbors with options
Serialize entire index to binary (includes full graph structure)
Serialize a single node (for incremental persistence)
Get index size
Types
-type hnsw_config() :: #hnsw_config{m :: pos_integer(), m_max0 :: pos_integer(), ef_construction :: pos_integer(), ml :: float() | undefined, distance_fn :: cosine | euclidean, quantization :: quantization_method(), tq_state :: term() | undefined}.
-type hnsw_index() :: #hnsw_index{entry_point :: binary() | undefined, max_layer :: non_neg_integer(), nodes :: #{binary() => hnsw_node()}, config :: hnsw_config(), size :: non_neg_integer(), dimension :: pos_integer()}.
-type hnsw_node() :: #hnsw_node{id :: binary(), vector :: quantized_vector(), norm :: float(), layer :: non_neg_integer(), neighbors :: #{non_neg_integer() => [binary()]}}.
-type quantization_method() :: scalar | pq | turboquant | subspace_turboquant | none.
-type quantized_vector() :: binary().
Functions
Compute L2 norm of a float vector
Cosine distance between two float vectors
Cosine similarity between two float vectors
-spec delete(hnsw_index(), binary()) -> hnsw_index().
Delete a node from the index
Dequantize back to float vector (scalar quantization)
-spec dequantize(binary(), #hnsw_config{m :: pos_integer(), m_max0 :: pos_integer(), ef_construction :: pos_integer(), ml :: float() | undefined, distance_fn :: cosine | euclidean, quantization :: quantization_method(), tq_state :: term() | undefined}) -> [float()].
Dequantize using the method specified in config
-spec deserialize(binary()) -> {ok, hnsw_index()} | {error, term()}.
Deserialize index from binary
Deserialize a single node
Euclidean distance between two float vectors
-spec get_node(hnsw_index(), binary()) -> {ok, hnsw_node()} | not_found.
Get a node by ID
-spec info(hnsw_index()) -> map().
Get index info
-spec insert(hnsw_index(), binary(), [float()]) -> hnsw_index().
Insert a vector with given ID into the index Vector is quantized to 8-bit and norm is cached
-spec new() -> hnsw_index().
Create a new empty HNSW index with default configuration
-spec new(map()) -> hnsw_index().
Create a new empty HNSW index with custom configuration Options: - m: Max connections per layer (default: 16) - m_max0: Max connections at layer 0 (default: 2*M) - ef_construction: Build-time ef (default: 200) - distance_fn: cosine | euclidean (default: cosine) - dimension: Vector dimension (default: 768) - quantization: scalar | turboquant | subspace_turboquant | none (default: scalar) - tq_bits: TurboQuant bits per component (default: 3, when quantization=turboquant or subspace_turboquant) - tq_seed: TurboQuant random seed (default: 42) - tq_m: Number of subspaces for subspace_turboquant (default: auto, based on dimension)
Quantize a float vector to 8-bit signed integers (scalar quantization). Format: <<Scale:32/float-little, Components/binary>> This is the default/legacy function for backward compatibility.
-spec quantize([float()], #hnsw_config{m :: pos_integer(), m_max0 :: pos_integer(), ef_construction :: pos_integer(), ml :: float() | undefined, distance_fn :: cosine | euclidean, quantization :: quantization_method(), tq_state :: term() | undefined}) -> binary().
Quantize a vector using the method specified in config
8-bit scalar quantization
-spec search(hnsw_index(), [float()], pos_integer()) -> [{binary(), float()}].
Search for k nearest neighbors
-spec search(hnsw_index(), [float()], pos_integer(), map()) -> [{binary(), float()}].
Search for k nearest neighbors with options
-spec serialize(hnsw_index()) -> binary().
Serialize entire index to binary (includes full graph structure)
Serialize a single node (for incremental persistence)
-spec size(hnsw_index()) -> non_neg_integer().
Get index size