Concepts graph 2 min read
Building the HNSW Graph
Layers, M, and ef_construction: how vectors join a hierarchical navigable small world graph.
Layers and greedy descent
Hierarchical navigable small world graphs give Jasper approximate nearest-neighbor search that scales sub-linearly with collection size, at the cost of never being perfectly exact. In exchange for a small, tunable amount of recall, queries touch a tiny fraction of the index instead of every row.
Every inserted vector is assigned a maximum layer at random, drawn from an exponentially decaying distribution — most vectors land only on the base layer, a few climb several layers up. The top layers are sparse long-range shortcuts; the base layer is dense and holds every vector in the collection. A query starts at the single entry point on the topmost layer, greedily walks toward the target on each layer, and drops down a layer each time it can't improve further, until it runs the final, most thorough search on the base layer.
The two build-time knobs
Two parameters shape the graph at build time. M is the maximum number of neighbors each vector keeps per layer — larger graphs give better recall but cost more memory and slower inserts. ef_construction controls how wide the candidate list is while the graph decides which neighbors to keep for a new vector — larger values build a better-connected graph at the cost of slower inserts.
Setting them in code
use jasper::{Index, IndexOptions, DistanceMetric};
// M=16 and ef_construction=200 is a reasonable default for
// 768-dimensional embeddings up to a few tens of millions of rows.
let opts = IndexOptions::new(768, DistanceMetric::Cosine)
.with_m(16)
.with_ef_construction(200)
.with_max_layers(6);
let index = Index::create("catalog.jasper", opts)?;
println!("entry point layer: {}", index.stats().top_layer);
import jasper
index = jasper.Index.create(
"catalog.jasper",
dim=768,
metric="cosine",
m=16,
ef_construction=200,
max_layers=6,
)
stats = index.stats()
print("entry point layer:", stats.top_layer)
Raising M from 16 to 32 roughly doubles the graph's memory footprint and typically buys one to two additional points of recall at a fixed ef_search — worth it for collections where every query matters, rarely worth it once you're already above 98% recall. The query-tuning chapter covers the matching search-time parameter, ef_search, which you can change per query without rebuilding anything.