| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| use crate::distance::{ |
| fvec_distance, fvec_distance_with_norms, fvec_norm_l2sqr, MetricType, QueryDistance, |
| }; |
| use rayon::prelude::*; |
| use std::cmp::Reverse; |
| use std::collections::BinaryHeap; |
| use std::io; |
| use std::sync::RwLock; |
| |
| // Parallel insertion pays off once an IVF list is large enough to amortize |
| // lock and per-worker visited-set setup. Smaller lists stay on the lean |
| // sequential path to avoid nested Rayon overhead. |
| const PARALLEL_BUILD_MIN_N: usize = 5_000; |
| |
| #[derive(Debug, Clone, Copy)] |
| pub struct HnswBuildParams { |
| pub m: usize, |
| pub ef_construction: usize, |
| pub max_level: usize, |
| } |
| |
| impl Default for HnswBuildParams { |
| fn default() -> Self { |
| Self { |
| m: 20, |
| ef_construction: 150, |
| max_level: 7, |
| } |
| } |
| } |
| |
| impl HnswBuildParams { |
| pub fn sanitized(self) -> Self { |
| Self { |
| m: self.m.max(1), |
| ef_construction: self.ef_construction.max(1), |
| max_level: self.max_level.max(1), |
| } |
| } |
| } |
| |
| #[derive(Debug, Clone)] |
| pub struct HnswGraph { |
| d: usize, |
| metric: MetricType, |
| vectors: Vec<f32>, |
| vector_norms: Option<Vec<f32>>, |
| levels: Vec<usize>, |
| neighbors: Vec<Vec<Vec<usize>>>, |
| entry_point: usize, |
| max_observed_level: usize, |
| params: HnswBuildParams, |
| } |
| |
| impl HnswGraph { |
| pub fn build( |
| vectors: &[f32], |
| n: usize, |
| d: usize, |
| metric: MetricType, |
| params: HnswBuildParams, |
| ) -> io::Result<Self> { |
| let expected_len = n.checked_mul(d).ok_or_else(|| { |
| io::Error::new(io::ErrorKind::InvalidInput, "n * dimension overflows usize") |
| })?; |
| if vectors.len() < expected_len { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidInput, |
| format!( |
| "vector data length {} is shorter than n*d {}", |
| vectors.len(), |
| expected_len |
| ), |
| )); |
| } |
| |
| Self::build_owned(vectors[..expected_len].to_vec(), n, d, metric, params) |
| } |
| |
| pub(crate) fn build_owned( |
| vectors: Vec<f32>, |
| n: usize, |
| d: usize, |
| metric: MetricType, |
| params: HnswBuildParams, |
| ) -> io::Result<Self> { |
| let expected_len = n.checked_mul(d).ok_or_else(|| { |
| io::Error::new(io::ErrorKind::InvalidInput, "n * dimension overflows usize") |
| })?; |
| if vectors.len() != expected_len { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidInput, |
| format!( |
| "vector data length {} does not match n*d {}", |
| vectors.len(), |
| expected_len |
| ), |
| )); |
| } |
| |
| let params = params.sanitized(); |
| if n >= PARALLEL_BUILD_MIN_N { |
| return Ok(Self::build_parallel(vectors, n, d, metric, params)); |
| } |
| |
| let vector_norms = vector_norms_for(metric, &vectors, n, d); |
| let mut graph = HnswGraph { |
| d, |
| metric, |
| vectors, |
| vector_norms, |
| levels: Vec::with_capacity(n), |
| neighbors: Vec::with_capacity(n), |
| entry_point: 0, |
| max_observed_level: 0, |
| params, |
| }; |
| |
| let mut workspace = HnswBuildWorkspace::new(n, params.ef_construction); |
| for node in 0..n { |
| graph.insert(node, &mut workspace); |
| } |
| Ok(graph) |
| } |
| |
| fn build_parallel( |
| vectors: Vec<f32>, |
| n: usize, |
| d: usize, |
| metric: MetricType, |
| params: HnswBuildParams, |
| ) -> Self { |
| let vector_norms = vector_norms_for(metric, &vectors, n, d); |
| let levels = parallel_build_levels(n, params); |
| let max_observed_level = levels.iter().copied().max().unwrap_or(0); |
| let nodes = levels |
| .iter() |
| .map(|&level| RwLock::new(ParallelBuildNode::new(level))) |
| .collect::<Vec<_>>(); |
| |
| { |
| let builder = ParallelHnswBuilder { |
| d, |
| metric, |
| vectors: &vectors, |
| vector_norms: vector_norms.as_deref(), |
| levels: &levels, |
| nodes: &nodes, |
| params, |
| entry_point: 0, |
| max_observed_level, |
| }; |
| (1..n).into_par_iter().for_each_init( |
| || HnswBuildWorkspace::new(n, params.ef_construction), |
| |workspace, node| builder.insert(node, workspace), |
| ); |
| } |
| |
| let neighbors = nodes |
| .into_iter() |
| .map(|node| { |
| node.into_inner() |
| .expect("parallel HNSW builder lock poisoned") |
| .levels |
| .into_iter() |
| .map(|level| level.into_iter().map(|neighbor| neighbor.id).collect()) |
| .collect() |
| }) |
| .collect(); |
| |
| Self { |
| d, |
| metric, |
| vectors, |
| vector_norms, |
| levels, |
| neighbors, |
| entry_point: 0, |
| max_observed_level, |
| params, |
| } |
| } |
| |
| #[allow(clippy::too_many_arguments)] |
| pub(crate) fn from_parts( |
| vectors: Vec<f32>, |
| n: usize, |
| d: usize, |
| metric: MetricType, |
| levels: Vec<usize>, |
| neighbors: Vec<Vec<Vec<usize>>>, |
| entry_point: usize, |
| max_observed_level: usize, |
| params: HnswBuildParams, |
| ) -> io::Result<Self> { |
| let expected_len = n.checked_mul(d).ok_or_else(|| { |
| io::Error::new(io::ErrorKind::InvalidInput, "n * dimension overflows usize") |
| })?; |
| if vectors.len() != expected_len { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| format!( |
| "graph vector length {} does not match n*d {}", |
| vectors.len(), |
| expected_len |
| ), |
| )); |
| } |
| if levels.len() != n || neighbors.len() != n { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| "graph level metadata does not match vector count", |
| )); |
| } |
| let vector_norms = vector_norms_for(metric, &vectors, n, d); |
| if n == 0 { |
| return Ok(Self { |
| d, |
| metric, |
| vectors, |
| vector_norms, |
| levels, |
| neighbors, |
| entry_point: 0, |
| max_observed_level: 0, |
| params: params.sanitized(), |
| }); |
| } |
| if entry_point >= n { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| format!("graph entry point {} out of range {}", entry_point, n), |
| )); |
| } |
| let observed = levels.iter().copied().max().unwrap_or(0); |
| if max_observed_level != observed { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| format!( |
| "graph max level {} does not match observed {}", |
| max_observed_level, observed |
| ), |
| )); |
| } |
| if levels[entry_point] < max_observed_level { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| "graph entry point does not reach max observed level", |
| )); |
| } |
| for node in 0..n { |
| if neighbors[node].len() != levels[node] + 1 { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| format!("graph node {} has invalid level adjacency", node), |
| )); |
| } |
| for (level, level_neighbors) in neighbors[node].iter().enumerate() { |
| for &neighbor in level_neighbors { |
| if neighbor >= n || levels[neighbor] < level { |
| return Err(io::Error::new( |
| io::ErrorKind::InvalidData, |
| format!( |
| "graph edge {} -> {} at level {} is invalid", |
| node, neighbor, level |
| ), |
| )); |
| } |
| } |
| } |
| } |
| Ok(Self { |
| d, |
| metric, |
| vectors, |
| vector_norms, |
| levels, |
| neighbors, |
| entry_point, |
| max_observed_level, |
| params: params.sanitized(), |
| }) |
| } |
| |
| pub fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<(usize, f32)> { |
| let mut workspace = HnswSearchWorkspace::new(ef.max(k)); |
| self.search_with_reusable_workspace(query, k, ef, &mut workspace) |
| .to_vec() |
| } |
| |
| pub(crate) fn search_with_reusable_workspace<'a>( |
| &self, |
| query: &[f32], |
| k: usize, |
| ef: usize, |
| workspace: &'a mut HnswSearchWorkspace, |
| ) -> &'a [(usize, f32)] { |
| workspace.output_pairs.clear(); |
| if self.levels.is_empty() || k == 0 { |
| return &workspace.output_pairs; |
| } |
| let ef = ef.max(k); |
| workspace.prepare(self.levels.len(), ef); |
| |
| let query_distance = QueryDistance::new(query, self.metric); |
| let mut ep = self.entry_point; |
| let mut ep_dist = self.distance_to_query(&query_distance, ep); |
| for level in (1..=self.max_observed_level).rev() { |
| let (next, dist) = self.greedy_search_query(&query_distance, ep, ep_dist, level); |
| ep = next; |
| ep_dist = dist; |
| } |
| |
| let current_mark = workspace.visit_mark; |
| self.search_layer_query_into(&query_distance, ep, ef, 0, current_mark, workspace); |
| workspace.visit_mark = advance_visit_mark(&mut workspace.visited, current_mark); |
| workspace.output_pairs.extend( |
| workspace |
| .output |
| .iter() |
| .take(k) |
| .map(|node| (node.id, node.dist)), |
| ); |
| &workspace.output_pairs |
| } |
| |
| pub fn len(&self) -> usize { |
| self.levels.len() |
| } |
| |
| pub fn is_empty(&self) -> bool { |
| self.levels.is_empty() |
| } |
| |
| pub fn max_degree(&self) -> usize { |
| self.neighbors |
| .iter() |
| .flat_map(|levels| levels.iter().map(Vec::len)) |
| .max() |
| .unwrap_or(0) |
| } |
| |
| pub(crate) fn vectors(&self) -> &[f32] { |
| &self.vectors |
| } |
| |
| pub(crate) fn levels(&self) -> &[usize] { |
| &self.levels |
| } |
| |
| pub(crate) fn neighbors(&self) -> &[Vec<Vec<usize>>] { |
| &self.neighbors |
| } |
| |
| pub(crate) fn entry_point(&self) -> usize { |
| self.entry_point |
| } |
| |
| pub(crate) fn max_observed_level(&self) -> usize { |
| self.max_observed_level |
| } |
| |
| fn insert(&mut self, node: usize, workspace: &mut HnswBuildWorkspace) { |
| let level = random_level(node, self.params.m, self.params.max_level); |
| self.levels.push(level); |
| self.neighbors.push(vec![Vec::new(); level + 1]); |
| |
| if node == 0 { |
| self.entry_point = 0; |
| self.max_observed_level = level; |
| return; |
| } |
| |
| let mut ep = self.entry_point; |
| let mut ep_dist = self.distance_between(node, ep); |
| |
| for layer in ((level + 1)..=self.max_observed_level).rev() { |
| let (next, dist) = self.greedy_search_node(node, ep, ep_dist, layer); |
| ep = next; |
| ep_dist = dist; |
| } |
| |
| for layer in (0..=level.min(self.max_observed_level)).rev() { |
| self.search_layer_node_with_workspace(node, ep, layer, workspace); |
| let next_ep = workspace.output.first().map(|candidate| candidate.id); |
| let selected = workspace |
| .select_output_neighbors_by(self.max_neighbors(layer), |candidate, neighbor| { |
| self.distance_between(candidate, neighbor) |
| }); |
| self.connect_selected(node, selected, layer); |
| if let Some(best) = next_ep { |
| ep = best; |
| } |
| } |
| |
| if level > self.max_observed_level { |
| self.entry_point = node; |
| self.max_observed_level = level; |
| } |
| } |
| |
| fn connect_selected(&mut self, node: usize, selected: &[ScoredNode], level: usize) { |
| let node_neighbors = &mut self.neighbors[node][level]; |
| node_neighbors.clear(); |
| node_neighbors.extend(selected.iter().map(|neighbor| neighbor.id)); |
| |
| for neighbor in selected { |
| let neighbor_id = neighbor.id; |
| if level < self.neighbors[neighbor_id].len() |
| && !self.neighbors[neighbor_id][level].contains(&node) |
| { |
| self.connect_reverse(node, neighbor_id, neighbor.dist, level); |
| } |
| } |
| } |
| |
| fn connect_reverse(&mut self, node: usize, neighbor: usize, distance: f32, level: usize) { |
| let max_neighbors = self.max_neighbors(level); |
| { |
| let neighbors = &self.neighbors[neighbor][level]; |
| if neighbors.len() >= max_neighbors |
| && !neighbors |
| .iter() |
| .any(|&existing| distance < self.distance_between(neighbor, existing)) |
| { |
| return; |
| } |
| } |
| |
| self.neighbors[neighbor][level].push(node); |
| if self.neighbors[neighbor][level].len() > max_neighbors { |
| let pruned = self.pruned_neighbors(neighbor, level, max_neighbors); |
| self.neighbors[neighbor][level] = pruned; |
| } |
| } |
| |
| fn pruned_neighbors(&self, node: usize, level: usize, max_neighbors: usize) -> Vec<usize> { |
| let neighbors = &self.neighbors[node][level]; |
| if neighbors.len() <= max_neighbors { |
| return neighbors.clone(); |
| } |
| |
| let ranked: Vec<ScoredNode> = neighbors |
| .iter() |
| .map(|&id| ScoredNode { |
| id, |
| dist: self.distance_between(node, id), |
| }) |
| .collect(); |
| self.select_neighbors(ranked, max_neighbors) |
| .into_iter() |
| .map(|neighbor| neighbor.id) |
| .collect() |
| } |
| |
| fn select_neighbors( |
| &self, |
| mut candidates: Vec<ScoredNode>, |
| max_neighbors: usize, |
| ) -> Vec<ScoredNode> { |
| candidates.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); |
| self.select_neighbors_sorted(&candidates, max_neighbors) |
| } |
| |
| fn select_neighbors_sorted( |
| &self, |
| candidates: &[ScoredNode], |
| max_neighbors: usize, |
| ) -> Vec<ScoredNode> { |
| let mut selected = Vec::with_capacity(max_neighbors.min(candidates.len())); |
| select_neighbors_sorted_into(candidates, max_neighbors, &mut selected, |a, b| { |
| self.distance_between(a, b) |
| }); |
| selected |
| } |
| |
| fn greedy_search_query( |
| &self, |
| distance: &QueryDistance<'_>, |
| mut current: usize, |
| mut current_dist: f32, |
| level: usize, |
| ) -> (usize, f32) { |
| loop { |
| let mut best = current; |
| let mut best_dist = current_dist; |
| for &neighbor in self.neighbors_at(current, level) { |
| let dist = self.distance_to_query(distance, neighbor); |
| if dist < best_dist { |
| best = neighbor; |
| best_dist = dist; |
| } |
| } |
| if best == current { |
| return (current, current_dist); |
| } |
| current = best; |
| current_dist = best_dist; |
| } |
| } |
| |
| fn greedy_search_node( |
| &self, |
| node: usize, |
| mut current: usize, |
| mut current_dist: f32, |
| level: usize, |
| ) -> (usize, f32) { |
| loop { |
| let mut best = current; |
| let mut best_dist = current_dist; |
| for &neighbor in self.neighbors_at(current, level) { |
| let dist = self.distance_between(node, neighbor); |
| if dist < best_dist { |
| best = neighbor; |
| best_dist = dist; |
| } |
| } |
| if best == current { |
| return (current, current_dist); |
| } |
| current = best; |
| current_dist = best_dist; |
| } |
| } |
| |
| fn search_layer_query_into( |
| &self, |
| distance: &QueryDistance<'_>, |
| entry: usize, |
| ef: usize, |
| level: usize, |
| visit_mark: usize, |
| workspace: &mut HnswSearchWorkspace, |
| ) { |
| self.search_layer_into( |
| entry, |
| ef, |
| level, |
| &mut workspace.visited, |
| visit_mark, |
| &mut workspace.candidates, |
| &mut workspace.results, |
| &mut workspace.output, |
| |id| self.distance_to_query(distance, id), |
| ); |
| } |
| |
| fn search_layer_node_with_workspace( |
| &self, |
| node: usize, |
| entry: usize, |
| level: usize, |
| workspace: &mut HnswBuildWorkspace, |
| ) { |
| let visit_mark = workspace.visit_mark; |
| self.search_layer_into( |
| entry, |
| self.params.ef_construction, |
| level, |
| &mut workspace.visited, |
| visit_mark, |
| &mut workspace.candidates, |
| &mut workspace.results, |
| &mut workspace.output, |
| |id| self.distance_between(node, id), |
| ); |
| workspace.visit_mark = advance_visit_mark(&mut workspace.visited, visit_mark); |
| } |
| |
| #[allow(clippy::too_many_arguments)] |
| fn search_layer_into( |
| &self, |
| entry: usize, |
| ef: usize, |
| level: usize, |
| visited: &mut [usize], |
| visit_mark: usize, |
| candidates: &mut BinaryHeap<Reverse<HeapNode>>, |
| results: &mut BinaryHeap<HeapNode>, |
| output: &mut Vec<ScoredNode>, |
| mut distance: impl FnMut(usize) -> f32, |
| ) { |
| candidates.clear(); |
| results.clear(); |
| output.clear(); |
| |
| let entry_dist = distance(entry); |
| visited[entry] = visit_mark; |
| |
| candidates.push(Reverse(HeapNode { |
| id: entry, |
| dist: entry_dist, |
| })); |
| |
| results.push(HeapNode { |
| id: entry, |
| dist: entry_dist, |
| }); |
| |
| while let Some(Reverse(current)) = candidates.pop() { |
| let worst = results |
| .peek() |
| .map(|node| node.dist) |
| .unwrap_or(f32::INFINITY); |
| if current.dist > worst && results.len() >= ef { |
| break; |
| } |
| |
| for &neighbor in self.neighbors_at(current.id, level) { |
| if visited[neighbor] == visit_mark { |
| continue; |
| } |
| visited[neighbor] = visit_mark; |
| let dist = distance(neighbor); |
| let worst = results |
| .peek() |
| .map(|node| node.dist) |
| .unwrap_or(f32::INFINITY); |
| if results.len() < ef || dist < worst { |
| candidates.push(Reverse(HeapNode { id: neighbor, dist })); |
| results.push(HeapNode { id: neighbor, dist }); |
| if results.len() > ef { |
| results.pop(); |
| } |
| } |
| } |
| } |
| |
| output.extend(results.drain().map(|node| ScoredNode { |
| id: node.id, |
| dist: node.dist, |
| })); |
| output.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); |
| } |
| |
| fn max_neighbors(&self, level: usize) -> usize { |
| if level == 0 { |
| self.params.m * 2 |
| } else { |
| self.params.m |
| } |
| } |
| |
| fn neighbors_at(&self, node: usize, level: usize) -> &[usize] { |
| self.neighbors |
| .get(node) |
| .and_then(|levels| levels.get(level)) |
| .map(Vec::as_slice) |
| .unwrap_or(&[]) |
| } |
| |
| fn distance_between(&self, a: usize, b: usize) -> f32 { |
| let va = &self.vectors[a * self.d..(a + 1) * self.d]; |
| let vb = &self.vectors[b * self.d..(b + 1) * self.d]; |
| match self.metric { |
| MetricType::Cosine => fvec_distance_with_norms( |
| va, |
| vb, |
| self.metric, |
| self.vector_norm(a), |
| self.vector_norm(b), |
| ), |
| _ => fvec_distance(va, vb, self.metric), |
| } |
| } |
| |
| fn distance_to_query(&self, query_distance: &QueryDistance<'_>, id: usize) -> f32 { |
| let vector = &self.vectors[id * self.d..(id + 1) * self.d]; |
| query_distance.distance_to(vector, self.vector_norms.as_ref().map(|norms| norms[id])) |
| } |
| |
| fn vector_norm(&self, id: usize) -> f32 { |
| self.vector_norms |
| .as_ref() |
| .map(|norms| norms[id]) |
| .unwrap_or_else(|| { |
| fvec_norm_l2sqr(&self.vectors[id * self.d..(id + 1) * self.d]).sqrt() |
| }) |
| } |
| } |
| |
| #[derive(Debug, Clone, Copy)] |
| struct ScoredNode { |
| id: usize, |
| dist: f32, |
| } |
| |
| #[derive(Debug, Clone, Copy, PartialEq)] |
| struct HeapNode { |
| id: usize, |
| dist: f32, |
| } |
| |
| impl Eq for HeapNode {} |
| |
| impl PartialOrd for HeapNode { |
| fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { |
| Some(self.cmp(other)) |
| } |
| } |
| |
| impl Ord for HeapNode { |
| fn cmp(&self, other: &Self) -> std::cmp::Ordering { |
| self.dist.total_cmp(&other.dist) |
| } |
| } |
| |
| pub(crate) struct HnswSearchWorkspace { |
| visited: Vec<usize>, |
| visit_mark: usize, |
| candidates: BinaryHeap<Reverse<HeapNode>>, |
| results: BinaryHeap<HeapNode>, |
| output: Vec<ScoredNode>, |
| output_pairs: Vec<(usize, f32)>, |
| } |
| |
| impl HnswSearchWorkspace { |
| pub(crate) fn new(ef: usize) -> Self { |
| Self { |
| visited: Vec::new(), |
| visit_mark: 1, |
| candidates: BinaryHeap::with_capacity(ef), |
| results: BinaryHeap::with_capacity(ef), |
| output: Vec::with_capacity(ef), |
| output_pairs: Vec::with_capacity(ef), |
| } |
| } |
| |
| fn prepare(&mut self, graph_len: usize, ef: usize) { |
| if self.visited.len() < graph_len { |
| self.visited.resize(graph_len, 0); |
| } |
| self.candidates |
| .reserve(ef.saturating_sub(self.candidates.capacity())); |
| self.results |
| .reserve(ef.saturating_sub(self.results.capacity())); |
| self.output |
| .reserve(ef.saturating_sub(self.output.capacity())); |
| self.output_pairs |
| .reserve(ef.saturating_sub(self.output_pairs.capacity())); |
| } |
| } |
| |
| struct HnswBuildWorkspace { |
| visited: Vec<usize>, |
| visit_mark: usize, |
| candidates: BinaryHeap<Reverse<HeapNode>>, |
| results: BinaryHeap<HeapNode>, |
| output: Vec<ScoredNode>, |
| selected: Vec<ScoredNode>, |
| } |
| |
| impl HnswBuildWorkspace { |
| fn new(n: usize, ef_construction: usize) -> Self { |
| Self { |
| visited: vec![0; n], |
| visit_mark: 1, |
| candidates: BinaryHeap::with_capacity(ef_construction), |
| results: BinaryHeap::with_capacity(ef_construction), |
| output: Vec::with_capacity(ef_construction), |
| selected: Vec::new(), |
| } |
| } |
| |
| fn select_output_neighbors_by( |
| &mut self, |
| max_neighbors: usize, |
| distance_between: impl FnMut(usize, usize) -> f32, |
| ) -> &[ScoredNode] { |
| select_neighbors_sorted_into( |
| &self.output, |
| max_neighbors, |
| &mut self.selected, |
| distance_between, |
| ); |
| &self.selected |
| } |
| } |
| |
| struct ParallelHnswBuilder<'a> { |
| d: usize, |
| metric: MetricType, |
| vectors: &'a [f32], |
| vector_norms: Option<&'a [f32]>, |
| levels: &'a [usize], |
| nodes: &'a [RwLock<ParallelBuildNode>], |
| params: HnswBuildParams, |
| entry_point: usize, |
| max_observed_level: usize, |
| } |
| |
| impl ParallelHnswBuilder<'_> { |
| fn insert(&self, node: usize, workspace: &mut HnswBuildWorkspace) { |
| let level = self.nodes[node] |
| .read() |
| .expect("parallel HNSW builder lock poisoned") |
| .level(); |
| let mut ep = self.entry_point; |
| let mut ep_dist = self.distance_between(node, ep); |
| |
| for layer in ((level + 1)..=self.max_observed_level).rev() { |
| let (next, dist) = self.greedy_search_node(node, ep, ep_dist, layer); |
| ep = next; |
| ep_dist = dist; |
| } |
| |
| for layer in (0..=level.min(self.max_observed_level)).rev() { |
| self.search_layer_node(node, ep, layer, workspace); |
| let next_ep = workspace.output.first().map(|candidate| candidate.id); |
| let selected = workspace |
| .select_output_neighbors_by(self.max_neighbors(layer), |candidate, neighbor| { |
| self.distance_between(candidate, neighbor) |
| }); |
| self.connect_selected(node, selected, layer); |
| if let Some(best) = next_ep { |
| ep = best; |
| } |
| } |
| } |
| |
| fn connect_selected(&self, node: usize, selected: &[ScoredNode], level: usize) { |
| { |
| let mut current = self.nodes[node] |
| .write() |
| .expect("parallel HNSW builder lock poisoned"); |
| let current_neighbors = &mut current.levels[level]; |
| current_neighbors.clear(); |
| current_neighbors.extend_from_slice(selected); |
| } |
| |
| for neighbor in selected { |
| let neighbor_id = neighbor.id; |
| if self.levels[neighbor_id] >= level { |
| self.connect_reverse(node, neighbor_id, neighbor.dist, level); |
| } |
| } |
| } |
| |
| fn connect_reverse(&self, node: usize, neighbor: usize, distance: f32, level: usize) { |
| let max_neighbors = self.max_neighbors(level); |
| { |
| let neighbor_node = self.nodes[neighbor] |
| .read() |
| .expect("parallel HNSW builder lock poisoned"); |
| let neighbors = &neighbor_node.levels[level]; |
| if neighbors.iter().any(|existing| existing.id == node) { |
| return; |
| } |
| if neighbors.len() >= max_neighbors |
| && !neighbors.iter().any(|existing| distance < existing.dist) |
| { |
| return; |
| } |
| } |
| |
| let mut neighbor_node = self.nodes[neighbor] |
| .write() |
| .expect("parallel HNSW builder lock poisoned"); |
| let neighbors = &mut neighbor_node.levels[level]; |
| if neighbors.iter().any(|existing| existing.id == node) { |
| return; |
| } |
| neighbors.push(ScoredNode { |
| id: node, |
| dist: distance, |
| }); |
| if neighbors.len() > max_neighbors { |
| let candidates = std::mem::take(neighbors); |
| let pruned = self.select_neighbors(candidates, max_neighbors); |
| *neighbors = pruned; |
| } |
| } |
| |
| fn greedy_search_node( |
| &self, |
| node: usize, |
| mut current: usize, |
| mut current_dist: f32, |
| level: usize, |
| ) -> (usize, f32) { |
| loop { |
| let mut best = current; |
| let mut best_dist = current_dist; |
| self.for_each_neighbor(current, level, |neighbor| { |
| let dist = self.distance_between(node, neighbor); |
| if dist < best_dist { |
| best = neighbor; |
| best_dist = dist; |
| } |
| }); |
| if best == current { |
| return (current, current_dist); |
| } |
| current = best; |
| current_dist = best_dist; |
| } |
| } |
| |
| fn search_layer_node( |
| &self, |
| node: usize, |
| entry: usize, |
| level: usize, |
| workspace: &mut HnswBuildWorkspace, |
| ) { |
| workspace.candidates.clear(); |
| workspace.results.clear(); |
| workspace.output.clear(); |
| |
| let visit_mark = workspace.visit_mark; |
| let entry_dist = self.distance_between(node, entry); |
| workspace.visited[entry] = visit_mark; |
| workspace.candidates.push(Reverse(HeapNode { |
| id: entry, |
| dist: entry_dist, |
| })); |
| workspace.results.push(HeapNode { |
| id: entry, |
| dist: entry_dist, |
| }); |
| |
| while let Some(Reverse(current)) = workspace.candidates.pop() { |
| let worst = workspace |
| .results |
| .peek() |
| .map(|node| node.dist) |
| .unwrap_or(f32::INFINITY); |
| if current.dist > worst && workspace.results.len() >= self.params.ef_construction { |
| break; |
| } |
| |
| self.for_each_neighbor(current.id, level, |neighbor| { |
| if workspace.visited[neighbor] == visit_mark { |
| return; |
| } |
| workspace.visited[neighbor] = visit_mark; |
| let dist = self.distance_between(node, neighbor); |
| let worst = workspace |
| .results |
| .peek() |
| .map(|node| node.dist) |
| .unwrap_or(f32::INFINITY); |
| if workspace.results.len() < self.params.ef_construction || dist < worst { |
| workspace |
| .candidates |
| .push(Reverse(HeapNode { id: neighbor, dist })); |
| workspace.results.push(HeapNode { id: neighbor, dist }); |
| if workspace.results.len() > self.params.ef_construction { |
| workspace.results.pop(); |
| } |
| } |
| }); |
| } |
| |
| workspace |
| .output |
| .extend(workspace.results.drain().map(|node| ScoredNode { |
| id: node.id, |
| dist: node.dist, |
| })); |
| workspace |
| .output |
| .sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); |
| workspace.visit_mark = advance_visit_mark(&mut workspace.visited, visit_mark); |
| } |
| |
| fn select_neighbors( |
| &self, |
| mut candidates: Vec<ScoredNode>, |
| max_neighbors: usize, |
| ) -> Vec<ScoredNode> { |
| candidates.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist)); |
| self.select_neighbors_sorted(&candidates, max_neighbors) |
| } |
| |
| fn select_neighbors_sorted( |
| &self, |
| candidates: &[ScoredNode], |
| max_neighbors: usize, |
| ) -> Vec<ScoredNode> { |
| let mut selected = Vec::with_capacity(max_neighbors.min(candidates.len())); |
| select_neighbors_sorted_into(candidates, max_neighbors, &mut selected, |a, b| { |
| self.distance_between(a, b) |
| }); |
| selected |
| } |
| |
| fn for_each_neighbor(&self, node: usize, level: usize, mut f: impl FnMut(usize)) { |
| let node = self.nodes[node] |
| .read() |
| .expect("parallel HNSW builder lock poisoned"); |
| if let Some(neighbors) = node.levels.get(level) { |
| for neighbor in neighbors { |
| f(neighbor.id); |
| } |
| } |
| } |
| |
| fn max_neighbors(&self, level: usize) -> usize { |
| if level == 0 { |
| self.params.m * 2 |
| } else { |
| self.params.m |
| } |
| } |
| |
| fn distance_between(&self, a: usize, b: usize) -> f32 { |
| let va = &self.vectors[a * self.d..(a + 1) * self.d]; |
| let vb = &self.vectors[b * self.d..(b + 1) * self.d]; |
| match self.metric { |
| MetricType::Cosine => fvec_distance_with_norms( |
| va, |
| vb, |
| self.metric, |
| self.vector_norm(a), |
| self.vector_norm(b), |
| ), |
| _ => fvec_distance(va, vb, self.metric), |
| } |
| } |
| |
| fn vector_norm(&self, id: usize) -> f32 { |
| self.vector_norms.map(|norms| norms[id]).unwrap_or_else(|| { |
| fvec_norm_l2sqr(&self.vectors[id * self.d..(id + 1) * self.d]).sqrt() |
| }) |
| } |
| } |
| |
| struct ParallelBuildNode { |
| levels: Vec<Vec<ScoredNode>>, |
| } |
| |
| impl ParallelBuildNode { |
| fn new(level: usize) -> Self { |
| Self { |
| levels: vec![Vec::new(); level + 1], |
| } |
| } |
| |
| fn level(&self) -> usize { |
| self.levels.len() - 1 |
| } |
| } |
| |
| fn parallel_build_levels(n: usize, params: HnswBuildParams) -> Vec<usize> { |
| let mut levels: Vec<_> = (0..n) |
| .map(|node| random_level(node, params.m, params.max_level)) |
| .collect(); |
| if let Some(first) = levels.first_mut() { |
| // LanceDB keeps the fixed entry point reachable from every configured |
| // layer during parallel build. Mirroring that avoids a serialized |
| // "promote newest max-level node" phase while preserving high-level |
| // search quality. |
| *first = params.max_level - 1; |
| } |
| levels |
| } |
| |
| fn select_neighbors_sorted_into( |
| candidates: &[ScoredNode], |
| max_neighbors: usize, |
| selected: &mut Vec<ScoredNode>, |
| mut distance_between: impl FnMut(usize, usize) -> f32, |
| ) { |
| selected.clear(); |
| if candidates.len() <= max_neighbors { |
| selected.extend_from_slice(candidates); |
| return; |
| } |
| |
| selected.reserve(max_neighbors.saturating_sub(selected.len())); |
| for &candidate in candidates { |
| if selected.len() >= max_neighbors { |
| break; |
| } |
| let closer_to_selected = selected |
| .iter() |
| .any(|neighbor| distance_between(candidate.id, neighbor.id) < candidate.dist); |
| if !closer_to_selected { |
| selected.push(candidate); |
| } |
| } |
| for &candidate in candidates { |
| if selected.len() >= max_neighbors { |
| break; |
| } |
| if !selected.iter().any(|neighbor| neighbor.id == candidate.id) { |
| selected.push(candidate); |
| } |
| } |
| } |
| |
| fn vector_norms_for(metric: MetricType, vectors: &[f32], n: usize, d: usize) -> Option<Vec<f32>> { |
| if metric != MetricType::Cosine { |
| return None; |
| } |
| Some( |
| (0..n) |
| .map(|id| fvec_norm_l2sqr(&vectors[id * d..(id + 1) * d]).sqrt()) |
| .collect(), |
| ) |
| } |
| |
| fn random_level(node: usize, m: usize, max_level: usize) -> usize { |
| if node == 0 || max_level <= 1 { |
| // Keep the first insertion deterministic. Later higher-level nodes replace |
| // the entry point as they appear, while tiny lists naturally stay flat. |
| return 0; |
| } |
| let mut x = splitmix64(node as u64 + 0x9E37_79B9_7F4A_7C15); |
| let mut level = 0; |
| let threshold = (u64::MAX / m.max(2) as u64).max(1); |
| while level + 1 < max_level && x < threshold { |
| level += 1; |
| x = splitmix64(x); |
| } |
| level |
| } |
| |
| fn advance_visit_mark(visited: &mut [usize], visit_mark: usize) -> usize { |
| visit_mark.checked_add(1).unwrap_or_else(|| { |
| visited.fill(0); |
| 1 |
| }) |
| } |
| |
| fn splitmix64(mut x: u64) -> u64 { |
| x = x.wrapping_add(0x9E37_79B9_7F4A_7C15); |
| let mut z = x; |
| z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); |
| z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); |
| z ^ (z >> 31) |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use crate::distance::MetricType; |
| |
| #[test] |
| fn test_hnsw_recalls_query_vector_on_single_partition() { |
| let d = 4; |
| let n = 128; |
| let data: Vec<f32> = (0..n) |
| .flat_map(|i| [i as f32 * 0.01, 1.0, 2.0, 3.0]) |
| .collect(); |
| let params = HnswBuildParams { |
| m: 8, |
| ef_construction: 32, |
| max_level: 6, |
| }; |
| |
| let graph = HnswGraph::build(&data, n, d, MetricType::L2, params).unwrap(); |
| let query_id = 17; |
| let results = graph.search(&data[query_id * d..(query_id + 1) * d], 5, 32); |
| |
| assert_eq!(results[0].0, query_id); |
| assert_eq!(results[0].1, 0.0); |
| } |
| |
| #[test] |
| fn test_hnsw_empty_graph_returns_no_results() { |
| let graph = |
| HnswGraph::build(&[], 0, 4, MetricType::L2, HnswBuildParams::default()).unwrap(); |
| |
| assert!(graph.search(&[0.0, 0.0, 0.0, 0.0], 10, 20).is_empty()); |
| assert!(graph.is_empty()); |
| assert_eq!(graph.len(), 0); |
| assert_eq!(graph.max_degree(), 0); |
| } |
| |
| #[test] |
| fn test_hnsw_build_rejects_short_vector_input() { |
| let err = HnswGraph::build( |
| &[0.0, 1.0, 2.0], |
| 2, |
| 2, |
| MetricType::L2, |
| HnswBuildParams::default(), |
| ) |
| .unwrap_err(); |
| |
| assert_eq!(err.kind(), io::ErrorKind::InvalidInput); |
| assert!(err.to_string().contains("shorter than n*d")); |
| } |
| |
| #[test] |
| fn test_hnsw_respects_neighbor_degree_bound() { |
| let d = 8; |
| let n = 512; |
| let data = generate_clustered_data(n, d, 16); |
| let params = HnswBuildParams { |
| m: 12, |
| ef_construction: 100, |
| max_level: 6, |
| }; |
| |
| let graph = HnswGraph::build(&data, n, d, MetricType::L2, params).unwrap(); |
| |
| assert_eq!(graph.len(), n); |
| assert!(graph.max_degree() <= params.m * 2); |
| } |
| |
| #[test] |
| fn test_hnsw_large_partition_recall_tracks_exact_search() { |
| let d = 16; |
| let n = 4096; |
| let nq = 32; |
| let k = 10; |
| let data = generate_clustered_data(n, d, 32); |
| let params = HnswBuildParams { |
| m: 16, |
| ef_construction: 200, |
| max_level: 7, |
| }; |
| |
| let graph = HnswGraph::build(&data, n, d, MetricType::L2, params).unwrap(); |
| let mut hits = 0usize; |
| for qi in 0..nq { |
| let query = &data[qi * d..(qi + 1) * d]; |
| let expected = exact_topk(&data, n, d, query, k); |
| let actual = graph.search(query, k, 200); |
| hits += actual |
| .iter() |
| .filter(|(id, _)| expected.contains(id)) |
| .count(); |
| } |
| |
| let recall = hits as f32 / (nq * k) as f32; |
| assert!(recall >= 0.95, "recall={}", recall); |
| } |
| |
| #[test] |
| fn test_hnsw_parallel_build_large_partition_recall_tracks_exact_search() { |
| let d = 16; |
| let n = PARALLEL_BUILD_MIN_N + 512; |
| let nq = 32; |
| let k = 10; |
| let data = generate_clustered_data(n, d, 32); |
| let params = HnswBuildParams { |
| m: 16, |
| ef_construction: 200, |
| max_level: 7, |
| }; |
| |
| let graph = HnswGraph::build(&data, n, d, MetricType::L2, params).unwrap(); |
| let mut hits = 0usize; |
| for qi in 0..nq { |
| let query = &data[qi * d..(qi + 1) * d]; |
| let expected = exact_topk(&data, n, d, query, k); |
| let actual = graph.search(query, k, 400); |
| hits += actual |
| .iter() |
| .filter(|(id, _)| expected.contains(id)) |
| .count(); |
| } |
| |
| let recall = hits as f32 / (nq * k) as f32; |
| // Parallel graph construction is schedule-dependent; keep the bar high |
| // enough to catch regressions without making the test flaky. |
| assert!(recall >= 0.90, "recall={}", recall); |
| assert!(graph.max_degree() <= params.m * 2); |
| } |
| |
| #[test] |
| fn test_hnsw_neighbor_selection_backfills_after_diversification() { |
| let d = 1; |
| let data = vec![0.0, 1.0, 2.0, 3.0]; |
| let graph = HnswGraph::build( |
| &data, |
| 4, |
| d, |
| MetricType::L2, |
| HnswBuildParams { |
| m: 2, |
| ef_construction: 4, |
| max_level: 1, |
| }, |
| ) |
| .unwrap(); |
| let candidates = vec![ |
| ScoredNode { id: 1, dist: 1.0 }, |
| ScoredNode { id: 2, dist: 2.0 }, |
| ScoredNode { id: 3, dist: 3.0 }, |
| ]; |
| |
| let selected = graph.select_neighbors(candidates, 3); |
| |
| assert_eq!(selected.len(), 3); |
| } |
| |
| #[test] |
| fn test_hnsw_pruning_keeps_diverse_neighbors() { |
| let graph = HnswGraph::from_parts( |
| vec![0.0, 0.0, 1.0, 0.0, 1.1, 0.0, 0.0, 2.0], |
| 4, |
| 2, |
| MetricType::L2, |
| vec![0, 0, 0, 0], |
| vec![ |
| vec![vec![1, 2, 3]], |
| vec![vec![]], |
| vec![vec![]], |
| vec![vec![]], |
| ], |
| 0, |
| 0, |
| HnswBuildParams::default(), |
| ) |
| .unwrap(); |
| |
| let selected = graph.pruned_neighbors(0, 0, 2); |
| |
| assert_eq!(selected, vec![1, 3]); |
| } |
| |
| #[test] |
| fn test_hnsw_greedy_search_chooses_best_improving_neighbor() { |
| let graph = HnswGraph::from_parts( |
| vec![0.0, 5.0, 2.0], |
| 3, |
| 1, |
| MetricType::L2, |
| vec![0, 0, 0], |
| vec![vec![vec![1, 2]], vec![vec![]], vec![vec![]]], |
| 0, |
| 0, |
| HnswBuildParams::default(), |
| ) |
| .unwrap(); |
| |
| let distance = QueryDistance::new(&[2.0], MetricType::L2); |
| let (next, dist) = graph.greedy_search_query(&distance, 0, 4.0, 0); |
| |
| assert_eq!(next, 2); |
| assert_eq!(dist, 0.0); |
| } |
| |
| #[test] |
| fn test_hnsw_cosine_distance_uses_vector_norms() { |
| let graph = HnswGraph::from_parts( |
| vec![2.0, 0.0, 4.0, 0.0, 0.0, 3.0], |
| 3, |
| 2, |
| MetricType::Cosine, |
| vec![0, 0, 0], |
| vec![vec![vec![]], vec![vec![]], vec![vec![]]], |
| 0, |
| 0, |
| HnswBuildParams::default(), |
| ) |
| .unwrap(); |
| |
| assert!((graph.distance_between(0, 1) - 0.0).abs() < 1e-6); |
| assert!((graph.distance_between(0, 2) - 1.0).abs() < 1e-6); |
| } |
| |
| fn exact_topk(data: &[f32], n: usize, d: usize, query: &[f32], k: usize) -> Vec<usize> { |
| let mut distances: Vec<(f32, usize)> = (0..n) |
| .map(|i| { |
| let vector = &data[i * d..(i + 1) * d]; |
| (fvec_distance(query, vector, MetricType::L2), i) |
| }) |
| .collect(); |
| distances.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); |
| distances[..k].iter().map(|&(_, id)| id).collect() |
| } |
| |
| fn generate_clustered_data(n: usize, d: usize, num_clusters: usize) -> Vec<f32> { |
| let mut data = vec![0.0f32; n * d]; |
| for i in 0..n { |
| let cluster = i % num_clusters; |
| for j in 0..d { |
| data[i * d + j] = cluster as f32 * 20.0 + j as f32 * 0.01 + i as f32 * 0.0001; |
| } |
| } |
| data |
| } |
| } |