Documentation
¶
Overview ¶
Package vector is an experimental vector-search PROTOTYPE for any-store.
Deprecated: this package is NOT the live vector engine and is not imported by any-store itself (only by cmd/vectorbench). The production vector index is internal/vindex (a btree-resident HNSW). This package is retained only as a benchmarking/recall reference and may be removed.
It explores a few ways to land approximate nearest-neighbour (ANN) search on top of the embedded btree engine, using github.com/coder/hnsw as the algorithmic reference. Three index families are provided:
- Brute: exact flat scan (ground truth / recall baseline).
- HNSW: map-based in-memory graph, a faithful port of coder/hnsw.
- FlatHNSW: an arena/SoA (struct-of-arrays) HNSW that keeps every vector in one contiguous []float32 slab and every adjacency list in flat []uint32 slices — no per-node maps, no per-vector allocations. This is the variant wired into the btree for persistence (see BtreeHNSW).
Distance is computed with SIMD (AVX2/AVX-512 via github.com/viterin/vek, pure-Go assembly, no CGO — the same library coder/hnsw uses) with scalar and hand-unrolled fallbacks kept around purely so the benchmarks can quantify the SIMD win.
Index ¶
- func CosineDistanceSIMD(a, b []float32) float32
- func CosineDistanceScalar(a, b []float32) float32
- func DotDistanceSIMD(a, b []float32) float32
- func DotDistanceScalar(a, b []float32) float32
- func GenMDDataset(n, dim int, seed int64) (vecs [][]float32, ids []uint64, topics []int, avgBytes float64)
- func L2DistanceSIMD(a, b []float32) float32
- func L2DistanceScalar(a, b []float32) float32
- func L2DistanceUnrolled(a, b []float32) float32
- func PagedExists(db *btree.DB, name string) bool
- func PersistPaged(g *FlatHNSW, db *btree.DB, name string, metric Metric) error
- func SIMD() bool
- type Brute
- type BtreeHNSW
- func (b *BtreeHNSW) Add(key uint64, vec []float32)
- func (b *BtreeHNSW) Delete(key uint64) bool
- func (b *BtreeHNSW) Flush() error
- func (b *BtreeHNSW) Len() int
- func (b *BtreeHNSW) Search(query []float32, k int) []SearchResult
- func (b *BtreeHNSW) SetEf(ef int)
- func (b *BtreeHNSW) Stats() WriteStats
- func (b *BtreeHNSW) Update(key uint64, vec []float32) bool
- type DistanceFunc
- type DocFlatHNSW
- func (x *DocFlatHNSW) Add(docID []byte, vec []float32)
- func (x *DocFlatHNSW) Compact()
- func (x *DocFlatHNSW) Delete(docID []byte) bool
- func (x *DocFlatHNSW) DictMemBytes() int
- func (x *DocFlatHNSW) Len() int
- func (x *DocFlatHNSW) MemBytes() int
- func (x *DocFlatHNSW) Search(query []float32, k int) []DocResult
- func (x *DocFlatHNSW) Update(docID []byte, vec []float32) bool
- type DocResult
- type FlatHNSW
- func (h *FlatHNSW) Add(key uint64, vec []float32)
- func (h *FlatHNSW) Compact()
- func (h *FlatHNSW) Delete(key uint64) bool
- func (h *FlatHNSW) DeleteHardRepair(key uint64, replenish bool) bool
- func (h *FlatHNSW) DeletedFraction() float64
- func (h *FlatHNSW) Len() int
- func (h *FlatHNSW) MemBytes() int
- func (h *FlatHNSW) PhysicalLen() int
- func (h *FlatHNSW) Rebuild()
- func (h *FlatHNSW) Search(query []float32, k int) []SearchResult
- func (h *FlatHNSW) Update(key uint64, vec []float32) bool
- func (h *FlatHNSW) UpdateInPlace(key uint64, vec []float32) bool
- type HNSW
- type IDDict
- type Metric
- type PagedHNSW
- type SearchResult
- type WriteStats
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func CosineDistanceSIMD ¶
CosineDistanceSIMD mirrors coder/hnsw's CosineDistance: 1 - cosineSimilarity.
func CosineDistanceScalar ¶
CosineDistanceScalar is a naive cosine distance.
func DotDistanceSIMD ¶
DotDistanceSIMD returns the negated inner product (smaller = closer).
func DotDistanceScalar ¶
DotDistanceScalar is a naive negated inner product.
func GenMDDataset ¶
func GenMDDataset(n, dim int, seed int64) (vecs [][]float32, ids []uint64, topics []int, avgBytes float64)
GenMDDataset builds n synthetic markdown documents, returning their embeddings, sequential doc ids, topic labels, and the average rendered markdown size in bytes.
func L2DistanceSIMD ¶
L2DistanceSIMD returns the Euclidean distance using vectorised kernels.
func L2DistanceScalar ¶
L2DistanceScalar is a naive Euclidean distance.
func L2DistanceUnrolled ¶
L2DistanceUnrolled is a 4-way unrolled Euclidean distance. The compiler can auto-vectorise the independent accumulators on some targets; this sits between the naive scalar and the explicit-SIMD kernels.
func PagedExists ¶
PagedExists reports whether a persisted paged index named name is present.
func PersistPaged ¶
PersistPaged writes g to db under name in the split on-disk form (:topo, :vec, :pmeta) in a single write transaction.
Types ¶
type Brute ¶
type Brute struct {
// contains filtered or unexported fields
}
Brute is an exact k-NN index: it keeps every vector in one contiguous slab and scans all of them per query. It is O(n·dim) per search but always returns the true nearest neighbours, so it doubles as the ground truth for measuring the recall of the approximate (HNSW) indexes.
The contiguous slab (vectors stored back-to-back in a single []float32) is the same arena layout used by FlatHNSW — it keeps distance kernels reading sequential memory and avoids one allocation per vector.
type BtreeHNSW ¶
type BtreeHNSW struct {
// contains filtered or unexported fields
}
BtreeHNSW lands the arena HNSW on top of any-store's embedded btree engine. It keeps the working graph in memory as a FlatHNSW (so search runs at in-memory speed) while durably persisting every node — vector + per-layer adjacency — into two btree namespaces:
<name>:meta one record with the index parameters + entry point <name>:nodes one record per node, keyed by the dense id (8-byte big-endian)
Because ids are dense and big-endian-keyed, a forward cursor over the nodes namespace yields them in id order, which is exactly the order FlatHNSW needs to rebuild its arenas on reload (no re-construction, no re-randomised levels).
Writes are buffered: Add mutates the in-memory graph and records which nodes changed; Flush persists the dirty set + meta in a single write transaction. This batches the (relatively expensive) durable writes the same way a bulk import would.
BtreeHNSW is not safe for concurrent Add/Flush; serialise those externally. Concurrent Search is safe (it only reads the in-memory FlatHNSW).
func OpenBtreeHNSW ¶
OpenBtreeHNSW opens (or creates) a persistent index named name inside db. If the namespaces already hold an index its parameters and dim are taken from the stored meta and the dim/metric arguments are only used when creating a fresh index.
func (*BtreeHNSW) Add ¶
Add inserts a vector into the in-memory graph and records the touched nodes for the next Flush. It does not write to disk.
func (*BtreeHNSW) Delete ¶
Delete tombstones key. Persistence is a single tiny record on the next Flush — the node's own record is retained because it still routes searches.
func (*BtreeHNSW) Flush ¶
Flush durably writes all nodes and tombstones touched since the last Flush, plus the index meta, in a single btree write transaction.
func (*BtreeHNSW) Search ¶
func (b *BtreeHNSW) Search(query []float32, k int) []SearchResult
Search runs an in-memory ANN search over the working graph.
func (*BtreeHNSW) Stats ¶
func (b *BtreeHNSW) Stats() WriteStats
Stats returns accumulated write-amplification telemetry.
type DistanceFunc ¶
DistanceFunc returns the distance between two equal-length vectors. Smaller means closer for every metric exposed here.
type DocFlatHNSW ¶
type DocFlatHNSW struct {
// contains filtered or unexported fields
}
DocFlatHNSW shows how a real any-store vector index composes the two pieces: an IDDict that turns document ids ([]byte) into dense uint32 labels, and a FlatHNSW arena graph keyed by those labels. It is the shape the collection layer would drive from its write transactions.
Labels are stable for the life of a document id (they survive graph Compact, because Compact only reshuffles the graph's *internal* arena ids, never the labels it is keyed by). That stability is what lets the on-disk node records key by label without rewriting them on every compaction.
func NewDocFlatHNSW ¶
func NewDocFlatHNSW(dim int, m Metric, seed int64, capHint int) *DocFlatHNSW
NewDocFlatHNSW creates an empty document-keyed index.
func (*DocFlatHNSW) Add ¶
func (x *DocFlatHNSW) Add(docID []byte, vec []float32)
Add inserts a vector for docID. Re-adding an existing id is a no-op (use Update to change its vector).
func (*DocFlatHNSW) Compact ¶
func (x *DocFlatHNSW) Compact()
Compact reclaims the graph's tombstoned arena space. Dictionary tombstones (just the orphaned id bytes) are left in place — they are tiny next to the vector/adjacency arenas — and labels stay stable across the call.
func (*DocFlatHNSW) Delete ¶
func (x *DocFlatHNSW) Delete(docID []byte) bool
Delete tombstones docID in both the dictionary and the graph.
func (*DocFlatHNSW) DictMemBytes ¶
func (x *DocFlatHNSW) DictMemBytes() int
DictMemBytes isolates the id-dictionary footprint (for the RAM breakdown).
func (*DocFlatHNSW) MemBytes ¶
func (x *DocFlatHNSW) MemBytes() int
MemBytes returns the combined footprint of the graph arenas and the id dictionary.
type DocResult ¶
DocResult is a search hit carrying the document id bytes (as any-store keys documents) instead of an internal label.
type FlatHNSW ¶
type FlatHNSW struct {
M int // max neighbours per node on layers >= 1
M0 int // max neighbours per node on layer 0 (commonly 2*M)
Ml float64 // level generation factor
EfSearch int // candidate-list size at query time
EfConstruction int // candidate-list size at insert time
// contains filtered or unexported fields
}
FlatHNSW is a struct-of-arrays / arena implementation of HNSW. Compared to the map-based HNSW it differs in three memory-conscious ways:
Vectors live in one contiguous []float32 slab (vectors), indexed by a dense uint32 id. No per-vector slice header, no N pointers for the GC to scan, sequential reads for the SIMD distance kernels.
Adjacency lists live in one flat []uint32 arena (links) plus a flat []int32 count arena. Each node's links are appended once at insert time (ids are dense and monotonic, so the arena grows append-only — exactly the access pattern an arena is good at). No map[K]*node per node.
The per-query visited set and the two search heaps are pooled and reused (see flatScratch / visitedList), so a steady-state search allocates nothing.
The same arena layout is what BtreeHNSW serialises to / from disk.
func NewFlatHNSW ¶
NewFlatHNSW creates an empty arena-backed HNSW. seed makes level generation deterministic; pass 0 for a fixed default.
func (*FlatHNSW) Add ¶
Add inserts a vector. If the key already exists the call is ignored; use Update to change an existing key's vector.
func (*FlatHNSW) Compact ¶
func (h *FlatHNSW) Compact()
Compact rebuilds the arenas keeping only live nodes, assigning fresh dense ids and remapping every neighbour id. Edges that pointed at tombstoned nodes are dropped (leaving slightly thinner neighbourhoods — cheap, and usually a negligible recall hit until the deleted fraction is large). This reclaims all tombstone memory and removes tombstones from query navigation, restoring search speed. O(physical_nodes + total_links). Does NOT re-run construction; use Rebuild for that.
func (*FlatHNSW) Delete ¶
Delete tombstones the vector for key. It returns false if the key is absent or already deleted. O(1); reclaim the space later with Compact.
func (*FlatHNSW) DeleteHardRepair ¶
DeleteHardRepair is the locked wrapper around deleteHardRepairLocked.
func (*FlatHNSW) DeletedFraction ¶
DeletedFraction returns tombstones / physical size — the signal for when to Compact. Production systems compact somewhere around 0.1–0.2.
func (*FlatHNSW) MemBytes ¶
MemBytes returns an approximate resident size of the index's backing arenas (vectors + adjacency + bookkeeping), excluding the keyToID map.
func (*FlatHNSW) PhysicalLen ¶
PhysicalLen returns the number of slots in the arenas, including tombstones. PhysicalLen-Len is the reclaimable space waiting for Compact.
func (*FlatHNSW) Rebuild ¶
func (h *FlatHNSW) Rebuild()
Rebuild reconstructs the graph from scratch by re-inserting every live vector. Highest quality (full HNSW construction) and most expensive; use when many updates/deletes have degraded the graph beyond what Compact restores.
func (*FlatHNSW) Search ¶
func (h *FlatHNSW) Search(query []float32, k int) []SearchResult
Search returns the k nearest neighbours, closest-first.
func (*FlatHNSW) Update ¶
Update changes the vector stored for key. It is implemented as tombstone-old + insert-new: the old node keeps acting as a waypoint until Compact reclaims it, while the new node is linked into the graph at a fresh id using the new vector. This is the standard HNSW update because an in-place vector swap would leave both the node's own edges and every incoming edge computed against the stale vector (see UpdateInPlace for that variant and its caveats). Returns false if key does not exist.
func (*FlatHNSW) UpdateInPlace ¶
UpdateInPlace overwrites a node's vector without changing its id or re-running construction. It is cheap (no new node, no re-link of the whole graph) but lower quality: the node's outgoing edges and every incoming edge were chosen for the OLD vector and are not recomputed, so the node can end up poorly placed. Offered for benchmarking against Update; not recommended when the vector moves significantly. Returns false if key is absent or deleted.
type HNSW ¶
type HNSW struct {
// M is the maximum number of neighbours kept per node per layer.
M int
// Ml is the level-generation factor (layer i is ~Ml× the size of i-1).
Ml float64
// EfSearch is the size of the dynamic candidate list during search.
EfSearch int
// contains filtered or unexported fields
}
HNSW is a map-based, fully in-memory Hierarchical Navigable Small World graph. It is a close adaptation of github.com/coder/hnsw with the generic key fixed to uint64 and the distance function pluggable. Adjacency is stored as a map[uint64]*hnswNode per node, which makes deletes cheap but costs an allocation per node and chases pointers during search.
This is the "pure in-memory, idiomatic Go" baseline that FlatHNSW is measured against.
func NewHNSW ¶
NewHNSW builds an empty graph. seed makes level generation deterministic for reproducible benchmarks; pass 0 for a fixed default seed.
type IDDict ¶
type IDDict struct {
// contains filtered or unexported fields
}
idmap.go answers the "how do we link graph nodes to real any-store documents" question.
any-store keys every document by the marshaled bytes of its `id` field (`idVal.MarshalTo`) — an arbitrary, variable-length []byte (a string, int, objectid, …). There is no numeric rowid. An HNSW arena, on the other hand, wants a dense, fixed-width uint32 to index its vector/adjacency slabs.
IDDict bridges the two with **dictionary encoding**: each distinct document id is assigned a dense uint32 *label* (a monotonic counter). The label is what the graph stores; the document id is recovered from the label only when results are returned.
Two ways to keep the label -> id-bytes reverse mapping:
[][]byte one slice header (24 B on 64-bit) + one backing array
PER id -> 24 B/id of pure overhead, N heap objects for
the GC to scan, ids scattered across the heap.
flat arena (this) all id bytes concatenated in one []byte, plus one
[]uint32 of end-offsets -> 4 B/id of overhead, exactly
two heap objects total, ids contiguous.
The arena form is the one any-store should use; the benchmark (BenchmarkIDMapMem) quantifies the gap.
func (*IDDict) Delete ¶
Delete tombstones the label for id (the arena bytes stay until Compact). The forward entry is removed so the id can be re-Interned to a fresh label.
func (*IDDict) ID ¶
ID returns the document-id bytes for a label. The returned slice aliases the arena; copy it if you need to retain it past the next mutation.
func (*IDDict) Intern ¶
Intern returns the label for id, assigning a fresh one if unseen. isNew reports whether a new label was allocated. The id bytes are copied into the arena, so the caller may reuse its buffer.
type Metric ¶
type Metric uint8
Metric selects how the distance between two vectors is measured.
const ( // L2 is the (squared-free) Euclidean distance. Smaller is closer. L2 Metric = iota // Cosine is 1 - cosine-similarity. Smaller is closer. Inputs need not be // normalised; the metric normalises internally. Cosine // Dot is the negated inner product (so that "smaller is closer" holds for // all metrics). Use with pre-normalised vectors for cosine-equivalent // ranking at lower cost. Dot )
func (Metric) DistanceFor ¶
func (m Metric) DistanceFor() DistanceFunc
DistanceFor returns the best available distance function for the metric on this CPU. The simd package selects a SIMD or pure-Go kernel internally (the pure-Go L2 is the hand-unrolled loop that beats vek's old fallback on no-AVX2 x86), so there is nothing CPU-specific to branch on here.
type PagedHNSW ¶
type PagedHNSW struct {
// contains filtered or unexported fields
}
PagedHNSW is a prototype of "Option B" from COMPARISON_pgvector.md: instead of holding every vector in a contiguous RAM arena, it keeps only the graph *topology* (adjacency + levels + entry point) resident and reads each node's vector from the btree on demand during traversal — paging it through the same page cache (pcache) the btree already maintains. This is the DiskANN-lite / pgvector residency model: RAM scales with the graph, not the vectors.
Memory math (per node): the topology is ~M0*4 = 128 B; a vector is dim*4 B (512 B at dim=128, 6 KB at dim=1536). So paging the vectors keeps ~80% (dim 128) to ~98% (dim 1536) of the data on disk while the navigable graph stays in RAM. The cost is one btree point-lookup per visited node instead of one array index — that overhead is exactly what TestPagedVsMemory measures.
This prototype borrows an existing FlatHNSW purely for its in-RAM topology (neighbors/level/entry/deleted); the vectors it reads come from the btree, not from the FlatHNSW slab. Search is single-threaded (one reusable scratch).
func AttachPaged ¶
AttachPaged wraps an in-RAM FlatHNSW with an existing on-disk :vec namespace (already populated, e.g. by PersistPaged) without writing anything. The returned index can do pure paged Search AND the route-in-RAM SearchHybrid, because its topology source g still holds the full vectors in RAM.
func BuildPagedFromFlat ¶
BuildPagedFromFlat persists g's vectors into a <name>:vec btree namespace and returns a PagedHNSW that traverses g's topology while reading vectors from the btree. g's own vector slab can be released afterwards (the paged index does not use it).
func (*PagedHNSW) PhysLen ¶
PhysLen returns the number of nodes (live + tombstoned) in the loaded graph.
func (*PagedHNSW) Search ¶
func (p *PagedHNSW) Search(query []float32, k int) ([]SearchResult, error)
Search runs the HNSW query reading vectors from the btree. It opens one read transaction for the query (the realistic unit) so the traversal sees a consistent snapshot and benefits from the reader's page cache.
func (*PagedHNSW) SearchHybrid ¶
func (p *PagedHNSW) SearchHybrid(query []float32, k int) ([]SearchResult, error)
SearchHybrid models the production answer (DiskANN / pgvector-with-rescore): ROUTE the graph using an in-RAM vector copy (here g's slab; in a real build a quantized int8/binary slab a quarter/thirtieth the size), then page only the final ef candidates' full vectors from the btree to RERANK. This collapses the ~1900 btree reads/query of pure paging down to ef reads/query, so latency returns to near in-memory while RAM stays at (routing-slab + topology).
Because the prototype reuses the same float32 vectors for routing and rerank, the *results* are identical to Search; what it isolates is the latency/Get profile of the hybrid (in-RAM routing + ef paged rerank reads).
func (*PagedHNSW) TopologyBytes ¶
TopologyBytes approximates the resident RAM of the paged index: the graph arenas WITHOUT the vector slab (the whole point — vectors live on disk).
type SearchResult ¶
SearchResult is one hit returned by a search: the caller's key and its distance to the query (smaller = closer).
type WriteStats ¶
type WriteStats struct {
Flushes int
RecordsWritten int // total btree Put calls (nodes + tombstones + meta)
NodeRecords int // node Puts only
Tombstones int // tombstone Puts
BytesWritten int // total payload bytes
VectorBytes int // of node payloads, the vector portion
AdjacencyBytes int // of node payloads, the adjacency portion
}
WriteStats accumulates write-amplification telemetry. RecordsWritten counts btree Put calls; the byte counters split node-record payload into the (large, rarely-changing) vector part and the (small, churning) adjacency part — the split that motivates storing them in separate namespaces.