Fast nearest neighbour search in arbitrary metric spaces for Go.
nnsearch is a high-performance Go library for finding similar items in any metric space using the graph-based descent method. Unlike vector-only solutions, nnsearch works with any distance function β from edit distance on strings to custom similarity measures on complex objects.
- Works with any metric space β not just vectors! Use edit distance, Jaccard similarity, custom distance functions, and more
- Graph-based descent method β achieves sub-linear query time after index construction
- Pivot-based initialization β smart index bootstrapping using landmark points
- Parallel processing β leverages all CPU cores for index building and querying
- Persistent indexes β save and load indexes using memory-mapped files
- Filter support β exclude items during search without rebuilding the index
- Brute-force fallback β simple baseline for comparison and small datasets
A metric space is any set of objects with a distance function that satisfies:
- Identity:
d(x, x) = 0 - Positivity:
d(x, y) > 0whenx β y - Symmetry:
d(x, y) = d(y, x) - Triangle inequality:
d(x, z) β€ d(x, y) + d(y, z)
This is more general than the familiar Euclidean space. Examples include:
| Data Type | Distance Function |
|---|---|
| Strings | Levenshtein (edit) distance |
| Sets | Jaccard distance |
| Word embeddings | Cosine distance |
| Time series | Dynamic Time Warping |
| Graphs | Graph edit distance |
| DNA sequences | Hamming distance |
Given a query point and a collection of items, find the k items closest to the query. With millions of items, brute-force comparison is too slow.
Traditional tree-based structures (KD-trees, Ball trees) work well in low dimensions but suffer from the curse of dimensionality β as dimensions increase, performance degrades to brute-force.
nnsearch uses a proximity graph approach:
- Index Construction: Build a graph where each node connects to its approximate k-nearest neighbours
- Query: Starting from random entry points, "walk" through the graph toward the query point, always moving to neighbours closer to the target
This achieves sub-linear query time in practice, even in high dimensions.
nnsearch builds the proximity graph using the NN-Descent algorithm:
- Pivot Selection: Choose landmark points that maximize coverage of the space
- Initial Connections: Use pivot-based hashing to connect nearby points
- Local Optimization: Iteratively improve connections β if AβB and AβC, check if BβC improves either's neighbourhood
- Random Exploration: Add random edges to escape local minima
- Make Undirected: Ensure bidirectional connectivity for robust traversal
1. Start at random nodes in the graph
2. Maintain a priority queue of candidates ordered by distance to query
3. For each candidate, examine its neighbours
4. If a neighbour is closer to the query, add it to candidates
5. Stop when no improvement is found
6. Return the k closest points seen
The graph structure means we typically examine only a small fraction of points (often <5%) to find exact or near-exact results.
go get github.com/smhanov/nnsearchpackage main
import (
"fmt"
"github.com/smhanov/nnsearch"
)
// StringSpace implements MetricSpace for strings with edit distance
type StringSpace struct {
words []string
}
func (s *StringSpace) Length() int {
return len(s.words)
}
func (s *StringSpace) At(i int) nnsearch.Point {
return s.words[i]
}
func (s *StringSpace) Distance(p1, p2 nnsearch.Point) float64 {
return float64(editDistance(p1.(string), p2.(string)))
}
func editDistance(s1, s2 string) int {
// ... implementation ...
}
func main() {
// Create your metric space
space := &StringSpace{
words: []string{"apple", "apply", "maple", "application", ...},
}
// Build the index (this takes time but only done once)
index := nnsearch.NewGraphIndex(space)
// Find 5 nearest neighbours to "appel" (misspelling)
results := index.NearestNeighbours("appel", 5, nil)
for _, r := range results {
fmt.Printf("%s (distance: %.0f)\n", r.Point, r.Distance)
}
}// Load word vectors (word2vec format)
wv := nnsearch.OpenWordVecs("vectors.bin")
// Build index
index := nnsearch.NewGraphIndex(wv)
// Find similar words
query := wv.PointFromQuery("king")
results := index.NearestNeighbours(query, 10, nil)// Build and save
index := nnsearch.NewGraphIndex(space)
index.Save("myindex.bin")
// Load later (much faster than rebuilding)
index, err := nnsearch.LoadGraphIndex("myindex.bin", space)// Only return words starting with 'a'
options := &nnsearch.SearchOptions{
Filter: func(pt nnsearch.Point) bool {
return strings.HasPrefix(pt.(string), "a")
},
}
results := index.NearestNeighbours(query, 10, options)ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
options := &nnsearch.SearchOptions{
Ctx: ctx,
}
results := index.NearestNeighbours(query, 10, options)Implement this interface to define your data and distance function:
type MetricSpace interface {
Length() int // Number of points
At(i int) Point // Get point at index
Distance(p1, p2 Point) float64 // Distance between points
}Index interface for nearest neighbour queries:
type SpaceIndex interface {
MetricSpace
NearestNeighbours(target Point, k int, options *SearchOptions) []PointDistance
Write(w io.Writer) (int64, error)
}| Constructor | Description | Use Case |
|---|---|---|
NewGraphIndex(space) |
Graph-based descent index | Large datasets, any metric |
NewBruteForceIndex(space) |
Linear scan | Small datasets, baseline comparison |
// Search multiple indexes in parallel
SearchAll(target, k, options, indices...)
// Compute average/median distance for calibration
ComputeAverageDistance(space, samples)
ComputeMedianDistance(space, samples)
// Distance functions
EuclideanDistance(vec1, vec2 []float32) float64
CosineDistance(vec1, vec2 []float32) float64Find the closest valid words to a misspelled input using edit distance.
Match user queries against a database of names, products, or documents even with typos.
Find similar items (movies, products, articles) based on feature vectors or user behaviour embeddings.
Detect similar documents using document embeddings or n-gram distance.
Find near-duplicate records in databases using custom similarity functions.
Use word embeddings (Word2Vec, GloVe, fastText) to find semantically similar text.
Search for similar DNA/protein sequences using sequence alignment distances.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β User's MetricSpace β
β (implements Length, At, Distance) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Index Construction β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββ β
β β Pivots ββ β Initial ββ β NN-Descent β β
β β Selection β β Graph β β Iterations β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Proximity Graph Index β
β (serializable via FrozenFile) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Query Processing β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββ β
β β Random ββ β Graph ββ β Return Top-K β β
β β Entry β β Descent β β Results β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Batch Index Building: Construction is parallelized across all CPU cores
- Persist Your Index: Use
Save()/LoadGraphIndex()to avoid rebuilding - Memory-Mapped Files: Large indexes are loaded efficiently via mmap
- Tune the Entry Points: The algorithm starts from 10 random points by default
- Pre-compute What You Can: If distances are expensive, consider caching
type SetSpace struct {
sets []map[string]bool
}
func (s *SetSpace) Length() int {
return len(s.sets)
}
func (s *SetSpace) At(i int) nnsearch.Point {
return s.sets[i]
}
func (s *SetSpace) Distance(p1, p2 nnsearch.Point) float64 {
a := p1.(map[string]bool)
b := p2.(map[string]bool)
intersection := 0
for k := range a {
if b[k] {
intersection++
}
}
union := len(a) + len(b) - intersection
if union == 0 {
return 0
}
return 1.0 - float64(intersection)/float64(union)
}type TimeSeriesSpace struct {
series [][]float64
}
func (t *TimeSeriesSpace) Distance(p1, p2 nnsearch.Point) float64 {
return dynamicTimeWarping(p1.([]float64), p2.([]float64))
}Performance depends heavily on:
- Size of dataset
- Dimensionality / complexity of distance function
- Intrinsic dimensionality of data
Typical results on 100K word vectors:
| Method | Query Time | Recall@10 |
|---|---|---|
| Brute Force | ~50ms | 100% |
| Graph Descent | ~2ms | 98%+ |
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.
- The NN-Descent algorithm is based on the paper "Efficient K-Nearest Neighbor Graph Construction for Generic Similarity Measures" by Dong, Moses, and Li.
- Pivot-based methods inspired by work on LAESA and related algorithms.
For questions, issues, or suggestions, please open an issue on GitHub.
β If you find this library useful, please consider giving it a star on GitHub!