Skip to main content

lance_index/vector/hnsw/
builder.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! Builder of Hnsw Graph.
5
6use arrow::array::{AsArray, ListBuilder, UInt32Builder};
7use arrow::compute::concat_batches;
8use arrow::datatypes::{DataType, UInt32Type};
9use arrow_array::{ArrayRef, Float32Array, ListArray, RecordBatch, UInt64Array};
10use crossbeam_queue::ArrayQueue;
11use itertools::Itertools;
12use lance_core::deepsize::DeepSizeOf;
13use lance_core::utils::row_addr_remap::RowAddrRemap;
14
15use lance_core::utils::tokio::get_num_compute_intensive_cpus;
16use lance_linalg::distance::DistanceType;
17use rayon::prelude::*;
18use std::cmp::min;
19use std::collections::{BinaryHeap, HashMap, VecDeque};
20use std::fmt::Debug;
21use std::iter;
22use std::sync::Arc;
23use std::sync::RwLock;
24use std::sync::atomic::{AtomicUsize, Ordering};
25use tracing::instrument;
26
27use lance_core::{Error, Result};
28use rand::{Rng, SeedableRng, rngs::SmallRng};
29use serde::{Deserialize, Serialize};
30
31use super::super::graph::beam_search;
32use super::{
33    HNSW_TYPE, HnswMetadata, VECTOR_ID_COL, VECTOR_ID_FIELD, select_neighbors_heuristic_owned,
34};
35use crate::metrics::MetricsCollector;
36use crate::prefilter::PreFilter;
37use crate::vector::flat::storage::{FlatBinStorage, FlatFloatStorage};
38use crate::vector::graph::builder::GraphBuilderNode;
39use crate::vector::graph::{
40    BorrowingGraph, DISTS_FIELD, Graph, NEIGHBORS_COL, NEIGHBORS_FIELD, OrderedFloat, OrderedNode,
41    VisitedGenerator,
42};
43use crate::vector::graph::{Visited, beam_search_borrowed, greedy_search, greedy_search_borrowed};
44use crate::vector::storage::{DistCalculator, VectorStore};
45use crate::vector::v3::subindex::IvfSubIndex;
46use crate::vector::{Query, VECTOR_RESULT_SCHEMA};
47
48pub const HNSW_METADATA_KEY: &str = "lance:hnsw";
49
50/// Fixed seed for HNSW node-level assignment.
51///
52/// A constant seed makes graph construction reproducible (same data + params =>
53/// same graph), which keeps index builds deterministic and tests stable. Recall
54/// is statistically unaffected — the level distribution is identical, only the
55/// random draws become fixed. Shared by the offline ([`HNSWBuilder`]) and online
56/// ([`super::online::OnlineHnswBuilder`]) builders so both produce comparable graphs.
57pub(crate) const HNSW_LEVEL_RNG_SEED: u64 = 42;
58
59/// Parameters of building HNSW index
60#[derive(Debug, Clone, Serialize, Deserialize, DeepSizeOf)]
61pub struct HnswBuildParams {
62    /// max level ofm
63    pub max_level: u16,
64
65    /// number of connections to establish while inserting new element
66    pub m: usize,
67
68    /// size of the dynamic list for the candidates
69    pub ef_construction: usize,
70
71    /// number of vectors ahead to prefetch while building the graph
72    pub prefetch_distance: Option<usize>,
73}
74
75impl From<&HnswBuildParams> for crate::pb::HnswParameters {
76    fn from(params: &HnswBuildParams) -> Self {
77        Self {
78            max_connections: params.m as u32,
79            construction_ef: params.ef_construction as u32,
80            max_level: params.max_level as u32,
81        }
82    }
83}
84
85impl Default for HnswBuildParams {
86    fn default() -> Self {
87        Self {
88            max_level: 7,
89            m: 20,
90            ef_construction: 150,
91            prefetch_distance: Some(2),
92        }
93    }
94}
95
96impl HnswBuildParams {
97    /// The maximum level of the graph.
98    /// The default value is `8`.
99    pub fn max_level(mut self, max_level: u16) -> Self {
100        self.max_level = max_level;
101        self
102    }
103
104    /// The number of connections to establish while inserting new element
105    /// The default value is `30`.
106    pub fn num_edges(mut self, m: usize) -> Self {
107        self.m = m;
108        self
109    }
110
111    /// Number of candidates to be considered when searching for the nearest neighbors
112    /// during the construction of the graph.
113    ///
114    /// The default value is `100`.
115    pub fn ef_construction(mut self, ef_construction: usize) -> Self {
116        self.ef_construction = ef_construction;
117        self
118    }
119
120    /// Build the HNSW index from the given data.
121    ///
122    /// # Parameters
123    /// - `data`: A FixedSizeList to build the HNSW.
124    /// - `distance_type`: The distance type to use.
125    pub async fn build(self, data: ArrayRef, distance_type: DistanceType) -> Result<HNSW> {
126        let vectors = data.as_fixed_size_list().clone();
127        match (vectors.value_type(), distance_type) {
128            (DataType::UInt8, DistanceType::Hamming) => {
129                let vec_store = Arc::new(FlatBinStorage::new(vectors, distance_type));
130                HNSW::index_vectors(vec_store.as_ref(), self)
131            }
132            (DataType::UInt8, _) => Err(Error::invalid_input(format!(
133                "HNSW only supports hamming distance for UInt8 vectors, got {}",
134                distance_type
135            ))),
136            (_, DistanceType::Hamming) => Err(Error::invalid_input(format!(
137                "HNSW hamming distance only supports UInt8 vectors, got {}",
138                vectors.value_type()
139            ))),
140            _ => {
141                let vec_store = Arc::new(FlatFloatStorage::new(vectors, distance_type));
142                HNSW::index_vectors(vec_store.as_ref(), self)
143            }
144        }
145    }
146}
147
148/// Build a HNSW graph.
149///
150/// Currently, the HNSW graph is fully built in memory.
151///
152/// During the build, the graph is built layer by layer.
153///
154/// Each node in the graph has a global ID which is the index on the base layer.
155#[derive(Clone, DeepSizeOf)]
156pub struct HNSW {
157    inner: Arc<HnswCore>,
158}
159
160struct HnswCore {
161    params: HnswBuildParams,
162    graph: HnswGraph,
163    level_count: Vec<usize>,
164    entry_point: u32,
165    visited_generator_queue: Arc<ArrayQueue<VisitedGenerator>>,
166}
167
168impl DeepSizeOf for HnswCore {
169    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
170        self.params.deep_size_of_children(context)
171            + self.graph.deep_size_of_children(context)
172            + self.level_count.deep_size_of_children(context)
173        // Skipping the visited_generator_queue
174    }
175}
176
177impl HnswCore {
178    fn max_level(&self) -> u16 {
179        self.params.max_level
180    }
181
182    fn num_nodes(&self, level: usize) -> usize {
183        self.level_count[level]
184    }
185}
186
187impl Debug for HNSW {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        write!(f, "HNSW(max_layers: {})", self.inner.max_level() as usize,)
190    }
191}
192
193impl HNSW {
194    /// Construct an HNSW from its constituent parts. Used by the online
195    /// builder when finalizing.
196    pub(crate) fn from_parts(
197        params: HnswBuildParams,
198        nodes: Vec<GraphBuilderNode>,
199        level_count: Vec<usize>,
200        entry_point: u32,
201    ) -> Self {
202        let queue_size = get_num_compute_intensive_cpus().max(1) * 2;
203        let visited_generator_queue = Arc::new(ArrayQueue::new(queue_size));
204        for _ in 0..queue_size {
205            let _ = visited_generator_queue.push(VisitedGenerator::new(0));
206        }
207        Self {
208            inner: Arc::new(HnswCore {
209                params,
210                graph: HnswGraph::Built(Arc::new(nodes)),
211                level_count,
212                entry_point,
213                visited_generator_queue,
214            }),
215        }
216    }
217
218    pub fn empty() -> Self {
219        Self {
220            inner: Arc::new(HnswCore {
221                params: HnswBuildParams::default(),
222                graph: HnswGraph::Built(Arc::new(Vec::new())),
223                level_count: Vec::new(),
224                entry_point: 0,
225                visited_generator_queue: Arc::new(ArrayQueue::new(1)),
226            }),
227        }
228    }
229
230    pub fn len(&self) -> usize {
231        match &self.inner.graph {
232            HnswGraph::Built(nodes) => nodes.len(),
233            // `level_count[0]` is the bottom-level (== total) node count.
234            HnswGraph::Loaded(graph) => graph.level_count[0],
235        }
236    }
237
238    pub fn is_empty(&self) -> bool {
239        self.len() == 0
240    }
241
242    pub fn max_level(&self) -> u16 {
243        self.inner.max_level()
244    }
245
246    pub fn num_nodes(&self, level: usize) -> usize {
247        self.inner.num_nodes(level)
248    }
249
250    /// Returns the in-memory builder nodes, if this graph was freshly built.
251    ///
252    /// A disk-loaded graph is Arrow-backed and has no `GraphBuilderNode`s,
253    /// so this returns `None` for it.
254    pub fn nodes(&self) -> Option<Arc<Vec<GraphBuilderNode>>> {
255        match &self.inner.graph {
256            HnswGraph::Built(nodes) => Some(nodes.clone()),
257            HnswGraph::Loaded(_) => None,
258        }
259    }
260
261    #[allow(clippy::too_many_arguments)]
262    pub fn search_inner(
263        &self,
264        query: ArrayRef,
265        k: usize,
266        params: &HnswQueryParams,
267        bitset: Option<Visited>,
268        visited_generator: &mut VisitedGenerator,
269        storage: &impl VectorStore,
270        prefetch_distance: Option<usize>,
271    ) -> Result<Vec<OrderedNode>> {
272        let dist_calc = storage.dist_calculator(query, params.dist_q_c);
273        let entry = self.inner.entry_point;
274        let ep = OrderedNode::new(entry, dist_calc.distance(entry).into());
275
276        // The level descent + bottom beam search are identical across
277        // graph backends; only the view types differ. `run_search` is
278        // generic over those view types so the loop is single-sourced:
279        // each backend supplies a per-level view closure and a
280        // bottom-level view.
281        let result = match &self.inner.graph {
282            HnswGraph::Built(nodes) => {
283                let nodes = nodes.as_slice();
284                self.run_search(
285                    ep,
286                    k,
287                    params,
288                    bitset.as_ref(),
289                    visited_generator,
290                    storage.len(),
291                    prefetch_distance,
292                    &dist_calc,
293                    |level| ImmutableHnswLevelView::new(level, nodes),
294                    ImmutableHnswBottomView::new(nodes),
295                )
296            }
297            HnswGraph::Loaded(graph) => {
298                let graph = graph.as_ref();
299                self.run_search(
300                    ep,
301                    k,
302                    params,
303                    bitset.as_ref(),
304                    visited_generator,
305                    storage.len(),
306                    prefetch_distance,
307                    &dist_calc,
308                    |level| LoadedHnswLevelView::new(level, graph),
309                    LoadedHnswBottomView::new(graph),
310                )
311            }
312        };
313        Ok(result)
314    }
315
316    /// Drives the shared HNSW query path over backend-specific graph
317    /// views: a per-level view produced by `make_level` and a
318    /// bottom-level view `bottom`. The views borrow their backing store
319    /// and are created, used, and dropped entirely within this call;
320    /// only the owned result escapes. Monomorphizing over `L`/`B` is the
321    /// single seam that lets the in-memory and disk-loaded backends
322    /// share one search loop.
323    #[allow(clippy::too_many_arguments)]
324    fn run_search<L, B>(
325        &self,
326        ep: OrderedNode,
327        k: usize,
328        params: &HnswQueryParams,
329        bitset: Option<&Visited>,
330        visited_generator: &mut VisitedGenerator,
331        storage_len: usize,
332        prefetch_distance: Option<usize>,
333        dist_calc: &impl DistCalculator,
334        make_level: impl Fn(u16) -> L,
335        bottom: B,
336    ) -> Vec<OrderedNode>
337    where
338        L: BorrowingGraph,
339        B: BorrowingGraph,
340    {
341        let mut ep = ep;
342        for level in (0..self.max_level()).rev() {
343            let cur_level = make_level(level);
344            ep = greedy_search_borrowed(
345                &cur_level,
346                ep,
347                dist_calc,
348                self.inner.params.prefetch_distance,
349            );
350        }
351        let mut visited = visited_generator.generate(storage_len);
352        beam_search_borrowed(
353            &bottom,
354            &ep,
355            params,
356            dist_calc,
357            bitset,
358            prefetch_distance,
359            &mut visited,
360        )
361        .into_iter()
362        .take(k)
363        .collect::<Vec<OrderedNode>>()
364    }
365
366    #[instrument(level = "debug", skip(self, query, bitset, storage))]
367    pub fn search_basic(
368        &self,
369        query: ArrayRef,
370        k: usize,
371        params: &HnswQueryParams,
372        bitset: Option<Visited>,
373        storage: &impl VectorStore,
374    ) -> Result<Vec<OrderedNode>> {
375        let mut visited_generator = self
376            .inner
377            .visited_generator_queue
378            .pop()
379            .unwrap_or_else(|| VisitedGenerator::new(storage.len()));
380        let result = self.search_inner(
381            query,
382            k,
383            params,
384            bitset,
385            &mut visited_generator,
386            storage,
387            Some(2),
388        );
389
390        match self.inner.visited_generator_queue.push(visited_generator) {
391            Ok(_) => {}
392            Err(_) => {
393                log::warn!("visited_generator_queue is full");
394            }
395        }
396
397        result
398    }
399
400    #[instrument(level = "debug", skip(self, storage, query, prefilter_bitset))]
401    fn flat_search(
402        &self,
403        storage: &impl VectorStore,
404        query: ArrayRef,
405        k: usize,
406        prefilter_bitset: Visited,
407        params: &HnswQueryParams,
408    ) -> Vec<OrderedNode> {
409        let lower_bound: OrderedFloat = params.lower_bound.unwrap_or(f32::MIN).into();
410        let upper_bound: OrderedFloat = params.upper_bound.unwrap_or(f32::MAX).into();
411
412        let dist_calc = storage.dist_calculator(query, params.dist_q_c);
413        let mut heap = BinaryHeap::<OrderedNode>::with_capacity(k);
414
415        match self.inner.params.prefetch_distance {
416            Some(ahead) if ahead > 0 => {
417                let mut ids_iter = prefilter_bitset.iter_ones().map(|i| i as u32);
418                let mut buffer = VecDeque::with_capacity(ahead + 1);
419                for _ in 0..=ahead {
420                    if let Some(id) = ids_iter.next() {
421                        buffer.push_back(id);
422                    } else {
423                        break;
424                    }
425                }
426
427                while let Some(node_id) = buffer.pop_front() {
428                    if let Some(&prefetch_id) = buffer.get(ahead - 1) {
429                        dist_calc.prefetch(prefetch_id);
430                    }
431                    if let Some(next) = ids_iter.next() {
432                        buffer.push_back(next);
433                    }
434
435                    let dist: OrderedFloat = dist_calc.distance(node_id).into();
436                    if dist <= lower_bound || dist > upper_bound {
437                        continue;
438                    }
439                    if heap.len() < k {
440                        heap.push((dist, node_id).into());
441                    } else if dist < heap.peek().unwrap().dist {
442                        heap.pop();
443                        heap.push((dist, node_id).into());
444                    }
445                }
446            }
447            _ => {
448                for node_id in prefilter_bitset.iter_ones().map(|i| i as u32) {
449                    let dist: OrderedFloat = dist_calc.distance(node_id).into();
450                    if dist <= lower_bound || dist > upper_bound {
451                        continue;
452                    }
453                    if heap.len() < k {
454                        heap.push((dist, node_id).into());
455                    } else if dist < heap.peek().unwrap().dist {
456                        heap.pop();
457                        heap.push((dist, node_id).into());
458                    }
459                }
460            }
461        };
462        heap.into_sorted_vec()
463    }
464
465    /// Returns the metadata of this [`HNSW`].
466    pub fn metadata(&self) -> HnswMetadata {
467        // calculate the offsets of each level,
468        // start from 0
469        let level_offsets = self
470            .inner
471            .level_count
472            .iter()
473            .chain(iter::once(&0))
474            .scan(0, |state, x| {
475                let start = *state;
476                *state += *x;
477                Some(start)
478            })
479            .collect();
480
481        HnswMetadata {
482            entry_point: self.inner.entry_point,
483            params: self.inner.params.clone(),
484            level_offsets,
485        }
486    }
487}
488
489struct HnswBuilder {
490    params: HnswBuildParams,
491
492    nodes: Arc<Vec<RwLock<GraphBuilderNode>>>,
493    level_count: Vec<AtomicUsize>,
494
495    entry_point: u32,
496
497    visited_generator_queue: Arc<ArrayQueue<VisitedGenerator>>,
498}
499
500impl DeepSizeOf for HnswBuilder {
501    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
502        self.params.deep_size_of_children(context)
503            + self.nodes.deep_size_of_children(context)
504            + self.level_count.deep_size_of_children(context)
505        // Skipping the visited_generator_queue
506    }
507}
508
509impl HnswBuilder {
510    fn finish(self) -> HNSW {
511        let nodes = match Arc::try_unwrap(self.nodes) {
512            Ok(nodes) => nodes
513                .into_iter()
514                .map(|node| node.into_inner().expect("builder lock poisoned"))
515                .collect(),
516            Err(nodes) => nodes
517                .iter()
518                .map(|node| node.read().expect("builder lock poisoned").clone())
519                .collect(),
520        };
521
522        let level_count = self
523            .level_count
524            .into_iter()
525            .map(|count| count.load(Ordering::Relaxed))
526            .collect();
527
528        HNSW {
529            inner: Arc::new(HnswCore {
530                params: self.params,
531                graph: HnswGraph::Built(Arc::new(nodes)),
532                level_count,
533                entry_point: self.entry_point,
534                visited_generator_queue: self.visited_generator_queue,
535            }),
536        }
537    }
538
539    /// Create a new [`HNSWBuilder`] with prepared params and in memory vector storage.
540    pub fn with_params(params: HnswBuildParams, storage: &impl VectorStore) -> Self {
541        let len = storage.len();
542        let max_level = params.max_level;
543
544        let level_count = (0..max_level)
545            .map(|_| AtomicUsize::new(0))
546            .collect::<Vec<_>>();
547
548        let visited_generator_queue = Arc::new(ArrayQueue::new(get_num_compute_intensive_cpus()));
549        for _ in 0..get_num_compute_intensive_cpus() {
550            visited_generator_queue
551                .push(VisitedGenerator::new(0))
552                .unwrap();
553        }
554        let mut builder = Self {
555            params,
556            nodes: Arc::new(Vec::new()),
557            level_count,
558            entry_point: 0,
559            visited_generator_queue,
560        };
561
562        if storage.is_empty() {
563            return builder;
564        }
565
566        let mut nodes = Vec::with_capacity(len);
567        {
568            if len > 0 {
569                nodes.push(RwLock::new(GraphBuilderNode::new(0, max_level as usize)));
570            }
571            let mut level_rng = SmallRng::seed_from_u64(HNSW_LEVEL_RNG_SEED);
572            for i in 1..len {
573                nodes.push(RwLock::new(GraphBuilderNode::new(
574                    i as u32,
575                    builder.random_level(&mut level_rng) as usize + 1,
576                )));
577            }
578        }
579        builder.nodes = Arc::new(nodes);
580
581        builder
582    }
583
584    /// New node's level
585    ///
586    /// See paper `Algorithm 1`
587    fn random_level<R: Rng + ?Sized>(&self, rng: &mut R) -> u16 {
588        let ml = 1.0 / (self.params.m as f32).ln();
589        min(
590            (-rng.random::<f32>().ln() * ml) as u16,
591            self.params.max_level - 1,
592        )
593    }
594
595    /// Insert one node.
596    fn insert(
597        &self,
598        node: u32,
599        visited_generator: &mut VisitedGenerator,
600        storage: &impl VectorStore,
601    ) {
602        let nodes = &self.nodes;
603        let target_level = nodes[node as usize].read().unwrap().level_neighbors.len() as u16 - 1;
604        let dist_calc = storage.dist_calculator_from_id(node);
605        let mut ep = OrderedNode::new(
606            self.entry_point,
607            dist_calc.distance(self.entry_point).into(),
608        );
609
610        //
611        // Search for entry point in paper.
612        // ```
613        //   for l_c in (L..l+1) {
614        //     W = Search-Layer(q, ep, ef=1, l_c)
615        //    ep = Select-Neighbors(W, 1)
616        //  }
617        // ```
618        for level in (target_level + 1..self.params.max_level).rev() {
619            let cur_level = HnswLevelView::new(level, nodes);
620            ep = greedy_search(&cur_level, ep, &dist_calc, self.params.prefetch_distance);
621        }
622
623        let mut pruned_neighbors_per_level: Vec<Vec<_>> =
624            vec![Vec::new(); (target_level + 1) as usize];
625        {
626            let mut current_node = nodes[node as usize].write().unwrap();
627            for level in (0..=target_level).rev() {
628                self.level_count[level as usize].fetch_add(1, Ordering::Relaxed);
629
630                let neighbors = self.search_level(&ep, level, &dist_calc, nodes, visited_generator);
631                for neighbor in &neighbors {
632                    current_node.add_neighbor(neighbor.id, neighbor.dist, level);
633                }
634                self.prune(storage, &mut current_node, level);
635                pruned_neighbors_per_level[level as usize]
636                    .clone_from(&current_node.level_neighbors_ranked[level as usize]);
637
638                ep = neighbors[0].clone();
639            }
640        }
641        for (level, pruned_neighbors) in pruned_neighbors_per_level.iter().enumerate() {
642            for unpruned_edge in pruned_neighbors {
643                let level = level as u16;
644                let m_max = match level {
645                    0 => self.params.m * 2,
646                    _ => self.params.m,
647                };
648                if unpruned_edge.dist
649                    < nodes[unpruned_edge.id as usize]
650                        .read()
651                        .unwrap()
652                        .cutoff(level, m_max)
653                {
654                    let mut chosen_node = nodes[unpruned_edge.id as usize].write().unwrap();
655                    chosen_node.add_neighbor(node, unpruned_edge.dist, level);
656                    self.prune(storage, &mut chosen_node, level);
657                }
658            }
659        }
660    }
661
662    fn search_level(
663        &self,
664        ep: &OrderedNode,
665        level: u16,
666        dist_calc: &impl DistCalculator,
667        nodes: &[RwLock<GraphBuilderNode>],
668        visited_generator: &mut VisitedGenerator,
669    ) -> Vec<OrderedNode> {
670        let cur_level = HnswLevelView::new(level, nodes);
671        let mut visited = visited_generator.generate(nodes.len());
672        beam_search(
673            &cur_level,
674            ep,
675            &HnswQueryParams {
676                ef: self.params.ef_construction,
677                lower_bound: None,
678                upper_bound: None,
679                dist_q_c: 0.0,
680            },
681            dist_calc,
682            None,
683            self.params.prefetch_distance,
684            &mut visited,
685        )
686    }
687
688    fn prune(&self, storage: &impl VectorStore, builder_node: &mut GraphBuilderNode, level: u16) {
689        let m_max = match level {
690            0 => self.params.m * 2,
691            _ => self.params.m,
692        };
693
694        let neighbors_ranked = &mut builder_node.level_neighbors_ranked[level as usize];
695        if neighbors_ranked.len() <= m_max {
696            builder_node.update_from_ranked_neighbors(level);
697            return;
698        }
699
700        let level_neighbors = std::mem::take(neighbors_ranked);
701        *neighbors_ranked = select_neighbors_heuristic_owned(storage, level_neighbors, m_max);
702        builder_node.update_from_ranked_neighbors(level);
703    }
704}
705
706// View of a level in HNSW graph.
707// This is used to iterate over neighbors in a specific level.
708pub(crate) struct HnswLevelView<'a> {
709    level: u16,
710    nodes: &'a [RwLock<GraphBuilderNode>],
711}
712
713impl<'a> HnswLevelView<'a> {
714    pub fn new(level: u16, nodes: &'a [RwLock<GraphBuilderNode>]) -> Self {
715        Self { level, nodes }
716    }
717}
718
719impl Graph for HnswLevelView<'_> {
720    fn len(&self) -> usize {
721        self.nodes.len()
722    }
723
724    fn neighbors(&self, key: u32) -> Arc<Vec<u32>> {
725        let node = &self.nodes[key as usize];
726        node.read().unwrap().level_neighbors[self.level as usize].clone()
727    }
728}
729
730pub(crate) struct ImmutableHnswLevelView<'a> {
731    level: u16,
732    nodes: &'a [GraphBuilderNode],
733}
734
735impl<'a> ImmutableHnswLevelView<'a> {
736    pub fn new(level: u16, nodes: &'a [GraphBuilderNode]) -> Self {
737        Self { level, nodes }
738    }
739}
740
741impl Graph for ImmutableHnswLevelView<'_> {
742    fn len(&self) -> usize {
743        self.nodes.len()
744    }
745
746    fn neighbors(&self, key: u32) -> Arc<Vec<u32>> {
747        self.nodes[key as usize].level_neighbors[self.level as usize].clone()
748    }
749}
750
751impl BorrowingGraph for ImmutableHnswLevelView<'_> {
752    fn len(&self) -> usize {
753        self.nodes.len()
754    }
755
756    fn neighbors(&self, key: u32) -> &[u32] {
757        self.nodes[key as usize].level_neighbors[self.level as usize].as_slice()
758    }
759}
760
761pub(crate) struct ImmutableHnswBottomView<'a> {
762    nodes: &'a [GraphBuilderNode],
763}
764
765impl<'a> ImmutableHnswBottomView<'a> {
766    pub fn new(nodes: &'a [GraphBuilderNode]) -> Self {
767        Self { nodes }
768    }
769}
770
771impl Graph for ImmutableHnswBottomView<'_> {
772    fn len(&self) -> usize {
773        self.nodes.len()
774    }
775
776    fn neighbors(&self, key: u32) -> Arc<Vec<u32>> {
777        self.nodes[key as usize].bottom_neighbors.clone()
778    }
779}
780
781impl BorrowingGraph for ImmutableHnswBottomView<'_> {
782    fn len(&self) -> usize {
783        self.nodes.len()
784    }
785
786    fn neighbors(&self, key: u32) -> &[u32] {
787        self.nodes[key as usize].bottom_neighbors.as_slice()
788    }
789}
790
791/// Per-level node-id -> row-index lookup for a disk-loaded HNSW graph.
792enum LevelLookup {
793    /// `row == node id`. Used only for level 0, where [`HNSW::to_batch`]
794    /// writes every node once in ascending `__vector_id` (== node id) order,
795    /// so the level-0 slice is exactly `[0, N)` with `row == id`.
796    Dense,
797    /// Upper level: an explicit `node_id -> row` map built from the level's
798    /// `__vector_id` column.
799    ///
800    /// We do *not* assume the column is sorted or that the slice is aligned
801    /// to a true level boundary: `level_offsets`/`level_count` omit the
802    /// entry-point node (it is written at every level by `to_batch` but only
803    /// counted at level 0), so upper-level slices can be off-by-one and
804    /// non-monotonic. Keying by the `__vector_id` value -- exactly what the
805    /// old per-node `load` did -- preserves behavior bit-for-bit. Upper
806    /// levels shrink geometrically, so this map stays tiny.
807    Sparse(HashMap<u32, u32>),
808}
809
810/// A search-only HNSW graph backed directly by the Arrow buffers of the
811/// on-disk `RecordBatch`.
812///
813/// Loading performs no per-node reconstruction: neighbor adjacency is served
814/// as `&[u32]` slices straight out of the `__neighbors` `ListArray` value
815/// buffer (zero copy). The full `batch` is retained so [`HNSW::to_batch`] is a
816/// near-free passthrough -- required, because the IVF partition cache
817/// re-serializes loaded indices through `to_batch()`
818/// (`lance/src/index/vector/ivf/partition_serde.rs`) -- and so a future
819/// zero-copy `CacheCodec` (#6745) can write/read it through
820/// `lance_arrow::ipc` without rebuilding the graph.
821struct LoadedHnswGraph {
822    /// The full loaded batch (all levels concatenated, level 0 first),
823    /// retained verbatim for `to_batch()` and #6745.
824    batch: RecordBatch,
825    /// Per-level `__neighbors` `List<UInt32>`, zero-copy slices of `batch`.
826    level_neighbors: Vec<ListArray>,
827    /// Per-level node-id -> row lookup (see [`LevelLookup`]).
828    level_lookup: Vec<LevelLookup>,
829    /// Number of nodes present at each level (`level_count[0]` == total).
830    level_count: Vec<usize>,
831}
832
833impl DeepSizeOf for LoadedHnswGraph {
834    fn deep_size_of_children(&self, _context: &mut lance_core::deepsize::Context) -> usize {
835        // `level_neighbors` are zero-copy views into `batch`, so counting
836        // `batch` alone avoids double counting (mirrors
837        // `vector/flat/storage.rs`). The upper-level `level_lookup` maps are
838        // sized to the geometrically-shrinking node counts above level 0 --
839        // negligible next to the batch and not separately accounted here.
840        self.batch.get_array_memory_size()
841    }
842}
843
844impl LoadedHnswGraph {
845    /// Borrow the neighbor ids of `key` at `level` directly from the Arrow
846    /// `ListArray` value buffer -- no allocation, no copy.
847    #[inline]
848    fn neighbors_at(&self, level: usize, key: u32) -> &[u32] {
849        let row = match &self.level_lookup[level] {
850            LevelLookup::Dense => key as usize,
851            LevelLookup::Sparse(id_to_row) => match id_to_row.get(&key) {
852                Some(&row) => row as usize,
853                // The node is absent at this level -- e.g. an empty upper
854                // level the search descends through, or a node that only
855                // exists at lower levels. Mirror the old representation
856                // (`level_neighbors[level]` defaulted to empty): no
857                // neighbors here, so greedy search stays put and descends.
858                None => return &[],
859            },
860        };
861        let list = &self.level_neighbors[level];
862        let offsets = list.value_offsets();
863        let start = offsets[row] as usize;
864        let end = offsets[row + 1] as usize;
865        // The `__neighbors` list child is `UInt32` per `HNSW::schema()`.
866        // Validity bitmap is ignored on purpose: `to_batch` never writes null
867        // neighbor lists, matching the previous `.unwrap()`-based load.
868        let values = list.values().as_primitive::<UInt32Type>();
869        &values.values()[start..end]
870    }
871}
872
873/// Per-level search view over a disk-loaded [`LoadedHnswGraph`].
874pub(crate) struct LoadedHnswLevelView<'a> {
875    level: usize,
876    graph: &'a LoadedHnswGraph,
877}
878
879impl<'a> LoadedHnswLevelView<'a> {
880    fn new(level: u16, graph: &'a LoadedHnswGraph) -> Self {
881        Self {
882            level: level as usize,
883            graph,
884        }
885    }
886}
887
888impl Graph for LoadedHnswLevelView<'_> {
889    fn len(&self) -> usize {
890        // Mirrors `ImmutableHnswLevelView::len` (total node count).
891        self.graph.level_count[0]
892    }
893
894    fn neighbors(&self, key: u32) -> Arc<Vec<u32>> {
895        // Non-hot fallback: HNSW search goes through `BorrowingGraph`. Kept
896        // only so the `Graph` trait / legacy `greedy_search` need no
897        // special-casing for loaded graphs.
898        Arc::new(self.graph.neighbors_at(self.level, key).to_vec())
899    }
900}
901
902impl BorrowingGraph for LoadedHnswLevelView<'_> {
903    fn len(&self) -> usize {
904        self.graph.level_count[0]
905    }
906
907    fn neighbors(&self, key: u32) -> &[u32] {
908        self.graph.neighbors_at(self.level, key)
909    }
910}
911
912/// Bottom-level (level 0) search view over a disk-loaded [`LoadedHnswGraph`].
913pub(crate) struct LoadedHnswBottomView<'a> {
914    graph: &'a LoadedHnswGraph,
915}
916
917impl<'a> LoadedHnswBottomView<'a> {
918    fn new(graph: &'a LoadedHnswGraph) -> Self {
919        Self { graph }
920    }
921}
922
923impl Graph for LoadedHnswBottomView<'_> {
924    fn len(&self) -> usize {
925        self.graph.level_count[0]
926    }
927
928    fn neighbors(&self, key: u32) -> Arc<Vec<u32>> {
929        Arc::new(self.graph.neighbors_at(0, key).to_vec())
930    }
931}
932
933impl BorrowingGraph for LoadedHnswBottomView<'_> {
934    fn len(&self) -> usize {
935        self.graph.level_count[0]
936    }
937
938    fn neighbors(&self, key: u32) -> &[u32] {
939        self.graph.neighbors_at(0, key)
940    }
941}
942
943/// The graph backing an [`HNSW`]: either built in memory or disk-loaded.
944enum HnswGraph {
945    /// Built in memory by the (online) builder / `index_vectors` /
946    /// `from_parts`. Mutable-shaped `GraphBuilderNode`s; `to_batch()`
947    /// re-encodes from these (it needs the per-node ranked distances).
948    Built(Arc<Vec<GraphBuilderNode>>),
949    /// Loaded from disk, Arrow-backed, search-only.
950    Loaded(Arc<LoadedHnswGraph>),
951}
952
953impl DeepSizeOf for HnswGraph {
954    fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize {
955        match self {
956            Self::Built(nodes) => nodes.deep_size_of_children(context),
957            Self::Loaded(graph) => graph.deep_size_of_children(context),
958        }
959    }
960}
961
962#[derive(Debug, Clone, Copy)]
963pub struct HnswQueryParams {
964    pub ef: usize,
965    pub lower_bound: Option<f32>,
966    pub upper_bound: Option<f32>,
967    pub dist_q_c: f32,
968}
969
970impl From<&Query> for HnswQueryParams {
971    fn from(query: &Query) -> Self {
972        let k = query.k * query.refine_factor.unwrap_or(1) as usize;
973        Self {
974            ef: query.ef.unwrap_or(k + k / 2),
975            lower_bound: query.lower_bound,
976            upper_bound: query.upper_bound,
977            dist_q_c: query.dist_q_c,
978        }
979    }
980}
981
982impl IvfSubIndex for HNSW {
983    type BuildParams = HnswBuildParams;
984    type QueryParams = HnswQueryParams;
985
986    fn load(data: RecordBatch) -> Result<Self>
987    where
988        Self: Sized,
989    {
990        if data.num_rows() == 0 {
991            return Ok(Self::empty());
992        }
993
994        let hnsw_metadata = data
995            .schema_ref()
996            .metadata()
997            .get(HNSW_METADATA_KEY)
998            .ok_or(Error::index(format!("{} not found", HNSW_METADATA_KEY)))?;
999        let hnsw_metadata: HnswMetadata = serde_json::from_str(hnsw_metadata).map_err(|e| {
1000            Error::index(format!(
1001                "Failed to decode HNSW metadata: {}, json: {}",
1002                e, hnsw_metadata
1003            ))
1004        })?;
1005
1006        // Slice the concatenated batch into one (zero-copy) view per level.
1007        let level_batches: Vec<RecordBatch> = hnsw_metadata
1008            .level_offsets
1009            .iter()
1010            .tuple_windows()
1011            .map(|(start, end)| data.slice(*start, end - start))
1012            .collect();
1013
1014        let level_count = level_batches
1015            .iter()
1016            .map(|b| b.num_rows())
1017            .collect::<Vec<_>>();
1018
1019        // No per-node reconstruction: keep the Arrow adjacency buffers as-is
1020        // and only build the tiny per-upper-level id->row lookups. The
1021        // `__distance` column is never materialized here -- search doesn't
1022        // need it, and `to_batch()` returns the retained `data` verbatim.
1023        let mut level_neighbors = Vec::with_capacity(level_batches.len());
1024        let mut level_lookup = Vec::with_capacity(level_batches.len());
1025        for (level, batch) in level_batches.iter().enumerate() {
1026            // `.clone()` on an Arrow array bumps a refcount; buffers stay
1027            // shared with `data` (zero copy).
1028            let neighbors = batch[NEIGHBORS_COL].as_list::<i32>().clone();
1029            let ids = batch[VECTOR_ID_COL].as_primitive::<UInt32Type>();
1030            if level == 0 {
1031                // `to_batch` writes every node at level 0 exactly once in
1032                // ascending `__vector_id` (== node id) order, so the level-0
1033                // slice is exactly `[0, N)` and the row index *is* the node
1034                // id. The `Dense` lookup below depends on this: in a release
1035                // build a violated invariant would silently make search read
1036                // the wrong neighbor list, so enforce it at load time (not via
1037                // `debug_assert!`) and reject a malformed or version-
1038                // incompatible batch.
1039                if let Some((row, id)) = ids
1040                    .values()
1041                    .iter()
1042                    .enumerate()
1043                    .find(|&(row, id)| *id != row as u32)
1044                {
1045                    return Err(Error::index(format!(
1046                        "HNSW level-0 __vector_id must equal the row index, but \
1047                         row {row} has __vector_id {id}; the on-disk batch is \
1048                         malformed or was written by an incompatible version"
1049                    )));
1050                }
1051                level_lookup.push(LevelLookup::Dense);
1052            } else {
1053                // Upper levels: explicit id -> row map. No ordering/alignment
1054                // assumption (see `LevelLookup::Sparse`). On the rare
1055                // duplicate id (a misaligned slice can repeat one across a
1056                // level boundary) the last wins, matching the old load's
1057                // `nodes[id].level_neighbors[level] = ...` last-write.
1058                let id_to_row: HashMap<u32, u32> = ids
1059                    .values()
1060                    .iter()
1061                    .enumerate()
1062                    .map(|(row, id)| (*id, row as u32))
1063                    .collect();
1064                level_lookup.push(LevelLookup::Sparse(id_to_row));
1065            }
1066            level_neighbors.push(neighbors);
1067        }
1068
1069        // `entry_point` is read from untrusted metadata and indexes the `Dense`
1070        // level-0 lookup directly; an out-of-range value would read past the
1071        // level-0 neighbor buffer during search. Validate it under the same
1072        // persisted-format invariant as the level-0 ids above.
1073        let num_nodes = level_count[0];
1074        if hnsw_metadata.entry_point as usize >= num_nodes {
1075            return Err(Error::index(format!(
1076                "HNSW entry_point {} is out of range for a graph with {num_nodes} \
1077                 nodes; the on-disk batch is malformed or was written by an \
1078                 incompatible version",
1079                hnsw_metadata.entry_point
1080            )));
1081        }
1082
1083        let visited_generator_queue =
1084            Arc::new(ArrayQueue::new(get_num_compute_intensive_cpus() * 2));
1085        for _ in 0..get_num_compute_intensive_cpus() * 2 {
1086            visited_generator_queue
1087                .push(VisitedGenerator::new(0))
1088                .unwrap();
1089        }
1090
1091        let graph = LoadedHnswGraph {
1092            batch: data,
1093            level_neighbors,
1094            level_lookup,
1095            level_count: level_count.clone(),
1096        };
1097        let inner = HnswCore {
1098            params: hnsw_metadata.params,
1099            graph: HnswGraph::Loaded(Arc::new(graph)),
1100            level_count,
1101            entry_point: hnsw_metadata.entry_point,
1102            visited_generator_queue,
1103        };
1104
1105        Ok(Self {
1106            inner: Arc::new(inner),
1107        })
1108    }
1109
1110    fn name() -> &'static str {
1111        HNSW_TYPE
1112    }
1113
1114    fn metadata_key() -> &'static str {
1115        "lance:hnsw"
1116    }
1117
1118    /// Return the schema of the sub index
1119    fn schema() -> arrow_schema::SchemaRef {
1120        arrow_schema::Schema::new(vec![
1121            VECTOR_ID_FIELD.clone(),
1122            NEIGHBORS_FIELD.clone(),
1123            DISTS_FIELD.clone(),
1124        ])
1125        .into()
1126    }
1127
1128    #[instrument(level = "debug", skip(self, query, storage, prefilter, _metrics))]
1129    fn search(
1130        &self,
1131        query: ArrayRef,
1132        k: usize,
1133        params: Self::QueryParams,
1134        storage: &impl VectorStore,
1135        prefilter: Arc<dyn PreFilter>,
1136        _metrics: &dyn MetricsCollector,
1137    ) -> Result<RecordBatch> {
1138        if params.ef < k {
1139            return Err(Error::index(
1140                "ef must be greater than or equal to k".to_string(),
1141            ));
1142        }
1143
1144        let schema = VECTOR_RESULT_SCHEMA.clone();
1145        if self.is_empty() {
1146            return Ok(RecordBatch::new_empty(schema));
1147        }
1148
1149        let mut prefilter_generator = self
1150            .inner
1151            .visited_generator_queue
1152            .pop()
1153            .unwrap_or_else(|| VisitedGenerator::new(storage.len()));
1154        let prefilter_bitset = if prefilter.is_empty() {
1155            None
1156        } else {
1157            let indices = prefilter.filter_row_ids(Box::new(storage.row_ids()));
1158            let mut bitset = prefilter_generator.generate(storage.len());
1159            for indices in indices {
1160                bitset.insert(indices as u32);
1161            }
1162            Some(bitset)
1163        };
1164
1165        let remained = prefilter_bitset
1166            .as_ref()
1167            .map(|b| b.count_ones())
1168            .unwrap_or(storage.len());
1169        let results = if remained < self.len() * 10 / 100 {
1170            let prefilter_bitset =
1171                prefilter_bitset.expect("the prefilter bitset must be set for flat search");
1172            self.flat_search(storage, query, k, prefilter_bitset, &params)
1173        } else {
1174            self.search_basic(query, k, &params, prefilter_bitset, storage)?
1175        };
1176        // if the queue is full, we just don't push it back, so ignore the error here
1177        let _ = self.inner.visited_generator_queue.push(prefilter_generator);
1178
1179        // need to unique by row ids in case of searching multivector
1180        let (row_ids, dists): (Vec<_>, Vec<_>) = results
1181            .into_iter()
1182            .map(|r| (storage.row_id(r.id), r.dist.0))
1183            .unique_by(|r| r.0)
1184            .unzip();
1185        let row_ids = Arc::new(UInt64Array::from(row_ids));
1186        let distances = Arc::new(Float32Array::from(dists));
1187
1188        Ok(RecordBatch::try_new(schema, vec![distances, row_ids])?)
1189    }
1190
1191    /// Given a vector storage, containing all the data for the IVF partition, build the sub index.
1192    fn index_vectors(storage: &impl VectorStore, params: Self::BuildParams) -> Result<Self>
1193    where
1194        Self: Sized,
1195    {
1196        let builder = HnswBuilder::with_params(params, storage);
1197
1198        log::debug!(
1199            "Building HNSW graph: num={}, max_levels={}, m={}, ef_construction={}, distance_type:{}",
1200            storage.len(),
1201            builder.params.max_level,
1202            builder.params.m,
1203            builder.params.ef_construction,
1204            storage.distance_type(),
1205        );
1206
1207        if storage.is_empty() {
1208            return Ok(builder.finish());
1209        }
1210
1211        let len = storage.len();
1212        builder.level_count[0].fetch_add(1, Ordering::Relaxed);
1213        (1..len).into_par_iter().for_each_init(
1214            || VisitedGenerator::new(len),
1215            |visited_generator, node| {
1216                builder.insert(node as u32, visited_generator, storage);
1217            },
1218        );
1219
1220        assert_eq!(builder.level_count[0].load(Ordering::Relaxed), len);
1221        Ok(builder.finish())
1222    }
1223
1224    fn remap(
1225        &self,
1226        _mapping: &RowAddrRemap, // we don't need the mapping here because we rebuild the graph from remapped storage
1227        store: &impl VectorStore,
1228    ) -> Result<Self> {
1229        // We can't simply remap the row ids in the graph because the vectors are changed,
1230        // so the graph needs to be rebuilt.
1231        Self::index_vectors(store, self.inner.params.clone())
1232    }
1233
1234    /// Encode the sub index into a record batch
1235    fn to_batch(&self) -> Result<RecordBatch> {
1236        let nodes = match &self.inner.graph {
1237            HnswGraph::Built(nodes) => nodes,
1238            HnswGraph::Loaded(graph) => {
1239                // A loaded graph is already Arrow-backed: return the retained
1240                // batch verbatim, re-stamped with up-to-date HNSW metadata.
1241                // The IVF partition cache re-serializes loaded indices through
1242                // here (`ivf/partition_serde.rs`), so this must round-trip.
1243                //
1244                // Merge into (not replace) the existing schema metadata: a
1245                // disk-loaded batch inherits other keys from the index file
1246                // schema (e.g. `INDEX_METADATA_SCHEMA_KEY`, `IVF_METADATA_KEY`),
1247                // and `RecordBatch::with_schema` requires the new metadata to be
1248                // a superset of the current one. Dropping those keys here makes
1249                // the new schema a non-superset and fails the round-trip with
1250                // "target schema is not superset of current schema".
1251                let metadata = serde_json::to_string(&self.metadata())?;
1252                let mut schema_metadata = graph.batch.schema_ref().metadata().clone();
1253                schema_metadata.insert(HNSW_METADATA_KEY.to_string(), metadata);
1254                let schema = graph
1255                    .batch
1256                    .schema()
1257                    .as_ref()
1258                    .clone()
1259                    .with_metadata(schema_metadata);
1260                return Ok(graph.batch.clone().with_schema(Arc::new(schema))?);
1261            }
1262        };
1263
1264        let mut vector_id_builder = UInt32Builder::with_capacity(self.len());
1265        let mut neighbors_builder = ListBuilder::with_capacity(UInt32Builder::new(), self.len());
1266        let mut distances_builder =
1267            ListBuilder::with_capacity(arrow_array::builder::Float32Builder::new(), self.len());
1268        let mut batches = Vec::with_capacity(self.max_level() as usize);
1269        for level in 0..self.max_level() {
1270            let level = level as usize;
1271            for (id, node) in nodes.iter().enumerate() {
1272                if level >= node.level_neighbors.len() {
1273                    continue;
1274                }
1275                let neighbors = node.level_neighbors[level].iter().map(|n| Some(*n));
1276                let distances = node.level_neighbors_ranked[level]
1277                    .iter()
1278                    .map(|n| Some(n.dist.0));
1279                vector_id_builder.append_value(id as u32);
1280                neighbors_builder.append_value(neighbors);
1281                distances_builder.append_value(distances);
1282            }
1283
1284            let batch = RecordBatch::try_new(
1285                Self::schema(),
1286                vec![
1287                    Arc::new(vector_id_builder.finish()),
1288                    Arc::new(neighbors_builder.finish()),
1289                    Arc::new(distances_builder.finish()),
1290                ],
1291            )?;
1292            batches.push(batch);
1293        }
1294
1295        let metadata = self.metadata();
1296        let metadata = serde_json::to_string(&metadata)?;
1297        let schema = Self::schema()
1298            .as_ref()
1299            .clone()
1300            .with_metadata(HashMap::from_iter(vec![(
1301                HNSW_METADATA_KEY.to_string(),
1302                metadata,
1303            )]));
1304        let batch = concat_batches(&Self::schema(), batches.iter())?;
1305        let batch = batch.with_schema(Arc::new(schema))?;
1306        Ok(batch)
1307    }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312    use std::sync::Arc;
1313
1314    use arrow_array::{ArrayRef, FixedSizeListArray, RecordBatch, UInt8Array, UInt32Array};
1315    use arrow_schema::Schema;
1316    use lance_arrow::FixedSizeListArrayExt;
1317    use lance_core::deepsize::DeepSizeOf;
1318    use lance_file::previous::{
1319        reader::FileReader as PreviousFileReader,
1320        writer::{
1321            FileWriter as PreviousFileWriter, FileWriterOptions as PreviousFileWriterOptions,
1322        },
1323    };
1324    use lance_io::object_store::ObjectStore;
1325    use lance_linalg::distance::DistanceType;
1326    use lance_table::format::SelfDescribingFileReader;
1327    use lance_table::io::manifest::ManifestDescribing;
1328    use lance_testing::datagen::generate_random_array;
1329    use object_store::path::Path;
1330    use rstest::rstest;
1331
1332    use super::HnswGraph;
1333    use crate::vector::storage::{DistCalculator, VectorStore};
1334    use crate::vector::v3::subindex::IvfSubIndex;
1335    use crate::vector::{
1336        flat::storage::{FlatBinStorage, FlatFloatStorage},
1337        graph::{DISTS_FIELD, NEIGHBORS_FIELD},
1338        hnsw::{
1339            HNSW, VECTOR_ID_FIELD,
1340            builder::{HnswBuildParams, HnswQueryParams},
1341        },
1342    };
1343
1344    #[tokio::test]
1345    async fn test_builder_write_load() {
1346        const DIM: usize = 32;
1347        const TOTAL: usize = 2048;
1348        const NUM_EDGES: usize = 20;
1349        let data = generate_random_array(TOTAL * DIM);
1350        let fsl = FixedSizeListArray::try_new_from_values(data, DIM as i32).unwrap();
1351        let store = Arc::new(FlatFloatStorage::new(fsl.clone(), DistanceType::L2));
1352        let builder = HNSW::index_vectors(
1353            store.as_ref(),
1354            HnswBuildParams::default()
1355                .num_edges(NUM_EDGES)
1356                .ef_construction(50),
1357        )
1358        .unwrap();
1359
1360        let object_store = ObjectStore::memory();
1361        let path = Path::from("test_builder_write_load");
1362        let writer = object_store.create(&path).await.unwrap();
1363        let schema = Schema::new(vec![
1364            VECTOR_ID_FIELD.clone(),
1365            NEIGHBORS_FIELD.clone(),
1366            DISTS_FIELD.clone(),
1367        ]);
1368        let schema = lance_core::datatypes::Schema::try_from(&schema).unwrap();
1369        let mut writer = PreviousFileWriter::<ManifestDescribing>::with_object_writer(
1370            writer,
1371            schema,
1372            &PreviousFileWriterOptions::default(),
1373        )
1374        .unwrap();
1375        let batch = builder.to_batch().unwrap();
1376        let metadata = batch.schema_ref().metadata().clone();
1377        writer.write(&[batch]).await.unwrap();
1378        writer.finish_with_metadata(&metadata).await.unwrap();
1379
1380        let reader = PreviousFileReader::try_new_self_described(&object_store, &path, None)
1381            .await
1382            .unwrap();
1383        let batch = reader
1384            .read_range(0..reader.len(), reader.schema())
1385            .await
1386            .unwrap();
1387        let loaded_hnsw = HNSW::load(batch).unwrap();
1388
1389        let query = fsl.value(0);
1390        let k = 10;
1391        let params = HnswQueryParams {
1392            ef: 50,
1393            lower_bound: None,
1394            upper_bound: None,
1395            dist_q_c: 0.0,
1396        };
1397        let builder_results = builder
1398            .search_basic(query.clone(), k, &params, None, store.as_ref())
1399            .unwrap();
1400        let loaded_results = loaded_hnsw
1401            .search_basic(query, k, &params, None, store.as_ref())
1402            .unwrap();
1403        assert_eq!(builder_results, loaded_results);
1404    }
1405
1406    #[tokio::test]
1407    async fn test_builder_write_load_binary_hamming() {
1408        const DIM: usize = 8;
1409        const TOTAL: usize = 256;
1410        const NUM_EDGES: usize = 20;
1411        let data = UInt8Array::from_iter_values((0..TOTAL * DIM).map(|v| (v % 16) as u8));
1412        let fsl = FixedSizeListArray::try_new_from_values(data, DIM as i32).unwrap();
1413        let store = Arc::new(FlatBinStorage::new(fsl.clone(), DistanceType::Hamming));
1414        let builder = HnswBuildParams::default()
1415            .num_edges(NUM_EDGES)
1416            .ef_construction(50)
1417            .build(Arc::new(fsl.clone()), DistanceType::Hamming)
1418            .await
1419            .unwrap();
1420
1421        let object_store = ObjectStore::memory();
1422        let path = Path::from("test_builder_write_load_binary_hamming");
1423        let writer = object_store.create(&path).await.unwrap();
1424        let schema = Schema::new(vec![
1425            VECTOR_ID_FIELD.clone(),
1426            NEIGHBORS_FIELD.clone(),
1427            DISTS_FIELD.clone(),
1428        ]);
1429        let schema = lance_core::datatypes::Schema::try_from(&schema).unwrap();
1430        let mut writer = PreviousFileWriter::<ManifestDescribing>::with_object_writer(
1431            writer,
1432            schema,
1433            &PreviousFileWriterOptions::default(),
1434        )
1435        .unwrap();
1436        let batch = builder.to_batch().unwrap();
1437        let metadata = batch.schema_ref().metadata().clone();
1438        writer.write(&[batch]).await.unwrap();
1439        writer.finish_with_metadata(&metadata).await.unwrap();
1440
1441        let reader = PreviousFileReader::try_new_self_described(&object_store, &path, None)
1442            .await
1443            .unwrap();
1444        let batch = reader
1445            .read_range(0..reader.len(), reader.schema())
1446            .await
1447            .unwrap();
1448        let loaded_hnsw = HNSW::load(batch).unwrap();
1449
1450        let query = fsl.value(0);
1451        let k = 10;
1452        let params = HnswQueryParams {
1453            ef: 50,
1454            lower_bound: None,
1455            upper_bound: None,
1456            dist_q_c: 0.0,
1457        };
1458        let builder_results = builder
1459            .search_basic(query.clone(), k, &params, None, store.as_ref())
1460            .unwrap();
1461        let loaded_results = loaded_hnsw
1462            .search_basic(query, k, &params, None, store.as_ref())
1463            .unwrap();
1464        assert_eq!(builder_results, loaded_results);
1465    }
1466
1467    /// Brute-force top-`k` node ids by distance -- recall ground truth.
1468    fn brute_force_topk(store: &FlatFloatStorage, query: ArrayRef, k: usize) -> Vec<u32> {
1469        let dist_calc = store.dist_calculator(query, 0.0);
1470        let mut all: Vec<(f32, u32)> = (0..store.len() as u32)
1471            .map(|id| (dist_calc.distance(id), id))
1472            .collect();
1473        all.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1474        all.into_iter().take(k).map(|(_, id)| id).collect()
1475    }
1476
1477    /// The Arrow-backed loaded graph must search bit-identically to the
1478    /// in-memory build, across distance types and graph sizes (single node,
1479    /// pair, and a multi-level graph exercising the sparse upper-level
1480    /// id->row lookup).
1481    #[rstest]
1482    #[case::l2_single(DistanceType::L2, 1)]
1483    #[case::l2_pair(DistanceType::L2, 2)]
1484    #[case::l2_multi_level(DistanceType::L2, 2048)]
1485    #[case::dot_multi_level(DistanceType::Dot, 2048)]
1486    #[tokio::test]
1487    async fn test_loaded_search_parity_and_recall(
1488        #[case] distance_type: DistanceType,
1489        #[case] total: usize,
1490    ) {
1491        const DIM: usize = 32;
1492        let fsl =
1493            FixedSizeListArray::try_new_from_values(generate_random_array(total * DIM), DIM as i32)
1494                .unwrap();
1495        let store = Arc::new(FlatFloatStorage::new(fsl.clone(), distance_type));
1496        let builder = HNSW::index_vectors(
1497            store.as_ref(),
1498            HnswBuildParams::default().num_edges(20).ef_construction(50),
1499        )
1500        .unwrap();
1501        assert!(!matches!(builder.inner.graph, HnswGraph::Loaded(_)));
1502
1503        let loaded = HNSW::load(builder.to_batch().unwrap()).unwrap();
1504        assert!(matches!(loaded.inner.graph, HnswGraph::Loaded(_)));
1505        assert_eq!(loaded.len(), total);
1506
1507        let k = total.min(10);
1508        let params = HnswQueryParams {
1509            ef: 50,
1510            lower_bound: None,
1511            upper_bound: None,
1512            dist_q_c: 0.0,
1513        };
1514        let query = fsl.value(0);
1515
1516        let builder_results = builder
1517            .search_basic(query.clone(), k, &params, None, store.as_ref())
1518            .unwrap();
1519        let loaded_results = loaded
1520            .search_basic(query.clone(), k, &params, None, store.as_ref())
1521            .unwrap();
1522        assert_eq!(builder_results, loaded_results);
1523
1524        // Recall vs brute-force ground truth (project rule: >= 0.5).
1525        let truth: std::collections::HashSet<u32> = brute_force_topk(store.as_ref(), query, k)
1526            .into_iter()
1527            .collect();
1528        let hits = loaded_results
1529            .iter()
1530            .filter(|n| truth.contains(&n.id))
1531            .count();
1532        let recall = hits as f32 / k as f32;
1533        assert!(recall >= 0.5, "recall {recall} below 0.5 (k={k})");
1534    }
1535
1536    /// Regression guard for the `level_offsets` misalignment (issue #6746).
1537    /// `to_batch` writes the entry-point node at *every* level, but
1538    /// `level_count` only counts it at level 0, so the serialized batch has
1539    /// strictly more rows than `sum(level_count)` and the upper-level
1540    /// `level_offsets` slices are off-by-one / non-monotonic. The Arrow-backed
1541    /// loaded graph must still search bit-identically to the in-memory build:
1542    /// it keys upper levels by `__vector_id` value via the `Sparse` map
1543    /// (last-write-wins), never `row == id`. A naive `row == id`
1544    /// reimplementation would pass the small cases but break here.
1545    #[tokio::test]
1546    async fn test_loaded_level_offsets_misalignment_invariant() {
1547        use arrow::array::AsArray;
1548        use arrow::datatypes::UInt32Type;
1549
1550        const DIM: usize = 32;
1551        const TOTAL: usize = 2048;
1552        let fsl =
1553            FixedSizeListArray::try_new_from_values(generate_random_array(TOTAL * DIM), DIM as i32)
1554                .unwrap();
1555        let store = Arc::new(FlatFloatStorage::new(fsl.clone(), DistanceType::L2));
1556        let builder = HNSW::index_vectors(
1557            store.as_ref(),
1558            HnswBuildParams::default().num_edges(20).ef_construction(50),
1559        )
1560        .unwrap();
1561
1562        // The scenario only exists on a multi-level graph.
1563        assert!(
1564            builder.max_level() >= 2,
1565            "expected a multi-level graph (got max_level {})",
1566            builder.max_level()
1567        );
1568
1569        let batch = builder.to_batch().unwrap();
1570        let md = builder.metadata();
1571        let total_counted = *md.level_offsets.last().unwrap();
1572
1573        // The exact misalignment: more serialized rows than `level_count` sums
1574        // to, because the entry-point node is written at every level yet
1575        // counted only at level 0.
1576        assert!(
1577            batch.num_rows() > total_counted,
1578            "expected serialized rows ({}) to exceed sum(level_count) ({}) -- \
1579             entry point should be written at every level",
1580            batch.num_rows(),
1581            total_counted,
1582        );
1583
1584        // Level-0 slice must still be exactly `[0, N)` with
1585        // `__vector_id == row` -- the precondition for `LevelLookup::Dense`.
1586        let n = md.level_offsets[1];
1587        assert_eq!(n, TOTAL);
1588        let level0 = batch.slice(0, n);
1589        let ids = level0.column(0).as_primitive::<UInt32Type>();
1590        assert!(
1591            ids.values()
1592                .iter()
1593                .enumerate()
1594                .all(|(row, id)| *id == row as u32),
1595            "level-0 __vector_id must equal the row index",
1596        );
1597
1598        // Despite the surplus rows and off-by-one upper slices, the loaded
1599        // graph searches bit-identically to the in-memory build (old `load`
1600        // semantics preserved via the `Sparse` last-write-wins map).
1601        let loaded = HNSW::load(batch).unwrap();
1602        assert!(matches!(loaded.inner.graph, HnswGraph::Loaded(_)));
1603        let params = HnswQueryParams {
1604            ef: 50,
1605            lower_bound: None,
1606            upper_bound: None,
1607            dist_q_c: 0.0,
1608        };
1609        let query = fsl.value(0);
1610        let builder_results = builder
1611            .search_basic(query.clone(), 10, &params, None, store.as_ref())
1612            .unwrap();
1613        let loaded_results = loaded
1614            .search_basic(query, 10, &params, None, store.as_ref())
1615            .unwrap();
1616        assert_eq!(builder_results, loaded_results);
1617    }
1618
1619    /// `load()` must reject a batch whose level-0 `__vector_id` no longer
1620    /// matches the row index. The `LevelLookup::Dense` fast path relies on
1621    /// `row == id`, and the old `debug_assert!` was compiled out of release
1622    /// builds -- so a corrupt batch must fail at the `load()` boundary instead
1623    /// of silently searching the wrong neighbor lists.
1624    #[tokio::test]
1625    async fn test_load_rejects_misaligned_level0_id() {
1626        use arrow::array::AsArray;
1627        use arrow::datatypes::UInt32Type;
1628
1629        const DIM: usize = 16;
1630        const TOTAL: usize = 256;
1631        let fsl =
1632            FixedSizeListArray::try_new_from_values(generate_random_array(TOTAL * DIM), DIM as i32)
1633                .unwrap();
1634        let store = Arc::new(FlatFloatStorage::new(fsl, DistanceType::L2));
1635        let builder = HNSW::index_vectors(
1636            store.as_ref(),
1637            HnswBuildParams::default().num_edges(20).ef_construction(50),
1638        )
1639        .unwrap();
1640
1641        let batch = builder.to_batch().unwrap();
1642        // Row 0 is always a level-0 node; break its `__vector_id == row`
1643        // invariant while preserving the (metadata-bearing) schema.
1644        let mut ids = batch
1645            .column(0)
1646            .as_primitive::<UInt32Type>()
1647            .values()
1648            .to_vec();
1649        ids[0] = ids.len() as u32;
1650        let mut columns = batch.columns().to_vec();
1651        columns[0] = Arc::new(UInt32Array::from(ids));
1652        let corrupted = RecordBatch::try_new(batch.schema(), columns).unwrap();
1653
1654        assert!(
1655            HNSW::load(corrupted).is_err(),
1656            "load() must reject a misaligned level-0 __vector_id"
1657        );
1658    }
1659
1660    /// `load()` must reject metadata whose `entry_point` is out of range for
1661    /// the node count: it indexes the `Dense` level-0 lookup directly, so an
1662    /// out-of-range value would read past the level-0 neighbor buffer at search
1663    /// time.
1664    #[tokio::test]
1665    async fn test_load_rejects_out_of_range_entry_point() {
1666        use super::{HNSW_METADATA_KEY, HnswMetadata};
1667
1668        const DIM: usize = 16;
1669        const TOTAL: usize = 256;
1670        let fsl =
1671            FixedSizeListArray::try_new_from_values(generate_random_array(TOTAL * DIM), DIM as i32)
1672                .unwrap();
1673        let store = Arc::new(FlatFloatStorage::new(fsl, DistanceType::L2));
1674        let builder = HNSW::index_vectors(
1675            store.as_ref(),
1676            HnswBuildParams::default().num_edges(20).ef_construction(50),
1677        )
1678        .unwrap();
1679
1680        let batch = builder.to_batch().unwrap();
1681        let mut metadata = batch.schema_ref().metadata().clone();
1682        let mut md: HnswMetadata =
1683            serde_json::from_str(metadata.get(HNSW_METADATA_KEY).unwrap()).unwrap();
1684        // Valid entry points are `[0, N)`; `level_offsets[1]` == N is one past.
1685        let n = md.level_offsets[1];
1686        md.entry_point = n as u32;
1687        metadata.insert(
1688            HNSW_METADATA_KEY.to_string(),
1689            serde_json::to_string(&md).unwrap(),
1690        );
1691        // Rebuild the batch under the rewritten metadata. `with_schema` would
1692        // reject this: it requires the new metadata to be a superset, but we
1693        // are changing an existing key's value, not adding one.
1694        let schema = batch.schema().as_ref().clone().with_metadata(metadata);
1695        let corrupted = RecordBatch::try_new(Arc::new(schema), batch.columns().to_vec()).unwrap();
1696
1697        assert!(
1698            HNSW::load(corrupted).is_err(),
1699            "load() must reject an out-of-range entry_point"
1700        );
1701    }
1702
1703    /// An empty index round-trips: 0-row `to_batch` -> `load` -> empty graph.
1704    #[tokio::test]
1705    async fn test_loaded_empty_index() {
1706        const DIM: usize = 16;
1707        let fsl =
1708            FixedSizeListArray::try_new_from_values(generate_random_array(0), DIM as i32).unwrap();
1709        let store = Arc::new(FlatFloatStorage::new(fsl, DistanceType::L2));
1710        let builder = HNSW::index_vectors(store.as_ref(), HnswBuildParams::default()).unwrap();
1711        assert!(builder.is_empty());
1712
1713        let batch = builder.to_batch().unwrap();
1714        assert_eq!(batch.num_rows(), 0);
1715
1716        let loaded = HNSW::load(batch).unwrap();
1717        assert!(loaded.is_empty());
1718        assert_eq!(loaded.len(), 0);
1719        // A 0-row load short-circuits to the empty (Built) graph.
1720        assert!(!matches!(loaded.inner.graph, HnswGraph::Loaded(_)));
1721        assert_eq!(loaded.to_batch().unwrap().num_rows(), 0);
1722    }
1723
1724    /// build -> `to_batch` (b1) -> `load` -> `to_batch` (b2) must satisfy
1725    /// `b1 == b2`, and the round-tripped batch must reload and search
1726    /// identically. This is exactly the IVF partition-cache path:
1727    /// `ivf/partition_serde.rs` calls `to_batch()` on a *loaded* index.
1728    #[tokio::test]
1729    async fn test_to_batch_roundtrip_loaded() {
1730        const DIM: usize = 24;
1731        const TOTAL: usize = 1500;
1732        let fsl =
1733            FixedSizeListArray::try_new_from_values(generate_random_array(TOTAL * DIM), DIM as i32)
1734                .unwrap();
1735        let store = Arc::new(FlatFloatStorage::new(fsl.clone(), DistanceType::L2));
1736        let builder = HNSW::index_vectors(
1737            store.as_ref(),
1738            HnswBuildParams::default().num_edges(16).ef_construction(50),
1739        )
1740        .unwrap();
1741
1742        let b1 = builder.to_batch().unwrap();
1743        let loaded = HNSW::load(b1.clone()).unwrap();
1744        assert!(matches!(loaded.inner.graph, HnswGraph::Loaded(_)));
1745        let b2 = loaded.to_batch().unwrap();
1746        assert_eq!(b1, b2);
1747
1748        let reloaded = HNSW::load(b2).unwrap();
1749        let params = HnswQueryParams {
1750            ef: 50,
1751            lower_bound: None,
1752            upper_bound: None,
1753            dist_q_c: 0.0,
1754        };
1755        let query = fsl.value(7);
1756        let a = builder
1757            .search_basic(query.clone(), 10, &params, None, store.as_ref())
1758            .unwrap();
1759        let b = reloaded
1760            .search_basic(query, 10, &params, None, store.as_ref())
1761            .unwrap();
1762        assert_eq!(a, b);
1763    }
1764
1765    /// Regression for the IVF partition-cache round-trip: a disk-loaded batch
1766    /// inherits extra schema metadata keys from the index file (e.g.
1767    /// `INDEX_METADATA_SCHEMA_KEY`, `IVF_METADATA_KEY`). `to_batch()` on the
1768    /// loaded graph must *merge* the HNSW key into that metadata rather than
1769    /// replacing it -- otherwise the new schema is not a superset of the
1770    /// current one and `RecordBatch::with_schema` fails with "target schema is
1771    /// not superset of current schema".
1772    #[tokio::test]
1773    async fn test_to_batch_loaded_preserves_extra_schema_metadata() {
1774        use super::HNSW_METADATA_KEY;
1775
1776        const DIM: usize = 24;
1777        const TOTAL: usize = 512;
1778        let fsl =
1779            FixedSizeListArray::try_new_from_values(generate_random_array(TOTAL * DIM), DIM as i32)
1780                .unwrap();
1781        let store = Arc::new(FlatFloatStorage::new(fsl, DistanceType::L2));
1782        let builder = HNSW::index_vectors(
1783            store.as_ref(),
1784            HnswBuildParams::default().num_edges(16).ef_construction(50),
1785        )
1786        .unwrap();
1787
1788        // Simulate the disk-load path (`ivf/v2.rs::load_partition_entry`):
1789        // the batch reaching `HNSW::load` carries the index file's schema
1790        // metadata in addition to the HNSW key.
1791        let built_batch = builder.to_batch().unwrap();
1792        let mut metadata = built_batch.schema_ref().metadata().clone();
1793        metadata.insert(
1794            "lance:index_metadata".to_string(),
1795            "{\"distance_type\":\"l2\"}".to_string(),
1796        );
1797        metadata.insert("lance:ivf".to_string(), "42".to_string());
1798        let schema = built_batch
1799            .schema()
1800            .as_ref()
1801            .clone()
1802            .with_metadata(metadata);
1803        let batch_with_extra =
1804            RecordBatch::try_new(Arc::new(schema), built_batch.columns().to_vec()).unwrap();
1805
1806        let loaded = HNSW::load(batch_with_extra).unwrap();
1807        assert!(matches!(loaded.inner.graph, HnswGraph::Loaded(_)));
1808
1809        // Before the fix this fails: the re-stamped schema dropped the extra
1810        // keys, so `with_schema`'s superset check rejected the round-trip.
1811        let out = loaded.to_batch().unwrap();
1812        let out_metadata = out.schema_ref().metadata();
1813        assert!(out_metadata.contains_key(HNSW_METADATA_KEY));
1814        assert_eq!(
1815            out_metadata.get("lance:index_metadata").map(String::as_str),
1816            Some("{\"distance_type\":\"l2\"}"),
1817        );
1818        assert_eq!(
1819            out_metadata.get("lance:ivf").map(String::as_str),
1820            Some("42")
1821        );
1822
1823        // The HNSW key must still decode to valid metadata after the merge.
1824        let reloaded = HNSW::load(out).unwrap();
1825        assert_eq!(reloaded.len(), loaded.len());
1826    }
1827
1828    /// The loaded graph shares the Arrow batch and reconstructs no per-node
1829    /// `Vec<GraphBuilderNode>` / `Vec<OrderedNode>`, so it is strictly
1830    /// lighter than the in-memory build representation.
1831    #[tokio::test]
1832    async fn test_loaded_graph_is_arrow_backed() {
1833        const DIM: usize = 32;
1834        const TOTAL: usize = 2048;
1835        let fsl =
1836            FixedSizeListArray::try_new_from_values(generate_random_array(TOTAL * DIM), DIM as i32)
1837                .unwrap();
1838        let store = Arc::new(FlatFloatStorage::new(fsl, DistanceType::L2));
1839        let builder = HNSW::index_vectors(
1840            store.as_ref(),
1841            HnswBuildParams::default().num_edges(20).ef_construction(50),
1842        )
1843        .unwrap();
1844        assert!(!matches!(builder.inner.graph, HnswGraph::Loaded(_)));
1845
1846        let loaded = HNSW::load(builder.to_batch().unwrap()).unwrap();
1847        assert!(matches!(loaded.inner.graph, HnswGraph::Loaded(_)));
1848        assert!(
1849            loaded.deep_size_of() < builder.deep_size_of(),
1850            "loaded graph ({}) should be lighter than built ({})",
1851            loaded.deep_size_of(),
1852            builder.deep_size_of(),
1853        );
1854    }
1855}