From 2a13066a0d76e0cca57be9a3b123ec4556be0e4f Mon Sep 17 00:00:00 2001 From: dimgigov Date: Wed, 6 May 2026 03:25:04 +0300 Subject: [PATCH] feat: real HNSW search with hierarchical graph navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement proper HNSW insert with level-based neighbor linking - Implement searchLayer with greedy beam search - Implement hierarchical search: top-level→level 0 with ef beam - Add bidirectional neighbor pruning (maxM limit) - searchWithFilter now uses HNSW + post-filtering - Achieves ~99% recall@10 on 2K vectors (dim=128) - All 214 tests pass --- src/barabadb/vector/engine.nim | 187 +++++++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 33 deletions(-) diff --git a/src/barabadb/vector/engine.nim b/src/barabadb/vector/engine.nim index 8cab7a1..ed79d3c 100644 --- a/src/barabadb/vector/engine.nim +++ b/src/barabadb/vector/engine.nim @@ -3,6 +3,7 @@ import std/math import std/algorithm import std/random import std/tables +import std/sets import std/monotimes type @@ -47,6 +48,8 @@ type metric*: DistanceMetric dimensions*: int + NodeDist = tuple[dist: float64, id: uint64] + proc cosineDistance*(a, b: Vector): float64 = var dot, normA, normB: float64 for i in 0.. 0: + # Pop closest candidate + var bestIdx = 0 + for i in 1..= ef and current.dist > nearest[^1].dist: + break + + # Explore neighbors at this level + let node = idx.nodes[current.id] + if level < node.neighbors.len: + for neighborId in node.neighbors[level]: + if neighborId notin visited: + visited.incl(neighborId) + let dist = distance(query, idx.nodes[neighborId].vector, metric) + candidates.add((dist, neighborId)) + nearest.add((dist, neighborId)) + nearest.sort(nodeDistCmp) + if nearest.len > ef: + nearest.setLen(ef) + + return nearest + +proc selectNeighbors(idx: HNSWIndex, baseVector: Vector, candidates: seq[NodeDist], + maxNeighbors: int, metric: DistanceMetric): seq[uint64] = + ## Keep only the closest `maxNeighbors` candidates. + var sorted = candidates + sorted.sort(nodeDistCmp) + let n = min(maxNeighbors, sorted.len) + result = newSeq[uint64](n) + for i in 0..= node.neighbors.len or level >= neighbor.neighbors.len: + return + + # Add forward link + if neighborId notin node.neighbors[level]: + node.neighbors[level].add(neighborId) + + # Add backward link + if nodeId notin neighbor.neighbors[level]: + neighbor.neighbors[level].add(nodeId) + + # Prune neighbor's connections if too many + if neighbor.neighbors[level].len > idx.maxM: + var dists: seq[(float64, uint64)] = @[] + for nid in neighbor.neighbors[level]: + dists.add((distance(neighbor.vector, idx.nodes[nid].vector, idx.metric), nid)) + dists.sort(proc(a, b: (float64, uint64)): int = cmp(a[0], b[0])) + neighbor.neighbors[level].setLen(idx.maxM) + for i in 0.. 0: + currEntry = nearest[0].id + + # For each level from min(level, maxLevel) down to 0, find neighbors and link + let topLevel = min(level, idx.maxLevel) + for lc in countdown(topLevel, 0): + let nearest = searchLayer(idx, currEntry, vector, idx.efConstruction, lc, idx.metric) + let neighbors = selectNeighbors(idx, vector, nearest, idx.m, idx.metric) + for neighborId in neighbors: + addBidirectionalLink(idx, id, neighborId, lc) + if nearest.len > 0: + currEntry = nearest[0].id + + # Update entry point if new node has higher level if level > idx.maxLevel: idx.entryPoint = id idx.maxLevel = level @@ -128,17 +231,22 @@ proc search*(idx: HNSWIndex, query: Vector, k: int, if idx.nodes.len == 0: return @[] - var candidates: seq[(uint64, float64)] = @[] - for nodeId, node in idx.nodes: - let dist = distance(query, node.vector, metric) - candidates.add((nodeId, dist)) + var currEntry = idx.entryPoint - candidates.sort(proc(a, b: (uint64, float64)): int = cmp(a[1], b[1])) + # Descend from top level to level 1 + for lc in countdown(idx.maxLevel, 1): + let nearest = searchLayer(idx, currEntry, query, 1, lc, metric) + if nearest.len > 0: + currEntry = nearest[0].id - if candidates.len > k: - candidates = candidates[0.. 0: + currEntry = nearest[0].id - candidates.sort(proc(a, b: (uint64, float64)): int = cmp(a[1], b[1])) - if candidates.len > k: - candidates = candidates[0..= k: + break + + return filtered + +# ---------------------------------------------------------------------- +# IVF-PQ Index (unchanged) +# ---------------------------------------------------------------------- proc newIVFPQIndex*(dimensions: int, nClusters: int = 100, nSubquantizers: int = 8, nBits: int = 8, @@ -222,6 +342,10 @@ proc search*(idx: IVFPQIndex, query: Vector, k: int, nProbe: int = 10, candidates = candidates[0..