Skip to content

About

Go bindings for hnswlib - fast approximate nearest neighbor search.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Repository files navigation

go-hnswlib

Go bindings for hnswlib - fast approximate nearest neighbor search.

Installation

go get github.com/viktordanov/go-hnswlib

Uses cgo: the bundled C++ wrapper is compiled by the Go toolchain on the target machine. A working C++17 toolchain is required where you build.

Usage

package main

import (
    "fmt"
    "github.com/viktordanov/go-hnswlib/hnsw"
)

func main() {
    // Create index: 128 dimensions, max 1000 elements
    index := hnsw.NewL2(128, 1000, 16, 200, 42)
    defer index.Close()

    // Add vectors (safe - returns error)
    vec := []float32{0.1, 0.2, 0.3, ...} // 128 dimensions
    if err := index.Add(vec, 0); err != nil {
        log.Fatal(err)
    }

    // Search for 5 nearest neighbors
    query := []float32{0.1, 0.2, 0.3, ...} // 128 dimensions
    labels, distances, count := index.SearchK(query, 5)
    
    fmt.Printf("Found %d neighbors\n", count)
}

📖 More Examples: See examples/examples.go for comprehensive examples of all spaces and features.

⚡ Benchmarks: See experimental/ for comprehensive EF parameter studies and performance analysis.

API

Create Index:

  • hnsw.NewL2(dim, maxElements, M, efConstruction, seed) - Euclidean distance

  • hnsw.NewIP(dim, maxElements, M, efConstruction, seed) - Inner product

  • hnsw.NewCosine(dim, maxElements, M, efConstruction, seed) - Cosine similarity

  • index, err := hnsw.NewChecked(space, dim, maxElements, M, efConstruction, seed) - like New, but returns the error instead of a nil index

    The New* constructors return a literal nil on failure (invalid params or out of memory); a nil index is safe to call (every method treats it as closed). Use NewChecked when you need the reason.

  • index, err := hnsw.Load(space, dim, path) - Load from file (copies into memory)

  • index, err := hnsw.LoadMmap(space, dim, path) - Memory-map from file (see below)

Both loaders validate the file's header, layout, and graph and return an error for a corrupt, truncated, internally-inconsistent, or wrong-dim file. The distance space (L2 vs inner product) is not stored on disk and cannot be validated — you must pass the same space the index was built with.

Parity note: the two loaders are otherwise equivalent, with one intentional difference — Load rejects a header declaring max_elements > INT_MAX, while LoadMmap accepts it. Both remain memory-safe; such indexes are not producible in practice, so this is informational only.

Operations:

  • err := index.Add(vec, label) - Add vector with label (safe, checks capacity)
  • labels, distances, count := index.SearchK(query, k) - Find k nearest neighbors
  • labels, similarities, count := index.SearchKSimilarity(query, k) - Get similarities instead of distances
  • err := index.Save(path) - Save to file (atomic temp-file + rename; see How Save writes files)
  • err := index.Resize(newMaxElements) - Resize index capacity (safe)
  • index.SetEf(ef) - Set search accuracy
  • index.Close() - Free memory

Introspection:

  • index.GetCurrentCount() - Number of elements in index
  • index.GetMaxElements() - Maximum capacity
  • index.GetDeletedCount() - Number of deleted elements
  • index.GetDimension() - Vector dimension of the index
  • index.IsCosineSpace() - Check if using cosine similarity
  • index.IsMapped() - Check if the index is memory-mapped (loaded via LoadMmap) vs heap-loaded (Load/New)

Delete Management:

  • err := index.MarkDeleted(label) - Soft delete element (safe)
  • err := index.UnmarkDeleted(label) - Restore deleted element (safe)

Vector Export (for data migration and inspection):

  • vec, err := index.GetVector(label) - Retrieve one stored vector by label (normalized for cosine indexes)
  • vecs, err := index.GetVectors(labels) - Retrieve many vectors as a map[label]vector (missing/deleted labels omitted)
  • err := index.Export(includeDeleted, fn) - Stream every element through a callback, one vector at a time
  • it, err := index.NewIterator() - Memory-efficient iterator over all elements; not safe against concurrent modification or Close
    • it.Next() - Whether more elements remain
    • elem, err := it.Element() - Current element's label + deleted flag
    • vec, err := it.Vector() - Current element's vector data
    • it.Advance() - Move to the next element
    • it.Progress() - (current, total) position
    • it.Reset() - Restart from the beginning

Memory-mapped loading (LoadMmap)

hnsw.LoadMmap(space, dim, path) maps the index file copy-on-write (MAP_PRIVATE) instead of copying it into the C heap. Searches then read file-backed, reclaimable page-cache pages, so the (usually dominant) vector blob never counts as anonymous process memory and can be evicted under pressure and re-faulted from disk. It consumes the exact same file format as Load — no format change, fully compatible with files written by Save (and by upstream hnswlib / the Python bindings). LoadMmap uses mmap(2) and so requires a POSIX platform (darwin/linux — see Supported Platforms below).

index, err := hnsw.LoadMmap(hnsw.SpaceL2, 128, "index.bin")
if err != nil {
    log.Fatal(err)
}
defer index.Close()

labels, distances, count := index.SearchK(query, 5) // reads shared pages

Use Load/New to build an index and LoadMmap to serve it. Mapped indexes have these caveats (all enforced, not just documented):

  • Capacity is pinned. GetMaxElements() == GetCurrentCount(), even if the file was saved with spare headroom. Add and Resize return an error — a mapped index cannot grow in place.
  • MarkDeleted / UnmarkDeleted work, but are memory-only. Under copy-on-write the change dirties a private page; the on-disk file is never modified and the change is lost at Close unless you persist it.
  • Save must target a different path. Saving a mapped index back onto its own source file is refused (see the source-file rule below). Saving to any distinct path is allowed. A plain heap index may Save onto a file that another index currently has mapped: because every Save is a temp file + atomic rename, the rename swaps in a new inode while the mapped index keeps reading its original (now-unlinked) inode intact — nothing is ever truncated under a live mapping. Only the low-level bindings.SaveIndexSafe writes in place, and it therefore refuses any destination that backs a live mapping.
  • The source file must stay present and unmodified until Close. Truncating or rewriting it in place causes SIGBUS on a later fault. To update an index on disk, write a new file and rename it over the old path — that leaves the old inode mapped and intact for existing readers.

How Save writes files

index.Save(path) never writes path in place: it writes a hidden temp file in the same directory, fsyncs it, and atomically renames it over path. Consequences of that flow:

  • No partially-written index is ever visible at path. Readers — including a concurrent LoadMmap — see either the old complete file or the new one.
  • The containing directory must be writable and searchable. Save creates and renames a temp file in path's directory, so it needs write+execute permission on the directory. Write access to just the destination file is not sufficient (this differs from a plain in-place overwrite).
  • A symlink at path is replaced, not followed. rename replaces the symlink itself with a regular file; the file the symlink pointed to is left untouched.
  • Permissions are preserved. Re-saving over an existing file keeps that file's mode; saving to a fresh path uses standard create semantics (mode 0666 masked by the process umask).

The low-level bindings.SaveIndexSafe bypasses this flow and writes its destination in place, with none of the guarantees above.

Build flags

The default build is minimal, portable, and dynamically linked: add the package, enable cgo, and go build just works — no extra linking or static-lib prerequisites. It compiles with -std=c++17 and cgo's default optimization, leaves hnswlib's manual SIMD vectorization at its upstream default, and does not pass -march=native or link statically (so go get artifacts stay portable across CPUs).

For host-tuned SIMD and/or static linking, opt in with the hnsw_native build tag — and own the consequences (the binary is baked to the build host's CPU features; -static with cgo+glibc on Linux can warn or fail when static system libs are absent):

go build -tags hnsw_native ./...   # adds -march=native (+ -static on linux)

Development

Build from source:

git clone https://github.com/viktordanov/go-hnswlib
cd go-hnswlib
go build ./...
go test ./...

Regenerate low-level bindings after C API changes:

./generate_bindings.sh
go build ./...

Project structure:

├── hnswlib/           # Upstream hnswlib headers
├── hnsw_wrapper.h     # C wrapper API
├── hnsw_wrapper.cpp   # C++ wrapper implementation
├── bindings.go        # Generated c-for-go bindings
├── cgo_helpers.*      # Generated cgo helpers
├── types.go           # Generated C type mappings
├── flags_native.go    # Opt-in `hnsw_native` build tag (‑march=native / ‑static)
├── ctest/             # Standalone C test harness + libFuzzer target (no Go)
├── examples/          # Usage examples
├── experimental/      # Benchmarks and parameter studies (separate module)
└── hnsw/              # High-level Go API

Requirements for building:

  • Go 1.26+ (matches the go directive in go.mod)
  • C++ compiler (clang++ or g++)
  • c-for-go (auto-installed)

Supported Platforms

Tested on:

  • darwin_arm64 (macOS Apple Silicon)
  • linux_amd64 (Linux x86-64)
  • linux_arm64 (Linux ARM64)

License

Same as hnswlib.

About

Go bindings for hnswlib - fast approximate nearest neighbor search.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages