Skip to content
FoundationVerified Implementation

HNSW (Hierarchical Navigable Small World)

Multi-layer proximity graph index for fast, high-recall approximate nearest-neighbor (ANN) search over high-dimensional vector embeddings (D = 64 to 4096).

Quick Facts

Compact metadata for implementation and discovery.

Category
Searching
Difficulty
Advanced
Family
Approximate Nearest Neighbor (ANN)
Tier
Foundation
Status
Verified
Review
Source Checked
Sources
1
Formulas
2
Relationships
2
Code example
Ready
Visualization
Ready
Aliases
Hierarchical Navigable Small World, HNSW Graph ANN

Overview

Hierarchical Navigable Small World (HNSW) builds a hierarchy of navigable small-world proximity graphs where upper layers provide long-range skip links and lower layers refine local cosine or Euclidean neighborhoods. Unlike K-D Trees and axis-aligned spatial trees—which suffer from the curse of dimensionality above ~20 dimensions and degrade to O(N) brute-force scan—HNSW achieves logarithmic empirical query time and >95% recall on million-scale 768D and 1536D embedding vectors.

Core Concepts

navigable small world graphhierarchical skip layersbeam search (efSearch)cosine distance

Quick facts

Complexity

Runtime and memory behavior at a glance.

Best

O(log n)

Average

O(log n)

Worst

O(n)

Space

O(n * M)

Interactive Runtime & Memory Budget Calculator

Estimate wall-clock latency, operation count, and working memory for HNSW (Hierarchical Navigable Small World) across input scales.

Input Scale N:
JIT-compiled JS/TS (~350M primitive ops/sec)
Est. Average TimeO(log n)

95 ns

Sub-microsecond~33
Est. Worst-Case TimeO(n)

428.6 µs

60 FPS Frame-Safe (<16ms)~150.0k ops
Est. Working MemoryO(n * M)

1.53 MB

Standard Heap FitN=100,000

When to Use

  • Hierarchical Navigable Small World multi-layer proximity graph for fast approximate nearest neighbor (ANN) vector search in high-dimensional embedding spaces (e.g., 512D, 768D, 1536D cosine or Euclidean embeddings).
  • Use HNSW when searching high-dimensional embeddings (D > 20, such as 64D, 768D, or 1536D vectors).
  • Use it when sub-millisecond query latency and >95% Recall@10 are required on large vector collections.

When Not to Use

  • Avoid HNSW for low-dimensional 2D or 3D geometry where an exact K-D Tree or R-Tree uses far less RAM.
  • Avoid uncompressed HNSW when memory is strictly bounded and graph links (M * 4-8 bytes/vector) exceed RAM.

What It Does

  • Assigns each inserted vector a maximum layer drawn from an exponential decay distribution
  • Greedily traverses upper layers (ef = 1) to locate the closest entry point for the base layer
  • Performs bounded beam search (efConstruction / efSearch) on lower layers and links diverse neighbors up to degree M

Use Cases

  • Semantic vector search over 768D or 1536D LLM and multimodal embeddings
  • Retrieval-Augmented Generation (RAG) and recommendation candidate generation
  • High-dimensional k-nearest-neighbor (k-NN) search where exact K-D Trees fail

Inputs

  • Collection of D-dimensional float vectors, query vector q, neighbor count k, beam width efSearch

Outputs

  • Top-k approximate nearest neighbor IDs and cosine/L2 distances

Key Formulas (2)

Cosine Distance

d_{\cos}(u, v) = 1 − \frac{u \cdot v}{\|u\|_2 \|v\|_2}
d_{\cos}(u, v) = 1 - \frac{u \cdot v}{\|u\|_2 \|v\|_2}

Angular dissimilarity in high-dimensional embedding spaces.

Layer Assignment

l = \lfloor −\ln(\text{unif}(0,1)) \cdot mL \rfloor,\quad mL = 1 / \ln(M)
l = \lfloor -\ln(\text{unif}(0,1)) \cdot m_L \rfloor,\quad m_L = 1 / \ln(M)

Exponentially decaying layer probability.

Pseudocode

  1. 1.Draw random level L = floor(-ln(uniform(0,1)) * m_L) for new vector v
  2. 2.From top layer down to L + 1: greedily walk to neighbor minimizing cosine distance to v
  3. 3.From min(L, maxLayer) down to 0: run beam search of width efConstruction to find candidate neighbors
  4. 4.Connect v bidirectionally to up to M closest candidates per layer, pruning overflow edges
  5. 5.To query k-NN(q): greedy walk down to layer 1, then run efSearch beam search at layer 0 and return top k

Verified Implementation & Unit Tests

Verified Implementation

Tested TypeScript and Python 3 implementations paired with unit tests covering edge cases, duplicate keys, and boundary conditions.

/**
 * HNSW (Hierarchical Navigable Small World) — Multi-layer proximity graph for high-D embeddings
 * Build: O(N log N) empirical via efConstruction beam search | Query: O(log N) via efSearch
 * Distance: Cosine dissimilarity (1 - dot(u, v) / (||u|| * ||v||))
 */
export interface HNSWHit { id: number; distance: number; }

export class HNSWIndex {
  private readonly vectors = new Map<number, Float32Array>();
  private readonly norms = new Map<number, number>();
  private readonly nodeLevels = new Map<number, number>();
  private readonly layers: Array<Map<number, number[]>> = [];
  private entryPoint: number | null = null;
  private maxLevel = -1;
  private readonly levelMult: number;
  private rngState = 0x9e3779b9;

  constructor(
    private readonly M = 24,
    private readonly efConstruction = 96,
    private readonly efSearch = 80
  ) {
    this.levelMult = 1 / Math.log(Math.max(2, M));
  }

  private randomLevel(): number {
    this.rngState = (Math.imul(this.rngState, 1664525) + 1013904223) >>> 0;
    const u = Math.max(1e-9, (this.rngState >>> 8) / 16777216);
    return Math.min(8, Math.floor(-Math.log(u) * this.levelMult));
  }

  private static norm(v: Float32Array): number {
    let s = 0;
    for (let i = 0; i < v.length; i++) s += v[i] * v[i];
    return Math.sqrt(s) || 1;
  }

  private cosineDist(a: Float32Array, normA: number, bId: number): number {
    const b = this.vectors.get(bId)!;
    const normB = this.norms.get(bId)!;
    let dot = 0;
    for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
    return 1 - dot / (normA * normB);
  }

  private searchLayer(query: Float32Array, qNorm: number, epId: number, ef: number, layer: number): HNSWHit[] {
    const visited = new Set<number>([epId]);
    const epDist = this.cosineDist(query, qNorm, epId);
    const candidates: HNSWHit[] = [{ id: epId, distance: epDist }];
    const results: HNSWHit[] = [{ id: epId, distance: epDist }];
    const adj = this.layers[layer];

    while (candidates.length > 0) {
      candidates.sort((a, b) => a.distance - b.distance);
      const curr = candidates.shift()!;
      results.sort((a, b) => a.distance - b.distance);
      const worstResult = results[results.length - 1];
      if (curr.distance > worstResult.distance && results.length >= ef) break;

      for (const nbId of adj.get(curr.id) ?? []) {
        if (visited.has(nbId)) continue;
        visited.add(nbId);
        const d = this.cosineDist(query, qNorm, nbId);
        if (results.length < ef || d < results[results.length - 1].distance) {
          candidates.push({ id: nbId, distance: d });
          results.push({ id: nbId, distance: d });
          if (results.length > ef) {
            results.sort((a, b) => a.distance - b.distance);
            results.pop();
          }
        }
      }
    }
    return results.sort((a, b) => a.distance - b.distance);
  }

  add(id: number, vector: Float32Array): void {
    const vNorm = HNSWIndex.norm(vector);
    this.vectors.set(id, vector);
    this.norms.set(id, vNorm);
    const level = this.randomLevel();
    this.nodeLevels.set(id, level);
    while (this.layers.length <= level) this.layers.push(new Map());
    for (let l = 0; l <= level; l++) this.layers[l].set(id, []);

    if (this.entryPoint === null) {
      this.entryPoint = id;
      this.maxLevel = level;
      return;
    }

    let currEp = this.entryPoint;
    let currDist = this.cosineDist(vector, vNorm, currEp);

    for (let l = this.maxLevel; l > level; l--) {
      let changed = true;
      while (changed) {
        changed = false;
        for (const nb of this.layers[l].get(currEp) ?? []) {
          const d = this.cosineDist(vector, vNorm, nb);
          if (d < currDist) {
            currDist = d;
            currEp = nb;
            changed = true;
          }
        }
      }
    }

    for (let l = Math.min(level, this.maxLevel); l >= 0; l--) {
      const topCandidates = this.searchLayer(vector, vNorm, currEp, this.efConstruction, l);
      const mMax = l === 0 ? this.M * 2 : this.M;
      const selected = topCandidates.slice(0, this.M).map((c) => c.id);
      this.layers[l].set(id, selected);
      for (const nb of selected) {
        const nbList = this.layers[l].get(nb)!;
        nbList.push(id);
        if (nbList.length > mMax) {
          const nbVec = this.vectors.get(nb)!;
          const nbNorm = this.norms.get(nb)!;
          nbList.sort((a, b) => this.cosineDist(nbVec, nbNorm, a) - this.cosineDist(nbVec, nbNorm, b));
          nbList.length = mMax;
        }
      }
      if (topCandidates.length > 0) currEp = topCandidates[0].id;
    }

    if (level > this.maxLevel) {
      this.maxLevel = level;
      this.entryPoint = id;
    }
  }

  searchKNN(query: Float32Array, k = 10): HNSWHit[] {
    if (this.entryPoint === null) return [];
    const qNorm = HNSWIndex.norm(query);
    let currEp = this.entryPoint;
    let currDist = this.cosineDist(query, qNorm, currEp);

    for (let l = this.maxLevel; l >= 1; l--) {
      let changed = true;
      while (changed) {
        changed = false;
        for (const nb of this.layers[l].get(currEp) ?? []) {
          const d = this.cosineDist(query, qNorm, nb);
          if (d < currDist) {
            currDist = d;
            currEp = nb;
            changed = true;
          }
        }
      }
    }

    const hits = this.searchLayer(query, qNorm, currEp, Math.max(this.efSearch, k), 0);
    return hits.slice(0, k);
  }
}

Canonical Example

Input
3,000 unit vectors in R^64, query q, k = 10, M = 12, efSearch = 48
Output
Top-10 nearest vector IDs with >0.92 Recall@10 vs brute-force cosine scan
Explanation
Upper layers skip across the sphere in a few hops; layer-0 beam search explores the local Voronoi neighborhood.

Implementation Notes

  • Typical production defaults: M = 16, Mmax0 = 32, efConstruction = 100..200, efSearch = 32..128.
  • Pre-normalize vectors to unit L2 norm so cosine distance simplifies to 1 - dot(a, b).

Common Pitfalls

  • Using K-D Tree instead of HNSW for high-dimensional embeddings (D > 20).
  • Connecting new nodes by sorting all existing vertices instead of using beam search (turns O(N log N) build into O(N^2)).
  • Setting efSearch smaller than k or forgetting to normalize vectors for cosine similarity.

Interactive Mechanism Walkthrough & State Debugger

Indexed Interval & Boundary Probe

Multi-layer skip-graph traversal zooming from sparse long-range links to dense local neighborhoods.

Target key: 41low=5, mid=4, high=9
4[0]
9[1]
15[2]
22[3]
27[4]
34[5]
41[6]
49[7]
58[8]
63[9]
target41
window [low..high][5 .. 9]
probe mididx 4 (val=27)
comparison27 < 41 (Go Right)
Step 1 of 5: Draw random level L = floor(-ln(uniform(0,1)) * m_L) for new vector v
search-space

Notes

Standard index in vector databases (pgvector, Qdrant, Milvus, Vespa, FAISS) for high-dimensional embeddings.

Sources (1)

What each reference supports for this entry.

Malkov & Yashunin (2018) — Efficient and robust approximate nearest neighbor search using HNSW graphs

Paper

Original IEEE TPAMI paper introducing HNSW.

Open source

Explore Next

Good next jumps from the relationship graph, ranked for useful discovery.

Alternative tok-d Tree

Replaces K-D Tree when dimensionality D > 20 causes spatial tree pruning to degrade to linear scan.

Achieves higher recall-per-query-millisecond than LSH on dense vector embeddings.

Tags

Nearest NeighborHigh DimensionalGraph IndexVector SearchEmbeddings