Go bindings for hnswlib - fast approximate nearest neighbor search.
go get github.com/viktordanov/go-hnswlibUses 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.
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.
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)- likeNew, but returns the error instead of a nil indexThe
New*constructors return a literalnilon failure (invalid params or out of memory); a nil index is safe to call (every method treats it as closed). UseNewCheckedwhen 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 —
Loadrejects a header declaringmax_elements > INT_MAX, whileLoadMmapaccepts 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 neighborslabels, similarities, count := index.SearchKSimilarity(query, k)- Get similarities instead of distanceserr := index.Save(path)- Save to file (atomic temp-file +rename; see HowSavewrites files)err := index.Resize(newMaxElements)- Resize index capacity (safe)index.SetEf(ef)- Set search accuracyindex.Close()- Free memory
Introspection:
index.GetCurrentCount()- Number of elements in indexindex.GetMaxElements()- Maximum capacityindex.GetDeletedCount()- Number of deleted elementsindex.GetDimension()- Vector dimension of the indexindex.IsCosineSpace()- Check if using cosine similarityindex.IsMapped()- Check if the index is memory-mapped (loaded viaLoadMmap) 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 amap[label]vector(missing/deleted labels omitted)err := index.Export(includeDeleted, fn)- Stream every element through a callback, one vector at a timeit, err := index.NewIterator()- Memory-efficient iterator over all elements; not safe against concurrent modification orCloseit.Next()- Whether more elements remainelem, err := it.Element()- Current element's label + deleted flagvec, err := it.Vector()- Current element's vector datait.Advance()- Move to the next elementit.Progress()-(current, total)positionit.Reset()- Restart from the beginning
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 pagesUse 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.AddandResizereturn an error — a mapped index cannot grow in place. MarkDeleted/UnmarkDeletedwork, 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 atCloseunless you persist it.Savemust 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 maySaveonto a file that another index currently has mapped: because everySaveis a temp file + atomicrename, 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-levelbindings.SaveIndexSafewrites 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 andrenameit over the old path — that leaves the old inode mapped and intact for existing readers.
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 concurrentLoadMmap— see either the old complete file or the new one. - The containing directory must be writable and searchable.
Savecreates and renames a temp file inpath'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
pathis replaced, not followed.renamereplaces 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
0666masked by the process umask).
The low-level bindings.SaveIndexSafe bypasses this flow and writes its
destination in place, with none of the guarantees above.
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)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
godirective ingo.mod) - C++ compiler (clang++ or g++)
- c-for-go (auto-installed)
Tested on:
- darwin_arm64 (macOS Apple Silicon)
- linux_amd64 (Linux x86-64)
- linux_arm64 (Linux ARM64)
Same as hnswlib.