From 9fe1038ace4a3fbbae1c447e8099be2ed5136779 Mon Sep 17 00:00:00 2001 From: Minoru OSUKA Date: Sun, 12 Jul 2026 23:10:39 +0900 Subject: [PATCH] perf(vector/index): intern field names in memory as (u64, u16) + per-segment dictionary (#859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final piece of the Issue #633 field-name work: readers keep one shared Arc<[Arc]> dictionary per segment and (doc_id, u16) pairs per record instead of one heap String per record per retained structure. - FieldInterner (format.rs): v3 segments use the header dictionary (fixed, ids validated); v1/v2 segments synthesize it at load in first-appearance order, so legacy files get the same interned representation with no version branching afterwards - readers: vector_ids becomes Vec<(u64, u16)>; IVF cluster_to_vectors likewise (its eager path used to retain 3 String copies per record); per-field caches and pool build inputs keep their String shapes (transient clones only, nothing per-record retained) - VectorStorage::get/contains take (doc_id, &str) and the OnDemand arm resolves the name against the segment dictionary by linear scan — the per-candidate field_name.to_string() on the mmap-default f32 paths is gone structurally; OnDemand offsets are keyed (u64, u16) - IVF searcher: probe results stay (u64, u16), the field filter resolves once per search and compares u16 per candidate, and names rehydrate only for emitted top-k hits - public trait signatures unchanged: vector_ids(), iterators, and the other boundary methods rehydrate from the dictionary at return time RAM accounting (structural): a retained per-record String costs ~24B header + heap + allocator overhead, x2 retained copies (HNSW/Flat) or x3 (IVF); the interned pair costs 16B inline with zero per-record heap. Eager gate bench neutral (del0 33.4us / del10 29.3us, inside the post-#854 envelope). --- laurus/src/vector/index/flat/reader.rs | 183 +++++++++++--------- laurus/src/vector/index/format.rs | 183 ++++++++++++++++++++ laurus/src/vector/index/hnsw/reader.rs | 211 ++++++++++++++---------- laurus/src/vector/index/ivf/reader.rs | 183 ++++++++++++-------- laurus/src/vector/index/ivf/searcher.rs | 120 ++++++++------ laurus/src/vector/index/storage.rs | 77 +++++++-- 6 files changed, 654 insertions(+), 303 deletions(-) diff --git a/laurus/src/vector/index/flat/reader.rs b/laurus/src/vector/index/flat/reader.rs index 763ca201..186bab9c 100644 --- a/laurus/src/vector/index/flat/reader.rs +++ b/laurus/src/vector/index/flat/reader.rs @@ -8,7 +8,9 @@ use crate::storage::Storage; use crate::vector::core::distance::DistanceMetric; use crate::vector::core::quantization::QuantizedVectorMeta; use crate::vector::core::vector::Vector; -use crate::vector::index::format::{QuantHeader, VectorSegmentHeader, record_prefix_size}; +use crate::vector::index::format::{ + FieldInterner, QuantHeader, VectorSegmentHeader, record_prefix_size, +}; use crate::vector::index::quantized_io::quantized_record_payload_size; use crate::vector::index::quantized_storage::QuantizedVectorPool; use crate::vector::reader::{ValidationReport, VectorIndexMetadata, VectorStats}; @@ -22,7 +24,12 @@ use crate::vector::index::storage::VectorStorage; #[derive(Debug)] pub struct FlatVectorIndexReader { vectors: VectorStorage, - vector_ids: Vec<(u64, String)>, + /// `(doc_id, field_id)` per record; ids index [`Self::field_dict`] + /// (Issue #633 PR-B — interned, no per-record heap `String`). + vector_ids: Vec<(u64, u16)>, + /// Per-segment field-name dictionary (synthesized at load for + /// v1/v2 segments, taken from the header for v3). + field_dict: Arc<[Arc]>, dimension: usize, distance_metric: DistanceMetric, deletion_bitmap: Option>, @@ -34,19 +41,17 @@ pub struct FlatVectorIndexReader { /// Group `vector_ids` by field name into refcount-shared slices. fn build_vector_ids_by_field( - vector_ids: &[(u64, String)], + vector_ids: &[(u64, u16)], + field_dict: &[Arc], ) -> std::collections::HashMap> { - let mut by_field: std::collections::HashMap> = - std::collections::HashMap::new(); - for (doc_id, field_name) in vector_ids { - by_field - .entry(field_name.clone()) - .or_default() - .push(*doc_id); + let mut by_field: Vec> = vec![Vec::new(); field_dict.len()]; + for &(doc_id, fid) in vector_ids { + by_field[fid as usize].push(doc_id); } - by_field - .into_iter() - .map(|(field, ids)| (field, Arc::<[u64]>::from(ids))) + field_dict + .iter() + .zip(by_field) + .map(|(field, ids)| (field.to_string(), Arc::<[u64]>::from(ids))) .collect() } @@ -134,7 +139,11 @@ impl FlatVectorIndexReader { let record_stride = record_prefix_size(header.version) + quantized_record_payload_size(dimension) as u64; - let (vectors, vector_ids) = match storage.loading_mode() { + // Interned field ids (Issue #633 PR-B): one shared dictionary per + // segment instead of one heap `String` per record. + let mut interner = FieldInterner::from_header(&header); + + let (vectors, vector_ids, field_dict) = match storage.loading_mode() { crate::storage::LoadingMode::Eager => { // Step 7 of #481 Stage 1: load vectors as int8 + meta // directly into a QuantizedVectorPool. @@ -153,8 +162,10 @@ impl FlatVectorIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - // Field reference: dictionary id (v3+) or inline name. - let field_name = header.read_record_field( + // Field reference: interned id (v3 = dictionary id, + // v1/v2 = inline name interned on first appearance). + let fid = interner.read_record_field_id( + &header, &mut input, records_remaining, "flat field_name_len", @@ -172,11 +183,18 @@ impl FlatVectorIndexReader { norm_q: f32::from_le_bytes(norm_q_buf), }; - vector_ids.push((doc_id, field_name.clone())); - records.push((doc_id, field_name, int8, meta)); + vector_ids.push((doc_id, fid)); + // The pool's build input keeps the String shape; this + // clone is transient (the pool retains only per-field + // grouping keys), so nothing per-record is kept. + records.push((doc_id, interner.name(fid).to_string(), int8, meta)); } let pool = QuantizedVectorPool::build(params, dimension, records); - (VectorStorage::OwnedQuantized(Arc::new(pool)), vector_ids) + ( + VectorStorage::OwnedQuantized(Arc::new(pool)), + vector_ids, + interner.into_dict(), + ) } crate::storage::LoadingMode::Lazy => { checked_capacity( @@ -204,7 +222,8 @@ impl FlatVectorIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, records_remaining, "flat field_name_len", @@ -214,8 +233,8 @@ impl FlatVectorIndexReader { // record prefix), so `VectorStorage::get` seeks straight // to the int8 data without re-parsing the prefix. let payload_offset = input.stream_position().map_err(LaurusError::Io)?; - offsets.insert((doc_id, field_name.clone()), payload_offset); - vector_ids.push((doc_id, field_name)); + offsets.insert((doc_id, fid), payload_offset); + vector_ids.push((doc_id, fid)); // Skip int8 payload + per-vector meta. input @@ -223,23 +242,27 @@ impl FlatVectorIndexReader { .map_err(LaurusError::Io)?; } + let field_dict = interner.into_dict(); ( VectorStorage::OnDemand { storage: storage.clone(), file_name: file_name.clone(), offsets: Arc::new(offsets), + field_dict: field_dict.clone(), quant_params: Some(params), cached_input: Arc::new(std::sync::RwLock::new(None)), }, vector_ids, + field_dict, ) } }; - let vector_ids_by_field = build_vector_ids_by_field(&vector_ids); + let vector_ids_by_field = build_vector_ids_by_field(&vector_ids, &field_dict); Ok(Self { vectors, vector_ids, + field_dict, dimension, distance_metric, deletion_bitmap: None, @@ -276,18 +299,18 @@ impl VectorIndexReader for FlatVectorIndexReader { if self.is_deleted(doc_id) { return Ok(None); } - self.vectors - .get(&(doc_id, field_name.to_string()), self.dimension) + self.vectors.get(doc_id, field_name, self.dimension) } fn get_vectors_for_doc(&self, doc_id: u64) -> Result> { let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if *id == doc_id - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if id == doc_id + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field, self.dimension)? { - result.push((field.clone(), vec)); + result.push((field.to_string(), vec)); } } Ok(result) @@ -299,14 +322,20 @@ impl VectorIndexReader for FlatVectorIndexReader { if self.is_deleted(*id) { result.push(None); } else { - result.push(self.vectors.get(&(*id, field.clone()), self.dimension)?); + result.push(self.vectors.get(*id, field, self.dimension)?); } } Ok(result) } fn vector_ids(&self) -> Result> { - Ok(self.vector_ids.clone()) + // Rehydrated at the trait boundary (Issue #633 PR-B): internal + // state is `(u64, u16)` + the shared dictionary. + Ok(self + .vector_ids + .iter() + .map(|&(id, fid)| (id, self.field_dict[fid as usize].to_string())) + .collect()) } fn doc_ids_for_field(&self, field_name: &str) -> Arc<[u64]> { @@ -354,20 +383,7 @@ impl VectorIndexReader for FlatVectorIndexReader { } fn contains_vector(&self, doc_id: u64, field_name: &str) -> bool { - match &self.vectors { - VectorStorage::Owned(vectors) => { - vectors.contains_key(&(doc_id, field_name.to_string())) - } - VectorStorage::OwnedQuantized(pool) => pool.contains(doc_id, field_name), - VectorStorage::OwnedPq(pool) => pool.contains(doc_id, field_name), - #[cfg(feature = "pq-fastscan")] - VectorStorage::OwnedPqFastScan(_) => { - unreachable!("Flat reader rejects PQ FastScan at the segment header (HNSW-only)") - } - VectorStorage::OnDemand { offsets, .. } => { - offsets.contains_key(&(doc_id, field_name.to_string())) - } - } + self.vectors.contains(doc_id, field_name) } fn get_vector_range( @@ -376,41 +392,47 @@ impl VectorIndexReader for FlatVectorIndexReader { end_doc_id: u64, ) -> Result> { let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if *id >= start_doc_id - && *id < end_doc_id - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if id >= start_doc_id + && id < end_doc_id + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field, self.dimension)? { - result.push((*id, field.clone(), vec)); + result.push((id, field.to_string(), vec)); } } Ok(result) } fn get_vectors_by_field(&self, field_name: &str) -> Result> { + // One dictionary resolve, then integer compares per record. + let Some(target) = + crate::vector::index::format::resolve_field_id(&self.field_dict, field_name) + else { + return Ok(Vec::new()); + }; let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if field == field_name - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + if fid == target + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field_name, self.dimension)? { - result.push((*id, vec)); + result.push((id, vec)); } } Ok(result) } fn field_names(&self) -> Result> { - use std::collections::HashSet; - let fields: HashSet = self.vector_ids.iter().map(|val| val.1.clone()).collect(); - Ok(fields.into_iter().collect()) + Ok(self.field_dict.iter().map(|f| f.to_string()).collect()) } fn vector_iterator(&self) -> Result> { Ok(Box::new(FlatVectorIterator { storage: self.vectors.clone(), keys: self.vector_ids.clone(), + field_dict: self.field_dict.clone(), current: 0, dimension: self.dimension, deletion_bitmap: self.deletion_bitmap.clone(), @@ -442,7 +464,9 @@ impl VectorIndexReader for FlatVectorIndexReader { match &self.vectors { VectorStorage::Owned(map) => { - for (id, field) in &self.vector_ids { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + let (id, field) = (&id, &field.to_string()); if let Some(vector) = map.get(&(*id, field.clone())) { if vector.dimension() != self.dimension { errors.push(format!( @@ -468,7 +492,9 @@ impl VectorIndexReader for FlatVectorIndexReader { } } VectorStorage::OwnedQuantized(pool) => { - for (id, field) in &self.vector_ids { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + let id = &id; if !pool.contains(*id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in quantized pool", @@ -483,7 +509,9 @@ impl VectorIndexReader for FlatVectorIndexReader { ); } VectorStorage::OwnedPq(pool) => { - for (id, field) in &self.vector_ids { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + let id = &id; if !pool.contains(*id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in PQ pool", @@ -502,8 +530,10 @@ impl VectorIndexReader for FlatVectorIndexReader { unreachable!("Flat reader rejects PQ FastScan at the segment header (HNSW-only)") } VectorStorage::OnDemand { offsets, .. } => { - for (id, field) in &self.vector_ids { - if !offsets.contains_key(&(*id, field.clone())) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + let id = &id; + if !offsets.contains_key(&(*id, fid)) { errors.push(format!( "Vector {}:{} in ids but missing in storage", id, field @@ -526,7 +556,8 @@ impl VectorIndexReader for FlatVectorIndexReader { /// Iterator for flat vector index. struct FlatVectorIterator { storage: VectorStorage, - keys: Vec<(u64, String)>, + keys: Vec<(u64, u16)>, + field_dict: Arc<[Arc]>, current: usize, dimension: usize, deletion_bitmap: Option>, @@ -537,22 +568,20 @@ impl VectorIterator for FlatVectorIterator { // Use a loop instead of recursion to avoid stack overflow when // many consecutive entries are deleted. while self.current < self.keys.len() { - let (doc_id, field) = &self.keys[self.current]; + let (doc_id, fid) = self.keys[self.current]; + let field = &self.field_dict[fid as usize]; // Skip deleted entries if let Some(bitmap) = &self.deletion_bitmap - && bitmap.is_deleted(*doc_id) + && bitmap.is_deleted(doc_id) { self.current += 1; continue; } - if let Some(vec) = self - .storage - .get(&(*doc_id, field.clone()), self.dimension)? - { + if let Some(vec) = self.storage.get(doc_id, field, self.dimension)? { self.current += 1; - return Ok(Some((*doc_id, field.clone(), vec))); + return Ok(Some((doc_id, field.to_string(), vec))); } else { return Err(LaurusError::internal(format!( "Vector {}:{} found in keys but missing in storage", @@ -566,8 +595,9 @@ impl VectorIterator for FlatVectorIterator { fn skip_to(&mut self, doc_id: u64, field_name: &str) -> Result { while self.current < self.keys.len() { - let (id, field) = &self.keys[self.current]; - if *id > doc_id || (*id == doc_id && field.as_str() >= field_name) { + let (id, fid) = self.keys[self.current]; + let field = &self.field_dict[fid as usize]; + if id > doc_id || (id == doc_id && field.as_ref() as &str >= field_name) { return Ok(true); } self.current += 1; @@ -577,7 +607,8 @@ impl VectorIterator for FlatVectorIterator { fn position(&self) -> (u64, String) { if self.current < self.keys.len() { - self.keys[self.current].clone() + let (id, fid) = self.keys[self.current]; + (id, self.field_dict[fid as usize].to_string()) } else { (u64::MAX, String::new()) } diff --git a/laurus/src/vector/index/format.rs b/laurus/src/vector/index/format.rs index 75fbe0a1..c5b8f574 100644 --- a/laurus/src/vector/index/format.rs +++ b/laurus/src/vector/index/format.rs @@ -627,6 +627,137 @@ impl VectorSegmentHeader { } } +/// Per-segment field-name interner for reader load paths (Issue #633, +/// PR-B). +/// +/// Readers parse records into `(doc_id, u16)` pairs plus one shared +/// dictionary instead of one heap `String` per record: +/// +/// * v3 segments: the dictionary comes fixed from the header; record +/// ids are validated against it. +/// * v1/v2 segments: the dictionary is synthesized on the fly in +/// first-appearance order, so legacy files get the same interned +/// in-memory representation. +pub(crate) struct FieldInterner { + names: Vec>, + /// Whether the dictionary is fixed by a v3 header (no growth). + fixed: bool, +} + +impl FieldInterner { + /// Build an interner for the given parsed header. + /// + /// # Arguments + /// + /// * `header` - The segment's parsed LVS header. + /// + /// # Returns + /// + /// A fixed interner seeded from the v3 dictionary, or an empty + /// growable one for v1/v2 segments. + pub(crate) fn from_header(header: &VectorSegmentHeader) -> Self { + if header.version >= VERSION_FIELD_DICT { + Self { + names: header + .field_dict + .iter() + .map(|s| std::sync::Arc::from(s.as_str())) + .collect(), + fixed: true, + } + } else { + Self { + names: Vec::new(), + fixed: false, + } + } + } + + /// Read one record's field reference and return its interned id. + /// + /// v3: reads the `field_id u16` and validates it against the fixed + /// dictionary. v1/v2: reads the inline name and interns it + /// (first-appearance order). + /// + /// # Arguments + /// + /// * `header` - The segment's parsed header (drives the version). + /// * `reader` - Stream positioned right after the record's doc id. + /// * `records_remaining` - Bound for the inline-name allocation. + /// * `what` - Label for corruption error messages. + /// + /// # Returns + /// + /// The record's field id within [`Self::dict`]. + pub(crate) fn read_record_field_id( + &mut self, + header: &VectorSegmentHeader, + reader: &mut R, + records_remaining: u64, + what: &str, + ) -> Result { + if self.fixed { + let mut id_bytes = [0u8; 2]; + reader.read_exact(&mut id_bytes)?; + let field_id = u16::from_le_bytes(id_bytes); + if field_id as usize >= self.names.len() { + return Err(LaurusError::index(format!( + "vector segment corrupt: record field_id {field_id} out of \ + dictionary range ({} entries)", + self.names.len() + ))); + } + Ok(field_id) + } else { + let name = header.read_record_field(reader, records_remaining, what)?; + if let Some(pos) = self.names.iter().position(|n| **n == *name) { + return Ok(pos as u16); + } + if self.names.len() >= u16::MAX as usize { + return Err(LaurusError::index(format!( + "vector segment has more than {} distinct field names", + u16::MAX + ))); + } + self.names.push(std::sync::Arc::from(name.as_str())); + Ok((self.names.len() - 1) as u16) + } + } + + /// Borrow the interned name for `fid` (valid ids only — callers + /// pass ids this interner just produced). + pub(crate) fn name(&self, fid: u16) -> &std::sync::Arc { + &self.names[fid as usize] + } + + /// Finalize into the per-segment shared dictionary. + pub(crate) fn into_dict(self) -> std::sync::Arc<[std::sync::Arc]> { + std::sync::Arc::from(self.names) + } +} + +/// Resolve a field name to its id in a per-segment dictionary by linear +/// scan (Issue #633 PR-B). +/// +/// Dictionaries hold 1–3 entries in practice, so a scan beats hashing +/// and allocates nothing — this is the once-per-call replacement for +/// the former per-call `field_name.to_string()` key materialization. +/// +/// # Arguments +/// +/// * `dict` - The segment's field dictionary. +/// * `field_name` - The name to resolve. +/// +/// # Returns +/// +/// The field id, or `None` when the segment has no such field. +#[inline] +pub(crate) fn resolve_field_id(dict: &[std::sync::Arc], field_name: &str) -> Option { + dict.iter() + .position(|n| **n == *field_name) + .map(|pos| pos as u16) +} + /// Size in bytes of the per-record prefix (`doc_id` + field reference) /// for the given header version (Issue #633). /// @@ -832,6 +963,58 @@ mod tests { ); } + #[test] + fn interner_synthesizes_dict_for_legacy_versions() { + let header = VectorSegmentHeader::scalar_8bit(sample_params()); + let mut interner = FieldInterner::from_header(&header); + + let mut rec = |name: &str| { + let mut bytes = (name.len() as u32).to_le_bytes().to_vec(); + bytes.extend_from_slice(name.as_bytes()); + let mut cursor = Cursor::new(bytes); + interner + .read_record_field_id(&header, &mut cursor, 64, "test") + .unwrap() + }; + assert_eq!(rec("b"), 0); + assert_eq!(rec("a"), 1); + assert_eq!(rec("b"), 0, "repeat name must intern to the same id"); + + let dict = interner.into_dict(); + assert_eq!(dict.len(), 2); + assert_eq!(&*dict[0], "b"); + assert_eq!(&*dict[1], "a"); + } + + #[test] + fn interner_uses_fixed_dict_for_v3_and_rejects_out_of_range() { + let header = VectorSegmentHeader::scalar_8bit(sample_params()) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(vec!["f".to_string()]); + let mut interner = FieldInterner::from_header(&header); + + let mut ok = Cursor::new(0u16.to_le_bytes().to_vec()); + assert_eq!( + interner + .read_record_field_id(&header, &mut ok, 0, "test") + .unwrap(), + 0 + ); + let mut bad = Cursor::new(3u16.to_le_bytes().to_vec()); + let err = interner + .read_record_field_id(&header, &mut bad, 0, "test") + .unwrap_err(); + assert!(err.to_string().contains("out of"), "got: {err}"); + } + + #[test] + fn resolve_field_id_scans_the_dict() { + let dict: Vec> = + vec![std::sync::Arc::from("a"), std::sync::Arc::from("b")]; + assert_eq!(resolve_field_id(&dict, "b"), Some(1)); + assert_eq!(resolve_field_id(&dict, "missing"), None); + } + #[test] fn record_prefix_size_matches_the_version_ladder() { assert_eq!(record_prefix_size(CURRENT_VERSION), 12); diff --git a/laurus/src/vector/index/hnsw/reader.rs b/laurus/src/vector/index/hnsw/reader.rs index 60a48113..dbe6133a 100644 --- a/laurus/src/vector/index/hnsw/reader.rs +++ b/laurus/src/vector/index/hnsw/reader.rs @@ -10,7 +10,7 @@ use crate::vector::core::distance::DistanceMetric; use crate::vector::core::quantization::QuantizedVectorMeta; use crate::vector::core::vector::Vector; use crate::vector::index::format::{ - QuantHeader, VERSION_ORDINAL_GRAPH, VectorSegmentHeader, record_prefix_size, + FieldInterner, QuantHeader, VERSION_ORDINAL_GRAPH, VectorSegmentHeader, record_prefix_size, }; use crate::vector::index::hnsw::graph::OrdinalHnswGraph; use crate::vector::index::quantized_io::quantized_record_payload_size; @@ -28,17 +28,18 @@ use crate::vector::index::storage::VectorStorage; /// /// The grouping happens once at reader load so search-time /// `doc_ids_for_field` is a single HashMap lookup + Arc clone. -fn build_vector_ids_by_field(vector_ids: &[(u64, String)]) -> HashMap> { - let mut by_field: HashMap> = HashMap::new(); - for (doc_id, field_name) in vector_ids { - by_field - .entry(field_name.clone()) - .or_default() - .push(*doc_id); +fn build_vector_ids_by_field( + vector_ids: &[(u64, u16)], + field_dict: &[Arc], +) -> HashMap> { + let mut by_field: Vec> = vec![Vec::new(); field_dict.len()]; + for &(doc_id, fid) in vector_ids { + by_field[fid as usize].push(doc_id); } - by_field - .into_iter() - .map(|(field, ids)| (field, Arc::<[u64]>::from(ids))) + field_dict + .iter() + .zip(by_field) + .map(|(field, ids)| (field.to_string(), Arc::<[u64]>::from(ids))) .collect() } @@ -46,7 +47,12 @@ fn build_vector_ids_by_field(vector_ids: &[(u64, String)]) -> HashMap, + /// `(doc_id, field_id)` per record; ids index [`Self::field_dict`] + /// (Issue #633 PR-B — interned, no per-record heap `String`). + vector_ids: Vec<(u64, u16)>, + /// Per-segment field-name dictionary (synthesized at load for + /// v1/v2 segments, taken from the header for v3). + field_dict: Arc<[Arc]>, dimension: usize, distance_metric: DistanceMetric, m: usize, @@ -206,7 +212,7 @@ impl HnswIndexReader { /// /// The strictly ascending unique doc-id table, or an error if the /// record ids are not non-decreasing. - fn unique_sorted_doc_ids(vector_ids: &[(u64, String)]) -> Result> { + fn unique_sorted_doc_ids(vector_ids: &[(u64, u16)]) -> Result> { let mut unique: Vec = Vec::with_capacity(vector_ids.len()); for (doc_id, _) in vector_ids { match unique.last() { @@ -546,8 +552,12 @@ impl HnswIndexReader { // calls this its `vector_ids` are fully collected — the ordinal // table (ascending unique record doc ids) is derived from them. let graph_version = header.version; + + // Interned field ids (Issue #633 PR-B): one shared dictionary per + // segment instead of one heap `String` per record. + let mut interner = FieldInterner::from_header(&header); let read_graph = |input: &mut dyn crate::storage::StorageInput, - vector_ids: &[(u64, String)]| + vector_ids: &[(u64, u16)]| -> Result>> { let doc_ids = Self::unique_sorted_doc_ids(vector_ids)?; if graph_version >= VERSION_ORDINAL_GRAPH { @@ -563,7 +573,8 @@ impl HnswIndexReader { let records_remaining = file_size.saturating_sub(input.stream_position().map_err(LaurusError::Io)?); - let (vectors, vector_ids, graph) = match (&header.quant, storage.loading_mode()) { + let (vectors, vector_ids, graph, field_dict) = match (&header.quant, storage.loading_mode()) + { (QuantHeader::Scalar8Bit(params), crate::storage::LoadingMode::Eager) => { // Step 6 of #481 Stage 1: load vectors as int8 + meta // directly into a QuantizedVectorPool so the search @@ -592,7 +603,8 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, records_remaining, "hnsw field_name_len", @@ -609,8 +621,10 @@ impl HnswIndexReader { norm_q: f32::from_le_bytes(norm_q_buf), }; - vector_ids.push((doc_id, field_name.clone())); - records.push((doc_id, field_name, int8, meta)); + vector_ids.push((doc_id, fid)); + // Transient clone for the pool's String-shaped build + // input; the pool retains only per-field keys. + records.push((doc_id, interner.name(fid).to_string(), int8, meta)); } let graph = read_graph(&mut input, &vector_ids)?; let pool = QuantizedVectorPool::build(*params, dimension, records); @@ -618,6 +632,7 @@ impl HnswIndexReader { VectorStorage::OwnedQuantized(Arc::new(pool)), vector_ids, graph, + interner.into_dict(), ) } (QuantHeader::Scalar8Bit(params), crate::storage::LoadingMode::Lazy) => { @@ -649,7 +664,8 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, records_remaining, "hnsw field_name_len", @@ -659,8 +675,8 @@ impl HnswIndexReader { // record prefix), so `VectorStorage::get` seeks straight // to the int8 data (Issue #633). let payload_offset = input.stream_position().map_err(LaurusError::Io)?; - offsets.insert((doc_id, field_name.clone()), payload_offset); - vector_ids.push((doc_id, field_name.clone())); + offsets.insert((doc_id, fid), payload_offset); + vector_ids.push((doc_id, fid)); input .seek(std::io::SeekFrom::Current(quant_payload_size)) @@ -668,16 +684,19 @@ impl HnswIndexReader { } let graph = read_graph(&mut input, &vector_ids)?; + let field_dict = interner.into_dict(); ( VectorStorage::OnDemand { storage: storage.clone(), file_name: file_name.clone(), offsets: Arc::new(offsets), + field_dict: field_dict.clone(), quant_params: Some(*params), cached_input: Arc::new(std::sync::RwLock::new(None)), }, vector_ids, graph, + field_dict, ) } ( @@ -711,7 +730,8 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, records_remaining, "hnsw field_name_len", @@ -720,8 +740,8 @@ impl HnswIndexReader { let mut codes = vec![0u8; codes_size]; input.read_exact(&mut codes)?; - vector_ids.push((doc_id, field_name.clone())); - records.push((doc_id, field_name, codes)); + vector_ids.push((doc_id, fid)); + records.push((doc_id, interner.name(fid).to_string(), codes)); } let graph = read_graph(&mut input, &vector_ids)?; let pool = crate::vector::index::pq_storage::PqVectorPool::build( @@ -729,7 +749,12 @@ impl HnswIndexReader { codebook.clone(), records, ); - (VectorStorage::OwnedPq(Arc::new(pool)), vector_ids, graph) + ( + VectorStorage::OwnedPq(Arc::new(pool)), + vector_ids, + graph, + interner.into_dict(), + ) } #[cfg(feature = "pq-fastscan")] ( @@ -760,7 +785,8 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, records_remaining, "hnsw field_name_len", @@ -770,8 +796,8 @@ impl HnswIndexReader { &mut input, *pq_params, )?; - vector_ids.push((doc_id, field_name.clone())); - records.push((doc_id, field_name, codes)); + vector_ids.push((doc_id, fid)); + records.push((doc_id, interner.name(fid).to_string(), codes)); } let graph = read_graph(&mut input, &vector_ids)?; let pool = crate::vector::index::pq_fastscan_storage::PqFastScanPool::build( @@ -783,6 +809,7 @@ impl HnswIndexReader { VectorStorage::OwnedPqFastScan(Arc::new(pool)), vector_ids, graph, + interner.into_dict(), ) } }; @@ -857,7 +884,7 @@ impl HnswIndexReader { // Per-field doc-id lookup (#405). Built from `vector_ids` so it // reflects the same set the search path used to filter at every // call; finalised into `Arc<[u64]>` for cheap clone semantics. - let vector_ids_by_field = build_vector_ids_by_field(&vector_ids); + let vector_ids_by_field = build_vector_ids_by_field(&vector_ids, &field_dict); // Per-field ordinal → pool-position tables (Issue #686). The // identity `ordinal == position` holds exactly when the segment @@ -921,12 +948,18 @@ impl HnswIndexReader { header.vector_count ))); } + // Transient rehydration for the sidecar's String-shaped + // assignment input (eager-only path; nothing retained). + let assignment: Vec<(u64, String)> = vector_ids + .iter() + .map(|&(id, fid)| (id, field_dict[fid as usize].to_string())) + .collect(); let pool = RerankStoragePool::from_sidecar_payload( header.storage_kind, dimension, header.vector_count as usize, payload, - &vector_ids, + &assignment, )?; Some(Arc::new(pool)) } else { @@ -939,6 +972,7 @@ impl HnswIndexReader { Ok(Self { vectors, vector_ids, + field_dict, dimension, distance_metric, m, @@ -1072,18 +1106,18 @@ impl VectorIndexReader for HnswIndexReader { if self.is_deleted(doc_id) { return Ok(None); } - self.vectors - .get(&(doc_id, field_name.to_string()), self.dimension) + self.vectors.get(doc_id, field_name, self.dimension) } fn get_vectors_for_doc(&self, doc_id: u64) -> Result> { let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if *id == doc_id - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if id == doc_id + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field, self.dimension)? { - result.push((field.clone(), vec)); + result.push((field.to_string(), vec)); } } Ok(result) @@ -1095,14 +1129,19 @@ impl VectorIndexReader for HnswIndexReader { if self.is_deleted(*id) { result.push(None); } else { - result.push(self.vectors.get(&(*id, field.clone()), self.dimension)?); + result.push(self.vectors.get(*id, field, self.dimension)?); } } Ok(result) } fn vector_ids(&self) -> Result> { - Ok(self.vector_ids.clone()) + // Rehydrated at the trait boundary (Issue #633 PR-B). + Ok(self + .vector_ids + .iter() + .map(|&(id, fid)| (id, self.field_dict[fid as usize].to_string())) + .collect()) } fn doc_ids_for_field(&self, field_name: &str) -> Arc<[u64]> { @@ -1149,18 +1188,7 @@ impl VectorIndexReader for HnswIndexReader { } fn contains_vector(&self, doc_id: u64, field_name: &str) -> bool { - match &self.vectors { - VectorStorage::Owned(vectors) => { - vectors.contains_key(&(doc_id, field_name.to_string())) - } - VectorStorage::OwnedQuantized(pool) => pool.contains(doc_id, field_name), - VectorStorage::OwnedPq(pool) => pool.contains(doc_id, field_name), - #[cfg(feature = "pq-fastscan")] - VectorStorage::OwnedPqFastScan(pool) => pool.contains(doc_id, field_name), - VectorStorage::OnDemand { offsets, .. } => { - offsets.contains_key(&(doc_id, field_name.to_string())) - } - } + self.vectors.contains(doc_id, field_name) } fn get_vector_range( @@ -1169,41 +1197,47 @@ impl VectorIndexReader for HnswIndexReader { end_doc_id: u64, ) -> Result> { let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if *id >= start_doc_id - && *id < end_doc_id - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if id >= start_doc_id + && id < end_doc_id + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field, self.dimension)? { - result.push((*id, field.clone(), vec)); + result.push((id, field.to_string(), vec)); } } Ok(result) } fn get_vectors_by_field(&self, field_name: &str) -> Result> { + // One dictionary resolve, then integer compares per record. + let Some(target) = + crate::vector::index::format::resolve_field_id(&self.field_dict, field_name) + else { + return Ok(Vec::new()); + }; let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if field == field_name - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + if fid == target + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field_name, self.dimension)? { - result.push((*id, vec)); + result.push((id, vec)); } } Ok(result) } fn field_names(&self) -> Result> { - use std::collections::HashSet; - let fields: HashSet = self.vector_ids.iter().map(|val| val.1.clone()).collect(); - Ok(fields.into_iter().collect()) + Ok(self.field_dict.iter().map(|f| f.to_string()).collect()) } fn vector_iterator(&self) -> Result> { Ok(Box::new(HnswVectorIterator { storage: self.vectors.clone(), keys: self.vector_ids.clone(), + field_dict: self.field_dict.clone(), current: 0, dimension: self.dimension, deletion_bitmap: self.deletion_bitmap.clone(), @@ -1235,7 +1269,9 @@ impl VectorIndexReader for HnswIndexReader { match &self.vectors { VectorStorage::Owned(map) => { - for (id, field) in &self.vector_ids { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + let (id, field) = (&id, &field.to_string()); if let Some(vector) = map.get(&(*id, field.clone())) { if vector.dimension() != self.dimension { errors.push(format!( @@ -1261,8 +1297,9 @@ impl VectorIndexReader for HnswIndexReader { } } VectorStorage::OwnedQuantized(pool) => { - for (id, field) in &self.vector_ids { - if !pool.contains(*id, field) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !pool.contains(id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in quantized pool", id, field @@ -1276,8 +1313,9 @@ impl VectorIndexReader for HnswIndexReader { ); } VectorStorage::OwnedPq(pool) => { - for (id, field) in &self.vector_ids { - if !pool.contains(*id, field) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !pool.contains(id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in PQ pool", id, field @@ -1292,8 +1330,9 @@ impl VectorIndexReader for HnswIndexReader { } #[cfg(feature = "pq-fastscan")] VectorStorage::OwnedPqFastScan(pool) => { - for (id, field) in &self.vector_ids { - if !pool.contains(*id, field) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !pool.contains(id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in PQ FastScan pool", id, field @@ -1307,8 +1346,9 @@ impl VectorIndexReader for HnswIndexReader { ); } VectorStorage::OnDemand { offsets, .. } => { - for (id, field) in &self.vector_ids { - if !offsets.contains_key(&(*id, field.clone())) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !offsets.contains_key(&(id, fid)) { errors.push(format!( "Vector {}:{} in ids but missing in storage", id, field @@ -1342,7 +1382,8 @@ impl VectorIndexReader for HnswIndexReader { /// Iterator for HNSW vector index. struct HnswVectorIterator { storage: VectorStorage, - keys: Vec<(u64, String)>, + keys: Vec<(u64, u16)>, + field_dict: Arc<[Arc]>, current: usize, dimension: usize, deletion_bitmap: Option>, @@ -1353,22 +1394,20 @@ impl VectorIterator for HnswVectorIterator { // Use a loop instead of recursion to avoid stack overflow when // many consecutive entries are deleted. while self.current < self.keys.len() { - let (doc_id, field) = &self.keys[self.current]; + let (doc_id, fid) = self.keys[self.current]; + let field = &self.field_dict[fid as usize]; // Skip deleted entries if let Some(bitmap) = &self.deletion_bitmap - && bitmap.is_deleted(*doc_id) + && bitmap.is_deleted(doc_id) { self.current += 1; continue; } - if let Some(vec) = self - .storage - .get(&(*doc_id, field.clone()), self.dimension)? - { + if let Some(vec) = self.storage.get(doc_id, field, self.dimension)? { self.current += 1; - return Ok(Some((*doc_id, field.clone(), vec))); + return Ok(Some((doc_id, field.to_string(), vec))); } else { return Err(LaurusError::internal(format!( "Vector {}:{} found in keys but missing in storage", @@ -1381,8 +1420,9 @@ impl VectorIterator for HnswVectorIterator { fn skip_to(&mut self, doc_id: u64, field_name: &str) -> Result { while self.current < self.keys.len() { - let (id, field) = &self.keys[self.current]; - if *id > doc_id || (*id == doc_id && field.as_str() >= field_name) { + let (id, fid) = self.keys[self.current]; + let field = &self.field_dict[fid as usize]; + if id > doc_id || (id == doc_id && field.as_ref() as &str >= field_name) { return Ok(true); } self.current += 1; @@ -1392,7 +1432,8 @@ impl VectorIterator for HnswVectorIterator { fn position(&self) -> (u64, String) { if self.current < self.keys.len() { - self.keys[self.current].clone() + let (id, fid) = self.keys[self.current]; + (id, self.field_dict[fid as usize].to_string()) } else { (u64::MAX, String::new()) } diff --git a/laurus/src/vector/index/ivf/reader.rs b/laurus/src/vector/index/ivf/reader.rs index 287db453..31357115 100644 --- a/laurus/src/vector/index/ivf/reader.rs +++ b/laurus/src/vector/index/ivf/reader.rs @@ -9,7 +9,9 @@ use crate::storage::Storage; use crate::vector::core::distance::DistanceMetric; use crate::vector::core::quantization::QuantizedVectorMeta; use crate::vector::core::vector::Vector; -use crate::vector::index::format::{QuantHeader, VectorSegmentHeader, record_prefix_size}; +use crate::vector::index::format::{ + FieldInterner, QuantHeader, VectorSegmentHeader, record_prefix_size, resolve_field_id, +}; use crate::vector::index::quantized_io::quantized_record_payload_size; use crate::vector::index::quantized_storage::QuantizedVectorPool; use crate::vector::index::storage::VectorStorage; @@ -25,15 +27,20 @@ use std::io::SeekFrom; #[derive(Debug)] pub struct IvfIndexReader { vectors: VectorStorage, - vector_ids: Vec<(u64, String)>, + /// `(doc_id, field_id)` per record; ids index [`Self::field_dict`] + /// (Issue #633 PR-B — interned, no per-record heap `String`). + vector_ids: Vec<(u64, u16)>, + /// Per-segment field-name dictionary (synthesized at load for + /// v1/v2 segments, taken from the header for v3). + field_dict: Arc<[Arc]>, dimension: usize, distance_metric: DistanceMetric, n_clusters: usize, n_probe: usize, centroids: Vec, /// Per-cluster inverted list: `cluster_to_vectors[i]` contains the - /// `(doc_id, field_name)` pairs assigned to cluster `i`. - cluster_to_vectors: Vec>, + /// `(doc_id, field_id)` pairs assigned to cluster `i`. + cluster_to_vectors: Vec>, deletion_bitmap: Option>, /// Pre-built per-field doc-id list (`field_name → Arc<[u64]>`). Built /// once at load so `doc_ids_for_field` returns a refcount-shared @@ -42,17 +49,18 @@ pub struct IvfIndexReader { } /// Group `vector_ids` by field name into refcount-shared slices. -fn build_vector_ids_by_field(vector_ids: &[(u64, String)]) -> HashMap> { - let mut by_field: HashMap> = HashMap::new(); - for (doc_id, field_name) in vector_ids { - by_field - .entry(field_name.clone()) - .or_default() - .push(*doc_id); - } - by_field - .into_iter() - .map(|(field, ids)| (field, Arc::<[u64]>::from(ids))) +fn build_vector_ids_by_field( + vector_ids: &[(u64, u16)], + field_dict: &[Arc], +) -> HashMap> { + let mut by_field: Vec> = vec![Vec::new(); field_dict.len()]; + for &(doc_id, fid) in vector_ids { + by_field[fid as usize].push(doc_id); + } + field_dict + .iter() + .zip(by_field) + .map(|(field, ids)| (field.to_string(), Arc::<[u64]>::from(ids))) .collect() } @@ -174,9 +182,13 @@ impl IvfIndexReader { // Read inverted lists, preserving per-cluster grouping. Each cluster // serializes at least its list_size (4 bytes). checked_capacity(n_clusters, 4, lists_remaining, "ivf cluster lists")?; - let mut cluster_to_vectors: Vec> = Vec::with_capacity(n_clusters); + let mut cluster_to_vectors: Vec> = Vec::with_capacity(n_clusters); - let (vectors, vector_ids) = match storage.loading_mode() { + // Interned field ids (Issue #633 PR-B): one shared dictionary per + // segment instead of 3 retained `String`s per record. + let mut interner = FieldInterner::from_header(&header); + + let (vectors, vector_ids, field_dict) = match storage.loading_mode() { crate::storage::LoadingMode::Eager => { checked_capacity( num_vectors, @@ -200,7 +212,8 @@ impl IvfIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, lists_remaining, "ivf field_name_len", @@ -218,15 +231,20 @@ impl IvfIndexReader { norm_q: f32::from_le_bytes(norm_q_buf), }; - let key = (doc_id, field_name.clone()); - cluster_vecs.push(key.clone()); - vector_ids.push(key.clone()); - records.push((doc_id, field_name, int8, meta)); + cluster_vecs.push((doc_id, fid)); + vector_ids.push((doc_id, fid)); + // Transient clone for the pool's String-shaped build + // input; the pool retains only per-field keys. + records.push((doc_id, interner.name(fid).to_string(), int8, meta)); } cluster_to_vectors.push(cluster_vecs); } let pool = QuantizedVectorPool::build(params, dimension, records); - (VectorStorage::OwnedQuantized(Arc::new(pool)), vector_ids) + ( + VectorStorage::OwnedQuantized(Arc::new(pool)), + vector_ids, + interner.into_dict(), + ) } crate::storage::LoadingMode::Lazy => { checked_capacity( @@ -251,7 +269,8 @@ impl IvfIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let field_name = header.read_record_field( + let fid = interner.read_record_field_id( + &header, &mut input, lists_remaining, "ivf field_name_len", @@ -261,10 +280,9 @@ impl IvfIndexReader { // record prefix), so `VectorStorage::get` seeks // straight to the int8 data (Issue #633). let payload_offset = input.stream_position().map_err(LaurusError::Io)?; - let key = (doc_id, field_name); - offsets.insert(key.clone(), payload_offset); - cluster_vecs.push(key.clone()); - vector_ids.push(key); + offsets.insert((doc_id, fid), payload_offset); + cluster_vecs.push((doc_id, fid)); + vector_ids.push((doc_id, fid)); // Skip int8 payload + per-vector meta. input @@ -273,20 +291,23 @@ impl IvfIndexReader { } cluster_to_vectors.push(cluster_vecs); } + let field_dict = interner.into_dict(); ( VectorStorage::OnDemand { storage: storage.clone(), file_name: file_name.clone(), offsets: Arc::new(offsets), + field_dict: field_dict.clone(), quant_params: Some(params), cached_input: Arc::new(std::sync::RwLock::new(None)), }, vector_ids, + field_dict, ) } }; - let vector_ids_by_field = build_vector_ids_by_field(&vector_ids); + let vector_ids_by_field = build_vector_ids_by_field(&vector_ids, &field_dict); Ok(Self { vectors, vector_ids, @@ -296,6 +317,7 @@ impl IvfIndexReader { n_probe, centroids, cluster_to_vectors, + field_dict, deletion_bitmap: None, vector_ids_by_field, }) @@ -340,7 +362,13 @@ impl IvfIndexReader { /// /// A slice of `(doc_id, field_name)` pairs, or an empty slice if /// `cluster_idx` is out of range. - pub fn cluster_vectors(&self, cluster_idx: usize) -> &[(u64, String)] { + /// Borrow the per-segment field-name dictionary (Issue #633 PR-B), + /// shared with the searcher so probe results can stay `(u64, u16)`. + pub(crate) fn field_dict(&self) -> Arc<[Arc]> { + self.field_dict.clone() + } + + pub fn cluster_vectors(&self, cluster_idx: usize) -> &[(u64, u16)] { self.cluster_to_vectors .get(cluster_idx) .map(|v| v.as_slice()) @@ -357,18 +385,18 @@ impl VectorIndexReader for IvfIndexReader { if self.is_deleted(doc_id) { return Ok(None); } - self.vectors - .get(&(doc_id, field_name.to_string()), self.dimension) + self.vectors.get(doc_id, field_name, self.dimension) } fn get_vectors_for_doc(&self, doc_id: u64) -> Result> { let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if *id == doc_id - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if id == doc_id + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field, self.dimension)? { - result.push((field.clone(), vec)); + result.push((field.to_string(), vec)); } } Ok(result) @@ -380,14 +408,19 @@ impl VectorIndexReader for IvfIndexReader { if self.is_deleted(*id) { result.push(None); } else { - result.push(self.vectors.get(&(*id, field.clone()), self.dimension)?); + result.push(self.vectors.get(*id, field, self.dimension)?); } } Ok(result) } fn vector_ids(&self) -> Result> { - Ok(self.vector_ids.clone()) + // Rehydrated at the trait boundary (Issue #633 PR-B). + Ok(self + .vector_ids + .iter() + .map(|&(id, fid)| (id, self.field_dict[fid as usize].to_string())) + .collect()) } fn doc_ids_for_field(&self, field_name: &str) -> Arc<[u64]> { @@ -423,7 +456,7 @@ impl VectorIndexReader for IvfIndexReader { } fn contains_vector(&self, doc_id: u64, field_name: &str) -> bool { - self.vectors.contains_key(&(doc_id, field_name.to_string())) + self.vectors.contains(doc_id, field_name) } fn get_vector_range( @@ -432,41 +465,45 @@ impl VectorIndexReader for IvfIndexReader { end_doc_id: u64, ) -> Result> { let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if *id >= start_doc_id - && *id < end_doc_id - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if id >= start_doc_id + && id < end_doc_id + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field, self.dimension)? { - result.push((*id, field.clone(), vec)); + result.push((id, field.to_string(), vec)); } } Ok(result) } fn get_vectors_by_field(&self, field_name: &str) -> Result> { + // One dictionary resolve, then integer compares per record. + let Some(target) = resolve_field_id(&self.field_dict, field_name) else { + return Ok(Vec::new()); + }; let mut result = Vec::new(); - for (id, field) in &self.vector_ids { - if field == field_name - && !self.is_deleted(*id) - && let Some(vec) = self.vectors.get(&(*id, field.clone()), self.dimension)? + for &(id, fid) in &self.vector_ids { + if fid == target + && !self.is_deleted(id) + && let Some(vec) = self.vectors.get(id, field_name, self.dimension)? { - result.push((*id, vec)); + result.push((id, vec)); } } Ok(result) } fn field_names(&self) -> Result> { - use std::collections::HashSet; - let fields: HashSet = self.vector_ids.iter().map(|val| val.1.clone()).collect(); - Ok(fields.into_iter().collect()) + Ok(self.field_dict.iter().map(|f| f.to_string()).collect()) } fn vector_iterator(&self) -> Result> { Ok(Box::new(IvfVectorIterator { storage: self.vectors.clone(), keys: self.vector_ids.clone(), + field_dict: self.field_dict.clone(), current: 0, dimension: self.dimension, deletion_bitmap: self.deletion_bitmap.clone(), @@ -518,8 +555,9 @@ impl VectorIndexReader for IvfIndexReader { } } VectorStorage::OwnedQuantized(pool) => { - for (id, field) in &self.vector_ids { - if !pool.contains(*id, field) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !pool.contains(id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in quantized pool", id, field @@ -533,8 +571,9 @@ impl VectorIndexReader for IvfIndexReader { ); } VectorStorage::OwnedPq(pool) => { - for (id, field) in &self.vector_ids { - if !pool.contains(*id, field) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !pool.contains(id, field) { errors.push(format!( "Vector {}:{} found in keys but missing in PQ pool", id, field @@ -552,8 +591,9 @@ impl VectorIndexReader for IvfIndexReader { unreachable!("IVF reader rejects PQ FastScan at the segment header (HNSW-only)") } VectorStorage::OnDemand { offsets, .. } => { - for (id, field) in &self.vector_ids { - if !offsets.contains_key(&(*id, field.clone())) { + for &(id, fid) in &self.vector_ids { + let field = &self.field_dict[fid as usize]; + if !offsets.contains_key(&(id, fid)) { errors.push(format!( "Vector {}:{} in ids but missing in storage", id, field @@ -608,7 +648,8 @@ impl VectorIndexReader for IvfIndexReader { /// Iterator for IVF vector index. struct IvfVectorIterator { storage: VectorStorage, - keys: Vec<(u64, String)>, + keys: Vec<(u64, u16)>, + field_dict: Arc<[Arc]>, current: usize, dimension: usize, deletion_bitmap: Option>, @@ -619,22 +660,20 @@ impl VectorIterator for IvfVectorIterator { // Use a loop instead of recursion to avoid stack overflow when // many consecutive entries are deleted. while self.current < self.keys.len() { - let (doc_id, field) = &self.keys[self.current]; + let (doc_id, fid) = self.keys[self.current]; + let field = &self.field_dict[fid as usize]; // Skip deleted entries if let Some(bitmap) = &self.deletion_bitmap - && bitmap.is_deleted(*doc_id) + && bitmap.is_deleted(doc_id) { self.current += 1; continue; } - if let Some(vec) = self - .storage - .get(&(*doc_id, field.clone()), self.dimension)? - { + if let Some(vec) = self.storage.get(doc_id, field, self.dimension)? { self.current += 1; - return Ok(Some((*doc_id, field.clone(), vec))); + return Ok(Some((doc_id, field.to_string(), vec))); } else { return Err(LaurusError::internal(format!( "Vector {}:{} found in keys but missing in storage", @@ -648,8 +687,9 @@ impl VectorIterator for IvfVectorIterator { fn skip_to(&mut self, doc_id: u64, field_name: &str) -> Result { while self.current < self.keys.len() { - let (id, field) = &self.keys[self.current]; - if *id > doc_id || (*id == doc_id && field.as_str() >= field_name) { + let (id, fid) = self.keys[self.current]; + let field = &self.field_dict[fid as usize]; + if id > doc_id || (id == doc_id && field.as_ref() as &str >= field_name) { return Ok(true); } self.current += 1; @@ -659,7 +699,8 @@ impl VectorIterator for IvfVectorIterator { fn position(&self) -> (u64, String) { if self.current < self.keys.len() { - self.keys[self.current].clone() + let (id, fid) = self.keys[self.current]; + (id, self.field_dict[fid as usize].to_string()) } else { (u64::MAX, String::new()) } diff --git a/laurus/src/vector/index/ivf/searcher.rs b/laurus/src/vector/index/ivf/searcher.rs index f5ba41d2..dc53a78b 100644 --- a/laurus/src/vector/index/ivf/searcher.rs +++ b/laurus/src/vector/index/ivf/searcher.rs @@ -100,14 +100,16 @@ impl IvfSearcher { /// /// # Returns /// - /// A `Vec` of `(doc_id, field_name)` pairs from the probed clusters, - /// optionally filtered by `field_name`. + /// A `Vec` of `(doc_id, field_id)` pairs from the probed clusters + /// (ids index the reader's field dictionary, Issue #633 PR-B), + /// optionally filtered by `field_name` — resolved once to an id, so + /// the per-candidate filter is an integer compare. fn probe_clusters( &self, query: &Vector, n_probe: usize, field_name: Option<&str>, - ) -> Result> { + ) -> Result> { use super::reader::IvfIndexReader; if let Some(ivf_reader) = self.index_reader.as_any().downcast_ref::() { @@ -148,12 +150,28 @@ impl IvfSearcher { centroid_distances.select_nth_unstable_by(n - 1, |a, b| a.1.total_cmp(&b.1)); } + // Resolve the requested field once; per-candidate filtering + // below is then a u16 compare (Issue #633 PR-B). + let target = match field_name { + Some(field) => { + match crate::vector::index::format::resolve_field_id( + &ivf_reader.field_dict(), + field, + ) { + Some(fid) => Some(fid), + // Unknown field ⇒ no candidates. + None => return Ok(Vec::new()), + } + } + None => None, + }; + // Collect vector IDs from the `n` nearest clusters. let mut result = Vec::new(); for &(cluster_idx, _) in centroid_distances.iter().take(n) { let cluster_vecs = ivf_reader.cluster_vectors(cluster_idx); - if let Some(field) = field_name { - result.extend(cluster_vecs.iter().filter(|(_, f)| f == field).cloned()); + if let Some(target) = target { + result.extend(cluster_vecs.iter().filter(|&&(_, f)| f == target).copied()); } else { result.extend_from_slice(cluster_vecs); } @@ -198,6 +216,16 @@ impl VectorIndexSearcher for IvfSearcher { let vector_ids = self.probe_clusters(&request.query, self.n_probe, request.field_name.as_deref())?; + // The probe above already rejected non-IVF readers, so the + // downcast below is guaranteed to succeed; the dictionary maps + // the probe results' u16 ids back to names at emission. + let field_dict = self + .index_reader + .as_any() + .downcast_ref::() + .map(|r| r.field_dict()) + .unwrap_or_default(); + // Calculate distances for vectors in the probed clusters. // Cache the query-side norm once per search (#414); for Cosine / // Angular this skips the per-candidate `||query||²` accumulation. @@ -227,52 +255,37 @@ impl VectorIndexSearcher for IvfSearcher { // Distance scan over the probed clusters, parallelised across // candidates above PARALLEL_SCAN_THRESHOLD (#662). The quantized hot // path and the f32 fallback both run inside the per-candidate closure. - let mut candidates: Vec<(u64, String, f32, f32, Vector)> = - crate::vector::search::searcher::parallel_scan( - &vector_ids[..], - |(doc_id, field_name)| { - // Skip non-matching candidates before the distance kernel. - if let Some(allowed) = filter - && !allowed.contains(*doc_id) - { - return Ok(None); - } - if let (Some(pool), Some(prepared)) = (&quant_pool, &prepared_quantized) - && let Some((int8, meta)) = pool.get_record(*doc_id, field_name) - { - let distance = crate::vector::core::distance_quantized::distance_quantized( - metric, prepared, int8, meta, - ); - let similarity = metric.distance_to_similarity(distance); - let vector = if request.params.include_vectors { - pool.dequantize_to_vector(*doc_id, field_name) - .unwrap_or_else(|| Vector::new(Vec::new())) - } else { - Vector::new(Vec::new()) - }; - return Ok(Some(( - *doc_id, - field_name.clone(), - similarity, - distance, - vector, - ))); - } - if let Ok(Some(vector)) = self.index_reader.get_vector(*doc_id, field_name) { - let distance = - metric.distance_with_prepared(&prepared_query, &vector.data)?; - let similarity = metric.distance_to_similarity(distance); - return Ok(Some(( - *doc_id, - field_name.clone(), - similarity, - distance, - vector, - ))); - } - Ok(None) - }, - )?; + let mut candidates: Vec<(u64, u16, f32, f32, Vector)> = + crate::vector::search::searcher::parallel_scan(&vector_ids[..], |(doc_id, fid)| { + let field_name: &str = &field_dict[*fid as usize]; + // Skip non-matching candidates before the distance kernel. + if let Some(allowed) = filter + && !allowed.contains(*doc_id) + { + return Ok(None); + } + if let (Some(pool), Some(prepared)) = (&quant_pool, &prepared_quantized) + && let Some((int8, meta)) = pool.get_record(*doc_id, field_name) + { + let distance = crate::vector::core::distance_quantized::distance_quantized( + metric, prepared, int8, meta, + ); + let similarity = metric.distance_to_similarity(distance); + let vector = if request.params.include_vectors { + pool.dequantize_to_vector(*doc_id, field_name) + .unwrap_or_else(|| Vector::new(Vec::new())) + } else { + Vector::new(Vec::new()) + }; + return Ok(Some((*doc_id, *fid, similarity, distance, vector))); + } + if let Ok(Some(vector)) = self.index_reader.get_vector(*doc_id, field_name) { + let distance = metric.distance_with_prepared(&prepared_query, &vector.data)?; + let similarity = metric.distance_to_similarity(distance); + return Ok(Some((*doc_id, *fid, similarity, distance, vector))); + } + Ok(None) + })?; // Sort by similarity (descending) candidates.sort_unstable_by(|a, b| b.2.total_cmp(&a.2)); @@ -280,8 +293,9 @@ impl VectorIndexSearcher for IvfSearcher { // Take top_k results let candidates_len = candidates.len(); let top_k = request.params.top_k.min(candidates_len); - for (doc_id, field_name, similarity, distance, vector) in candidates.into_iter().take(top_k) - { + for (doc_id, fid, similarity, distance, vector) in candidates.into_iter().take(top_k) { + // Rehydrate the field name only for emitted hits. + let field_name = field_dict[fid as usize].to_string(); // Apply minimum similarity threshold if similarity < request.params.min_similarity { break; diff --git a/laurus/src/vector/index/storage.rs b/laurus/src/vector/index/storage.rs index 5a565403..353691a0 100644 --- a/laurus/src/vector/index/storage.rs +++ b/laurus/src/vector/index/storage.rs @@ -65,8 +65,14 @@ pub enum VectorStorage { storage: Arc, /// Name of the vector index file within the storage. file_name: String, - /// Pre-built mapping from `(doc_id, field_name)` to byte offset. - offsets: Arc>, + /// Pre-built mapping from `(doc_id, field_id)` to the byte + /// offset of the record's payload (Issue #633 PR-B: interned + /// u16 field ids instead of one heap `String` per record). + offsets: Arc>, + /// Per-segment field-name dictionary; `field_id` indexes into + /// it. Resolution from a name is a linear scan (segments hold + /// 1–3 fields in practice), which allocates nothing. + field_dict: Arc<[Arc]>, /// Per-segment quantization params, if the on-disk vector /// format is the Stage-1 quantized layout. `None` means the /// legacy f32 layout. @@ -132,7 +138,14 @@ impl VectorStorage { VectorStorage::OwnedPq(pool) => pool.keys(), #[cfg(feature = "pq-fastscan")] VectorStorage::OwnedPqFastScan(pool) => pool.keys(), - VectorStorage::OnDemand { offsets, .. } => offsets.keys().cloned().collect(), + VectorStorage::OnDemand { + offsets, + field_dict, + .. + } => offsets + .keys() + .map(|&(doc_id, fid)| (doc_id, field_dict[fid as usize].to_string())) + .collect(), } } @@ -153,19 +166,29 @@ impl VectorStorage { self.len() == 0 } - /// Returns `true` if a vector with the given key exists. + /// Returns `true` if a vector exists for `(doc_id, field_name)`. + /// + /// Allocation-free on every variant (Issue #633 PR-B): the + /// `OnDemand` arm resolves the field name against the segment + /// dictionary instead of materializing an owned key. /// /// # Arguments /// - /// * `key` - A `(doc_id, field_name)` tuple identifying the vector. - pub fn contains_key(&self, key: &(u64, String)) -> bool { + /// * `doc_id` - The document id. + /// * `field_name` - The vector field name. + pub fn contains(&self, doc_id: u64, field_name: &str) -> bool { match self { - VectorStorage::Owned(map) => map.contains_key(key), - VectorStorage::OwnedQuantized(pool) => pool.contains(key.0, &key.1), - VectorStorage::OwnedPq(pool) => pool.contains(key.0, &key.1), + VectorStorage::Owned(map) => map.contains_key(&(doc_id, field_name.to_string())), + VectorStorage::OwnedQuantized(pool) => pool.contains(doc_id, field_name), + VectorStorage::OwnedPq(pool) => pool.contains(doc_id, field_name), #[cfg(feature = "pq-fastscan")] - VectorStorage::OwnedPqFastScan(pool) => pool.contains(key.0, &key.1), - VectorStorage::OnDemand { offsets, .. } => offsets.contains_key(key), + VectorStorage::OwnedPqFastScan(pool) => pool.contains(doc_id, field_name), + VectorStorage::OnDemand { + offsets, + field_dict, + .. + } => crate::vector::index::format::resolve_field_id(field_dict, field_name) + .is_some_and(|fid| offsets.contains_key(&(doc_id, fid))), } } @@ -176,9 +199,15 @@ impl VectorStorage { /// the reader seeks to the recorded offset, and the vector data is read /// directly. /// + /// Allocation-free key handling (Issue #633 PR-B): callers pass + /// `(doc_id, &str)` and the `OnDemand` arm resolves the name against + /// the segment dictionary — the former per-call + /// `field_name.to_string()` key materialization is gone. + /// /// # Arguments /// - /// * `key` - A `(doc_id, field_name)` tuple identifying the vector. + /// * `doc_id` - The document id. + /// * `field_name` - The vector field name. /// * `dimension` - The expected number of dimensions (used to size the read buffer). /// /// # Returns @@ -188,21 +217,33 @@ impl VectorStorage { /// # Errors /// /// Returns [`LaurusError`] on I/O failure. - pub fn get(&self, key: &(u64, String), dimension: usize) -> Result> { + pub fn get(&self, doc_id: u64, field_name: &str, dimension: usize) -> Result> { match self { - VectorStorage::Owned(map) => Ok(map.get(key).cloned()), - VectorStorage::OwnedQuantized(pool) => Ok(pool.dequantize_to_vector(key.0, &key.1)), - VectorStorage::OwnedPq(pool) => Ok(pool.dequantize_to_vector(key.0, &key.1)), + // Match-only legacy variant (never constructed by current + // readers); the owned-key probe is acceptable here. + VectorStorage::Owned(map) => Ok(map.get(&(doc_id, field_name.to_string())).cloned()), + VectorStorage::OwnedQuantized(pool) => { + Ok(pool.dequantize_to_vector(doc_id, field_name)) + } + VectorStorage::OwnedPq(pool) => Ok(pool.dequantize_to_vector(doc_id, field_name)), #[cfg(feature = "pq-fastscan")] - VectorStorage::OwnedPqFastScan(pool) => Ok(pool.dequantize_to_vector(key.0, &key.1)), + VectorStorage::OwnedPqFastScan(pool) => { + Ok(pool.dequantize_to_vector(doc_id, field_name)) + } VectorStorage::OnDemand { storage, file_name, offsets, + field_dict, quant_params, cached_input, } => { - let Some(&offset) = offsets.get(key) else { + let Some(fid) = + crate::vector::index::format::resolve_field_id(field_dict, field_name) + else { + return Ok(None); + }; + let Some(&offset) = offsets.get(&(doc_id, fid)) else { return Ok(None); }; // Reuse the cached input handle if it has been opened by a