diff --git a/docs/ja/src/concepts/indexing/vector_indexing.md b/docs/ja/src/concepts/indexing/vector_indexing.md index 107a0bf9..6c96caf3 100644 --- a/docs/ja/src/concepts/indexing/vector_indexing.md +++ b/docs/ja/src/concepts/indexing/vector_indexing.md @@ -242,7 +242,11 @@ let opt = HnswOption { は int8 SIMD multiply-accumulate 1 回 + scalar 補正 3 回に縮約され、 検索時の per-element dequantize は不要。 - segment ファイルは `LVS1` magic + 16 byte header で始まり、reader - はロード時にフォーマットを判定。 + はロード時にフォーマットを判定。header はバージョン(機能のはしご: + v2 = HNSW グラフブロックの ordinal エンコード、v3 = セグメントごとの + フィールド名辞書)を持ち、v3 以降は辞書そのものも header に載る — + 各レコードはフィールド名をインラインで繰り返す代わりに 16 ビット ID + で参照する。 ### Two-stage rerank(Issue #481 Stage 2) diff --git a/docs/ja/src/laurus/persistence.md b/docs/ja/src/laurus/persistence.md index 3d4efb25..42588800 100644 --- a/docs/ja/src/laurus/persistence.md +++ b/docs/ja/src/laurus/persistence.md @@ -32,7 +32,7 @@ sequenceDiagram 4. **クラッシュセーフティ**: 書き込みとコミットの間にプロセスがクラッシュした場合、次回起動時にWALがリプレイされます 5. **アトミックなファイル書き込み**: セグメントファイル(HNSW の `.hnsw` グラフ・そのメタデータ・削除ビットマップなど)は一時ファイルへ書き込んでからアトミックにリネームして配置されるため、書き込み途中のクラッシュでも切り詰められたファイルではなく直前にコミット済みのファイルがそのまま残ります 6. **チェックサム検証**: これらのファイルは CRC-32(`.hnsw` と `.hnsw.f32` rerank sidecar は footer、`metadata.json` と削除ビットマップは framing)を持ち、ロード時に検証されるため、ディスク上の静かな破損を正常データとして読まずに検出できます。チェックサム導入前に書かれたファイルもそのままロードできます(ファイル単位で任意)。また、ローダーはヘッダーを信頼する前にバッファ確保サイズを実ファイルサイズで上限を縛るため、サイズフィールドが破損していても巨大なメモリ確保(OOM)を引き起こさずに破損として拒否します -7. **バージョン付きセグメントヘッダー**: ベクトルセグメントは共有ヘッダーにフォーマットバージョンを持ちます。HNSW セグメントはバージョン 2 で書き込まれ、そのグラフブロックは 64 ビットのドキュメント ID の代わりにセグメントローカルな 32 ビット ordinal を格納します(ディスク上のグラフブロックはおよそ半分になります)。古いビルドが書いたバージョン 1 のセグメントもそのままロードでき、次の書き直し(コンパクションやマージ)でバージョン 2 に更新されます。Flat と IVF のセグメントは引き続きバージョン 1 で書き込まれるため、古いビルドでも読み込めます +7. **バージョン付きセグメントヘッダー**: ベクトルセグメントは共有ヘッダーにフォーマットバージョンを持ち、機能のはしごを形成します。バージョン 2(HNSW のみ)はグラフブロックを 64 ビットのドキュメント ID の代わりにセグメントローカルな 32 ビット ordinal で格納します(ディスク上のグラフブロックはおよそ半分になります)。バージョン 3(全ベクトルインデックス型)はセグメントごとのフィールド名辞書を追加し、各レコードはフィールド名をインラインで繰り返す代わりに 16 ビットの ID で参照します(レコードごとに名前の長さ + 2 バイト分縮小されます)。新しいセグメントはバージョン 3 で書き込まれ、古いビルドが書いたセグメント(バージョン 1・2)もそのままロードでき、次の書き直し(コンパクションやマージ)で更新されます ## Write-Ahead Log(WAL) diff --git a/docs/src/concepts/indexing/vector_indexing.md b/docs/src/concepts/indexing/vector_indexing.md index 707e6dc0..bdfaaafe 100644 --- a/docs/src/concepts/indexing/vector_indexing.md +++ b/docs/src/concepts/indexing/vector_indexing.md @@ -240,7 +240,11 @@ let opt = HnswOption { collapses to one int8 SIMD multiply-accumulate plus three scalar corrections — no per-element dequantization at search time. - Segment files start with the `LVS1` magic + a 16-byte header so the - reader can detect the format at load time. + reader can detect the format at load time. The header carries a + version (a feature ladder: v2 = ordinal-encoded HNSW graph block, + v3 = per-segment field-name dictionary) and, from v3 on, the + dictionary itself — records then reference field names by a 16-bit + id instead of repeating the full name inline. ### Two-stage rerank (Issue #481 Stage 2) @@ -327,7 +331,7 @@ skipped. Every vector segment header carries element counts (`num_vectors`, `n_clusters`, the HNSW graph's `node_count` / `layer_count` / -`neighbor_count`) and per-record byte lengths (`field_name_len`, PQ +`neighbor_count`) and per-record byte lengths (the v1/v2 inline `field_name_len`, PQ `codes`) that the reader uses to size `Vec` / `HashMap` capacities and read buffers. The HNSW CRC footer is verified before the structural parse, but legacy footer-less segments — and the writer reload paths, diff --git a/docs/src/laurus/persistence.md b/docs/src/laurus/persistence.md index dd445dc2..2c88ec54 100644 --- a/docs/src/laurus/persistence.md +++ b/docs/src/laurus/persistence.md @@ -32,7 +32,7 @@ sequenceDiagram 4. **Crash safety**: If the process crashes between writes and commit, the WAL is replayed on the next startup 5. **Atomic file writes**: Segment files (e.g. the HNSW `.hnsw` graph, its metadata, and the deletion bitmap) are written to a temporary file and atomically renamed into place, so a crash mid-write leaves the previously committed file intact rather than a truncated one 6. **Checksum verification**: Those files carry a CRC-32 (a footer on `.hnsw` and the `.hnsw.f32` rerank sidecar, framing on `metadata.json` and the deletion bitmap) that is verified on load, so silent on-disk corruption is detected instead of being read as valid data. Files written before checksums were added still load (the checksum is optional per file). Loaders also bound buffer allocations against the real file size before trusting a header, so a corrupt size field is rejected as corruption rather than triggering a huge out-of-memory allocation -7. **Versioned segment headers**: Vector segments carry a format version in their shared header. HNSW segments are written with version 2, whose graph block stores segment-local 32-bit ordinals instead of 64-bit document ids (roughly halving the graph block on disk); version 1 segments written by older builds still load and are upgraded to version 2 on the next rewrite (compaction or merge). Flat and IVF segments keep writing version 1, so older builds can still read them +7. **Versioned segment headers**: Vector segments carry a format version in their shared header, forming a feature ladder. Version 2 (HNSW only) stores the graph block as segment-local 32-bit ordinals instead of 64-bit document ids (roughly halving the graph block on disk). Version 3 (all vector index types) adds a per-segment field-name dictionary: records reference field names by a 16-bit id instead of repeating the full name inline, shrinking every record by the name's length plus two bytes. New segments are written at version 3; segments written by older builds (versions 1 and 2) still load and are upgraded on the next rewrite (compaction or merge) ## Write-Ahead Log (WAL) diff --git a/laurus/src/vector/index/flat/reader.rs b/laurus/src/vector/index/flat/reader.rs index bd92f614..763ca201 100644 --- a/laurus/src/vector/index/flat/reader.rs +++ b/laurus/src/vector/index/flat/reader.rs @@ -8,7 +8,7 @@ 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}; +use crate::vector::index::format::{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}; @@ -78,7 +78,7 @@ impl FlatVectorIndexReader { path: &str, distance_metric: DistanceMetric, ) -> Result { - use crate::vector::index::alloc_bounds::{checked_capacity, checked_len}; + use crate::vector::index::alloc_bounds::checked_capacity; use std::io::{Read, Seek}; // Open the index file @@ -102,9 +102,11 @@ impl FlatVectorIndexReader { // Read the Issue #481 Stage 1 vector segment header (LVS1). // Pre-Stage-1 segments are rejected with IncompatibleFormat. + // Matched by reference so `header` (version + field dictionary, + // Issue #633) stays alive for the record parse below. let header = VectorSegmentHeader::read_from(&mut input)?; - let params = match header.quant { - QuantHeader::Scalar8Bit(p) => p, + let params = match &header.quant { + QuantHeader::Scalar8Bit(p) => *p, QuantHeader::ProductQuantization { .. } => { return Err(crate::error::LaurusError::NotImplemented( "Product quantization (Issue #481 Stage 3) is HNSW-only; \ @@ -123,12 +125,14 @@ impl FlatVectorIndexReader { }; // Bytes left for the per-vector records section, captured once at its - // start (Issue #806). Each record is at least doc_id (8) + - // field_name_len (4) + the fixed quantized payload (dim int8 + 8 meta), - // so this stride also bounds the per-record `dimension`-sized int8 read. + // start (Issue #806). Each record is at least the version-dependent + // prefix (doc_id + field reference, Issue #633) + the fixed quantized + // payload (dim int8 + 8 meta), so this stride also bounds the + // per-record `dimension`-sized int8 read. let records_remaining = file_size.saturating_sub(input.stream_position().map_err(LaurusError::Io)?); - let record_stride = 12 + quantized_record_payload_size(dimension) as u64; + let record_stride = + record_prefix_size(header.version) + quantized_record_payload_size(dimension) as u64; let (vectors, vector_ids) = match storage.loading_mode() { crate::storage::LoadingMode::Eager => { @@ -149,17 +153,12 @@ impl FlatVectorIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - // Read field name - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "flat field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + // Field reference: dictionary id (v3+) or inline name. + let field_name = header.read_record_field( + &mut input, + records_remaining, + "flat field_name_len", + )?; // Read int8 + meta directly (no dequantize). let mut int8 = vec![0u8; dimension]; @@ -189,11 +188,11 @@ impl FlatVectorIndexReader { let mut offsets = HashMap::with_capacity(num_vectors); let mut vector_ids = Vec::with_capacity(num_vectors); - // Seek to start of per-vector entries: Flat preamble - // (count u32 + dim u32 = 8 bytes) + VectorSegmentHeader - // (Stage-1 Scalar8Bit = 24 bytes) = 32 bytes. - let start_pos = - 8u64 + VectorSegmentHeader::scalar_8bit(params).serialized_size() as u64; + // Seek to the start of the per-vector entries: Flat preamble + // (count u32 + dim u32 = 8 bytes) + the parsed header's real + // size (which includes the v3 field dictionary, Issue #633 — + // reconstructing a fresh header here would omit it). + let start_pos = 8u64 + header.serialized_size() as u64; input .seek(std::io::SeekFrom::Start(start_pos)) .map_err(LaurusError::Io)?; @@ -201,24 +200,21 @@ impl FlatVectorIndexReader { let quant_payload_size = quantized_record_payload_size(dimension) as i64; for _ in 0..num_vectors { - let start_offset = input.stream_position().map_err(LaurusError::Io)?; - let mut doc_id_buf = [0u8; 8]; input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "flat field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; - - offsets.insert((doc_id, field_name.clone()), start_offset); + let field_name = header.read_record_field( + &mut input, + records_remaining, + "flat field_name_len", + )?; + + // Offsets point at the payload start (right after the + // 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)); // Skip int8 payload + per-vector meta. diff --git a/laurus/src/vector/index/flat/writer.rs b/laurus/src/vector/index/flat/writer.rs index b060f3a9..b035ab75 100644 --- a/laurus/src/vector/index/flat/writer.rs +++ b/laurus/src/vector/index/flat/writer.rs @@ -10,9 +10,11 @@ use crate::storage::Storage; use crate::vector::core::quantization::ScalarQuantParams; use crate::vector::core::vector::Vector; use crate::vector::index::FlatIndexConfig; -use crate::vector::index::alloc_bounds::{checked_capacity, checked_len}; +use crate::vector::index::alloc_bounds::checked_capacity; use crate::vector::index::field::LegacyVectorFieldWriter; -use crate::vector::index::format::{QuantHeader, VectorSegmentHeader}; +use crate::vector::index::format::{ + QuantHeader, VERSION_FIELD_DICT, VectorSegmentHeader, build_field_dict, record_prefix_size, +}; use crate::vector::index::quantized_io::{ quantize_segment, quantized_record_payload_size, read_dequantized_vector, write_quantized_record, @@ -122,9 +124,11 @@ impl FlatIndexWriter { // Read the Issue #481 Stage 1 vector segment header (LVS1). // Pre-Stage-1 segments are rejected with IncompatibleFormat. + // Matched by reference so `header` (version + field dictionary, + // Issue #633) stays alive for the record parse below. let header = VectorSegmentHeader::read_from(&mut input)?; - let params = match header.quant { - QuantHeader::Scalar8Bit(p) => p, + let params = match &header.quant { + QuantHeader::Scalar8Bit(p) => *p, QuantHeader::ProductQuantization { .. } => { return Err(crate::error::LaurusError::NotImplemented( "Product quantization (Issue #481 Stage 3) is HNSW-only; \ @@ -150,7 +154,8 @@ impl FlatIndexWriter { // quantized payload (dim int8 + 8 meta). let records_remaining = file_size.saturating_sub(input.stream_position().map_err(LaurusError::Io)?); - let record_stride = 12 + quantized_record_payload_size(dimension) as u64; + let record_stride = + record_prefix_size(header.version) + quantized_record_payload_size(dimension) as u64; checked_capacity( num_vectors, record_stride, @@ -163,17 +168,9 @@ impl FlatIndexWriter { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - // Read field name - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "flat field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + // Field reference: dictionary id (v3+) or inline name. + let field_name = + header.read_record_field(&mut input, records_remaining, "flat field_name_len")?; // Read quantized payload + dequantize. let values = read_dequantized_vector(&mut input, dimension, ¶ms)?; @@ -457,16 +454,19 @@ impl VectorIndexWriter for FlatIndexWriter { } else { quantize_segment(&f32_vectors, self.index_config.dimension)? }; - VectorSegmentHeader::scalar_8bit(params).write_to(&mut output)?; - - // Write vectors with field names and quantized records. + // Per-segment field-name dictionary (Issue #633): ids assigned in + // first-appearance order over the exact emission order below. + let (field_dict, field_ids) = + build_field_dict(self.vectors.iter().map(|(_, f, _)| f.as_str()))?; + VectorSegmentHeader::scalar_8bit(params) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict) + .write_to(&mut output)?; + + // Write vectors with dictionary field ids and quantized records. for ((doc_id, field_name, _), (int8, meta)) in self.vectors.iter().zip(records.iter()) { output.write_all(&doc_id.to_le_bytes())?; - - // Write field name length and field name - let field_name_bytes = field_name.as_bytes(); - output.write_all(&(field_name_bytes.len() as u32).to_le_bytes())?; - output.write_all(field_name_bytes)?; + output.write_all(&field_ids[field_name.as_str()].to_le_bytes())?; // Write quantized payload (dim int8 + sum_q + norm_q). write_quantized_record(&mut output, int8, *meta)?; diff --git a/laurus/src/vector/index/format.rs b/laurus/src/vector/index/format.rs index feb76c02..75fbe0a1 100644 --- a/laurus/src/vector/index/format.rs +++ b/laurus/src/vector/index/format.rs @@ -591,9 +591,6 @@ impl VectorSegmentHeader { /// /// The record's field name, or an error when the id is out of /// dictionary range / the inline name is oversized or not UTF-8. - // Consumed by the reader/writer migration (sub-issue #858); unused - // while this format layer ships alone. - #[allow(dead_code)] pub(crate) fn read_record_field( &self, reader: &mut R, @@ -644,8 +641,6 @@ impl VectorSegmentHeader { /// # Returns /// /// The fixed prefix size in bytes. -// Consumed by the reader/writer migration (sub-issue #858). -#[allow(dead_code)] pub(crate) fn record_prefix_size(version: u16) -> u64 { if version >= VERSION_FIELD_DICT { 10 @@ -666,8 +661,6 @@ pub(crate) fn record_prefix_size(version: u16) -> u64 { /// /// `(dictionary, name → field_id map)` for the writer's emit loop, or /// an error if more than `u16::MAX` distinct names are present. -// Consumed by the writer migration (sub-issue #858). -#[allow(dead_code)] pub(crate) fn build_field_dict<'a>( names: impl Iterator, ) -> Result<(Vec, std::collections::HashMap)> { diff --git a/laurus/src/vector/index/hnsw/reader.rs b/laurus/src/vector/index/hnsw/reader.rs index c502ea3b..60a48113 100644 --- a/laurus/src/vector/index/hnsw/reader.rs +++ b/laurus/src/vector/index/hnsw/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, VERSION_ORDINAL_GRAPH, VectorSegmentHeader}; +use crate::vector::index::format::{ + 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; use crate::vector::index::quantized_storage::QuantizedVectorPool; @@ -573,7 +575,8 @@ impl HnswIndexReader { // the fixed quantized payload (dim int8 + 8 meta). Bounding // `num_vectors` by that stride also bounds the per-record // `dimension`-sized int8 read (Issue #806). - let record_stride = 12 + quantized_record_payload_size(dimension) as u64; + let record_stride = record_prefix_size(header.version) + + quantized_record_payload_size(dimension) as u64; checked_capacity( num_vectors, record_stride, @@ -589,15 +592,11 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "hnsw field_name_len")?; - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + let field_name = header.read_record_field( + &mut input, + records_remaining, + "hnsw field_name_len", + )?; let mut int8 = vec![0u8; dimension]; input.read_exact(&mut int8)?; @@ -622,7 +621,8 @@ impl HnswIndexReader { ) } (QuantHeader::Scalar8Bit(params), crate::storage::LoadingMode::Lazy) => { - let record_stride = 12 + quantized_record_payload_size(dimension) as u64; + let record_stride = record_prefix_size(header.version) + + quantized_record_payload_size(dimension) as u64; checked_capacity( num_vectors, record_stride, @@ -632,12 +632,12 @@ impl HnswIndexReader { let mut offsets = HashMap::with_capacity(num_vectors); let mut vector_ids = Vec::with_capacity(num_vectors); - // Seek to start of per-vector entries: HNSW preamble - // (count u64 + dim u32 + m u32 + ef u32 = 20 bytes) - // followed by VectorSegmentHeader (Stage-1, 24 bytes - // for Scalar8Bit) = 44 bytes. - let start_pos = - 20u64 + VectorSegmentHeader::scalar_8bit(*params).serialized_size() as u64; + // Seek to the start of the per-vector entries: HNSW preamble + // (count u64 + dim u32 + m u32 + ef u32 = 20 bytes) followed + // by the parsed header's real size (which includes the v3 + // field dictionary, Issue #633 — reconstructing a fresh + // header here would omit it). + let start_pos = 20u64 + header.serialized_size() as u64; input .seek(std::io::SeekFrom::Start(start_pos)) .map_err(LaurusError::Io)?; @@ -645,24 +645,21 @@ impl HnswIndexReader { let quant_payload_size = quantized_record_payload_size(dimension) as i64; for _ in 0..num_vectors { - let start_offset = input.stream_position().map_err(LaurusError::Io)?; - let mut doc_id_buf = [0u8; 8]; input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "hnsw field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + let field_name = header.read_record_field( + &mut input, + records_remaining, + "hnsw field_name_len", + )?; - offsets.insert((doc_id, field_name.clone()), start_offset); + // Offsets point at the payload start (right after the + // 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())); input @@ -696,11 +693,12 @@ impl HnswIndexReader { // carries Scalar8Bit params), so we eagerly load the // codes regardless of `loading_mode`. let codes_size = pq_params.m as usize; - // Each PQ record is at least doc_id (8) + field_name_len (4) + - // `codes_size` bytes of codes (Issue #806). + // Each PQ record is at least the version-dependent prefix + // (doc_id + field reference, Issue #633) + `codes_size` + // bytes of codes (Issue #806). checked_capacity( num_vectors, - 12 + codes_size as u64, + record_prefix_size(header.version) + codes_size as u64, records_remaining, "hnsw num_vectors", )?; @@ -713,15 +711,11 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "hnsw field_name_len")?; - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + let field_name = header.read_record_field( + &mut input, + records_remaining, + "hnsw field_name_len", + )?; let mut codes = vec![0u8; codes_size]; input.read_exact(&mut codes)?; @@ -749,9 +743,15 @@ impl HnswIndexReader { // codes via `pq_fastscan_io::read_pq_fastscan_record` // and build a `PqFastScanPool` for the SIMD-friendly // block-transposed in-memory layout. - // Each record is at least doc_id (8) + field_name_len (4) + - // one byte of packed codes (Issue #806). - checked_capacity(num_vectors, 13, records_remaining, "hnsw num_vectors")?; + // Each record is at least the version-dependent prefix + // (doc_id + field reference, Issue #633) + one byte of + // packed codes (Issue #806). + checked_capacity( + num_vectors, + record_prefix_size(header.version) + 1, + records_remaining, + "hnsw num_vectors", + )?; let mut vector_ids = Vec::with_capacity(num_vectors); let mut records: Vec<(u64, String, Vec)> = Vec::with_capacity(num_vectors); @@ -760,15 +760,11 @@ impl HnswIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "hnsw field_name_len")?; - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + let field_name = header.read_record_field( + &mut input, + records_remaining, + "hnsw field_name_len", + )?; let codes = crate::vector::index::pq_fastscan_io::read_pq_fastscan_record( &mut input, *pq_params, @@ -1502,7 +1498,7 @@ mod ordinal_migration_tests { use crate::storage::memory::{MemoryStorage, MemoryStorageConfig}; use crate::vector::core::quantization::ScalarQuantParams; use crate::vector::index::HnswIndexConfig; - use crate::vector::index::format::CURRENT_VERSION; + use crate::vector::index::format::{CURRENT_VERSION, VERSION_FIELD_DICT}; use crate::vector::index::hnsw::searcher::HnswSearcher; use crate::vector::index::hnsw::writer::HnswIndexWriter; use crate::vector::search::searcher::{VectorIndexQuery, VectorIndexSearcher}; @@ -1592,6 +1588,65 @@ mod ordinal_migration_tests { buf } + /// v3 LVS1 header bytes: neutral SQ params + the given dictionary. + fn v3_header_bytes(dict: &[&str]) -> Vec { + let mut buf = Vec::new(); + VectorSegmentHeader::scalar_8bit(ScalarQuantParams { + offset: 0.0, + scale: 1.0, + }) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(dict.iter().map(|s| s.to_string()).collect()) + .write_to(&mut buf) + .unwrap(); + buf + } + + /// One v3 SQ record: `[doc_id u64][field_id u16][int8][meta]`. + fn sq_record_v3(doc_id: u64, field_id: u16, values: [u8; DIM]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&doc_id.to_le_bytes()); + buf.extend_from_slice(&field_id.to_le_bytes()); + buf.extend_from_slice(&values); + let sum_q: u32 = values.iter().map(|&v| v as u32).sum(); + let norm_q: f32 = values + .iter() + .map(|&v| (v as f32) * (v as f32)) + .sum::() + .sqrt(); + buf.extend_from_slice(&sum_q.to_le_bytes()); + buf.extend_from_slice(&norm_q.to_le_bytes()); + buf + } + + /// A v2-encoding (ordinal) graph block over 3 nodes: entry ordinal 0, + /// one layer each, ring adjacency. Valid for any version >= 2 fixture. + fn ordinal_graph_block() -> Vec { + let mut buf = Vec::new(); + buf.push(1u8); // has_graph + buf.extend_from_slice(&0u32.to_le_bytes()); // entry ordinal + buf.extend_from_slice(&0u32.to_le_bytes()); // max_level + buf.extend_from_slice(&3u32.to_le_bytes()); // node_count + for neighbors in [[1u32, 2u32], [0, 2], [0, 1]] { + buf.extend_from_slice(&1u32.to_le_bytes()); // layer_count + buf.extend_from_slice(&(neighbors.len() as u32).to_le_bytes()); + for n in neighbors { + buf.extend_from_slice(&n.to_le_bytes()); + } + } + buf + } + + fn v3_fixture_bytes() -> Vec { + let mut bytes = preamble(3); + bytes.extend_from_slice(&v3_header_bytes(&["f"])); + bytes.extend_from_slice(&sq_record_v3(10, 0, [100, 1, 1, 1])); + bytes.extend_from_slice(&sq_record_v3(20, 0, [1, 100, 1, 1])); + bytes.extend_from_slice(&sq_record_v3(30, 0, [1, 1, 100, 1])); + bytes.extend_from_slice(&ordinal_graph_block()); + bytes + } + fn v1_fixture_bytes() -> Vec { let mut bytes = preamble(3); bytes.extend_from_slice(&header_bytes(CURRENT_VERSION)); @@ -1600,6 +1655,133 @@ mod ordinal_migration_tests { bytes } + #[test] + fn reads_v3_fixture_and_searches() { + let storage = storage_with("v3_fixture.hnsw", v3_fixture_bytes()); + let reader = HnswIndexReader::load(storage, "v3_fixture", DistanceMetric::Cosine).unwrap(); + + let graph = reader.graph.as_ref().expect("v3 graph must load"); + assert_eq!(graph.node_count(), 3); + assert_eq!(graph.entry_point(), Some(0)); + // Single-field, unique ids → identity ord→pos mapping. + assert!(reader.field_ord_to_pos("f").is_none()); + + let searcher = HnswSearcher::new(Arc::new(reader)).unwrap(); + let results = searcher + .search( + &VectorIndexQuery::new(Vector::new(vec![1.0, 1.0, 100.0, 1.0])) + .top_k(3) + .field_name("f".to_string()), + ) + .unwrap(); + let mut ids: Vec = results.results.iter().map(|r| r.doc_id).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec![10, 20, 30]); + assert_eq!(results.results[0].doc_id, 30); + } + + #[test] + fn rejects_v3_record_field_id_out_of_range() { + let mut bytes = preamble(3); + bytes.extend_from_slice(&v3_header_bytes(&["f"])); + bytes.extend_from_slice(&sq_record_v3(10, 0, [100, 1, 1, 1])); + bytes.extend_from_slice(&sq_record_v3(20, 7, [1, 100, 1, 1])); // id 7 >= dict len 1 + bytes.extend_from_slice(&sq_record_v3(30, 0, [1, 1, 100, 1])); + bytes.extend_from_slice(&ordinal_graph_block()); + + let storage = storage_with("bad_fid.hnsw", bytes); + let err = HnswIndexReader::load(storage, "bad_fid", DistanceMetric::Cosine) + .expect_err("out-of-range field_id must be rejected"); + assert!( + err.to_string().contains("out of dictionary range"), + "got: {err}" + ); + } + + #[test] + fn rejects_v3_empty_dict_with_records() { + let mut bytes = preamble(1); + bytes.extend_from_slice(&v3_header_bytes(&[])); + bytes.extend_from_slice(&sq_record_v3(10, 0, [100, 1, 1, 1])); + bytes.push(0u8); // has_graph = false + + let storage = storage_with("empty_dict.hnsw", bytes); + let err = HnswIndexReader::load(storage, "empty_dict", DistanceMetric::Cosine) + .expect_err("records referencing an empty dictionary must be rejected"); + assert!( + err.to_string().contains("out of dictionary range"), + "got: {err}" + ); + } + + #[test] + fn v3_writer_output_has_exact_size_and_v1_delta() { + // Deterministic headline gate for #633: byte-exact size formula + // plus the delta a v1 layout would have produced. + let storage: Arc = + Arc::new(MemoryStorage::new(MemoryStorageConfig::default())); + let config = HnswIndexConfig { + dimension: DIM, + m: 4, + ef_construction: 16, + distance_metric: DistanceMetric::Cosine, + ..Default::default() + }; + let field = "embedding"; + let n = 5u64; + let mut writer = HnswIndexWriter::with_storage( + config, + VectorIndexWriterConfig::default(), + "sized", + Arc::clone(&storage), + ) + .unwrap(); + let vectors: Vec<(u64, String, Vector)> = (0..n) + .map(|i| { + ( + i, + field.to_string(), + Vector::new(vec![i as f32 + 1.0, 1.0, 1.0, 1.0]), + ) + }) + .collect(); + writer.add_vectors(vectors).unwrap(); + writer.finalize().unwrap(); + writer.write().unwrap(); + + let input = storage.open_input("sized.hnsw").unwrap(); + let actual = input.size().unwrap(); + + // Reload to measure the graph block exactly (its size depends on + // the built topology): total = preamble(20) + header(24 + dict) + // + records + graph + footer(8). + let reader = + HnswIndexReader::load(Arc::clone(&storage), "sized", DistanceMetric::Cosine).unwrap(); + let graph = reader.graph.as_ref().unwrap(); + let mut graph_block = 1u64 + 4 + 4 + 4; // has_graph + entry + max_level + node_count + for (_, layers) in graph.iter_nodes() { + graph_block += 4; // layer_count + for neighbors in layers { + graph_block += 4 + 4 * neighbors.len() as u64; + } + } + let dict_bytes = 2 + (2 + field.len()) as u64; + let record_bytes = n * (10 + DIM as u64 + 8); + let expected = 20 + (24 + dict_bytes) + record_bytes + graph_block + 8; + assert_eq!(actual, expected, "v3 .hnsw file size must be byte-exact"); + + // v1 would have spent (4 + field.len()) per record on the name and + // carried no dictionary: delta = n*(len-2+4) - (2 + (2+len)). + let v1_records = n * (12 + field.len() as u64 + DIM as u64 + 8); + let saved = (v1_records + 24) - (record_bytes + 24 + dict_bytes); + assert_eq!( + saved, + n * (field.len() as u64 + 2) - dict_bytes, + "the v1-vs-v3 record-section delta formula must hold" + ); + assert!(saved > 0, "v3 must be smaller for n=5, k=9"); + } + #[test] fn reads_v1_graph_fixture_as_ordinals_and_searches() { let storage = storage_with("v1_fixture.hnsw", v1_fixture_bytes()); @@ -1633,7 +1815,7 @@ mod ordinal_migration_tests { } #[test] - fn writer_loads_v1_and_rewrites_v2_that_reader_loads() { + fn writer_loads_v1_and_rewrites_v3_that_reader_loads() { let storage = storage_with("upgrade.hnsw", v1_fixture_bytes()); let config = HnswIndexConfig { dimension: DIM, @@ -1663,8 +1845,8 @@ mod ordinal_migration_tests { input.read_exact(&mut head).unwrap(); assert_eq!( u16::from_le_bytes([head[24], head[25]]), - VERSION_ORDINAL_GRAPH, - "rewritten segment must stamp the v2 header version" + VERSION_FIELD_DICT, + "rewritten segment must stamp the v3 header version (Issue #633)" ); let reader = HnswIndexReader::load(storage, "upgrade", DistanceMetric::Cosine).unwrap(); diff --git a/laurus/src/vector/index/hnsw/writer.rs b/laurus/src/vector/index/hnsw/writer.rs index ce8ee3c7..6b6cc852 100644 --- a/laurus/src/vector/index/hnsw/writer.rs +++ b/laurus/src/vector/index/hnsw/writer.rs @@ -7,9 +7,12 @@ use crate::storage::Storage; use crate::vector::core::rerank::RerankStorageKind; use crate::vector::core::vector::Vector; use crate::vector::index::HnswIndexConfig; -use crate::vector::index::alloc_bounds::{checked_capacity, checked_len}; +use crate::vector::index::alloc_bounds::checked_capacity; use crate::vector::index::field::LegacyVectorFieldWriter; -use crate::vector::index::format::{QuantHeader, VERSION_ORDINAL_GRAPH, VectorSegmentHeader}; +use crate::vector::index::format::{ + QuantHeader, VERSION_FIELD_DICT, VERSION_ORDINAL_GRAPH, VectorSegmentHeader, build_field_dict, + record_prefix_size, +}; use crate::vector::index::hnsw::graph::HnswGraph; use crate::vector::index::quantized_io::{ quantize_segment, quantized_record_payload_size, read_dequantized_vector, @@ -536,7 +539,7 @@ impl HnswIndexWriter { #[cfg(feature = "pq-fastscan")] QuantHeader::ProductQuantizationFastScan { .. } => 1, }; - let record_stride = 12 + min_payload; + let record_stride = record_prefix_size(header.version) + min_payload; checked_capacity( num_vectors, record_stride, @@ -549,17 +552,9 @@ impl HnswIndexWriter { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - // Read field name - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, records_remaining, "hnsw field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + // Field reference: dictionary id (v3+) or inline name. + let field_name = + header.read_record_field(&mut input, records_remaining, "hnsw field_name_len")?; // Decode the per-vector payload according to the segment's // quantization kind. @@ -1385,6 +1380,11 @@ impl VectorIndexWriter for HnswIndexWriter { let mut sorted_vectors: Vec<_> = self.vectors.iter().collect(); sorted_vectors.sort_by_key(|(doc_id, _, _)| *doc_id); + // Per-segment field-name dictionary (Issue #633): ids assigned in + // first-appearance order over the exact emission order below. + let (field_dict, field_ids) = + build_field_dict(sorted_vectors.iter().map(|(_, f, _)| f.as_str()))?; + let f32_vectors: Vec = sorted_vectors .iter() .map(|(_, _, v)| (*v).clone()) @@ -1408,15 +1408,14 @@ impl VectorIndexWriter for HnswIndexWriter { quantize_segment(&f32_vectors, self.index_config.dimension)? }; VectorSegmentHeader::scalar_8bit(params) - .with_version(VERSION_ORDINAL_GRAPH) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict.clone()) .write_to(&mut output)?; for ((doc_id, field_name, _), (int8, meta)) in sorted_vectors.iter().zip(records.iter()) { output.write_all(&doc_id.to_le_bytes())?; - let field_name_bytes = field_name.as_bytes(); - output.write_all(&(field_name_bytes.len() as u32).to_le_bytes())?; - output.write_all(field_name_bytes)?; + output.write_all(&field_ids[field_name.as_str()].to_le_bytes())?; write_quantized_record(&mut output, int8, *meta)?; } } @@ -1436,7 +1435,8 @@ impl VectorIndexWriter for HnswIndexWriter { )?; let codebook = vec![0.0_f32; params.codebook_len()]; VectorSegmentHeader::product_quantization(params, codebook) - .with_version(VERSION_ORDINAL_GRAPH) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict.clone()) .write_to(&mut output)?; } else { let (params, codebook, codes) = @@ -1446,15 +1446,14 @@ impl VectorIndexWriter for HnswIndexWriter { subvector_count, )?; VectorSegmentHeader::product_quantization(params, codebook) - .with_version(VERSION_ORDINAL_GRAPH) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict.clone()) .write_to(&mut output)?; for ((doc_id, field_name, _), codes_i) in sorted_vectors.iter().zip(codes.iter()) { output.write_all(&doc_id.to_le_bytes())?; - let field_name_bytes = field_name.as_bytes(); - output.write_all(&(field_name_bytes.len() as u32).to_le_bytes())?; - output.write_all(field_name_bytes)?; + output.write_all(&field_ids[field_name.as_str()].to_le_bytes())?; crate::vector::index::pq_io::write_pq_record(&mut output, codes_i)?; } } @@ -1476,7 +1475,8 @@ impl VectorIndexWriter for HnswIndexWriter { )?; let codebook = vec![0.0_f32; params.codebook_len()]; VectorSegmentHeader::product_quantization_fastscan(params, codebook) - .with_version(VERSION_ORDINAL_GRAPH) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict.clone()) .write_to(&mut output)?; } else { let (params, codebook, codes) = @@ -1486,15 +1486,14 @@ impl VectorIndexWriter for HnswIndexWriter { subvector_count, )?; VectorSegmentHeader::product_quantization_fastscan(params, codebook) - .with_version(VERSION_ORDINAL_GRAPH) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict.clone()) .write_to(&mut output)?; for ((doc_id, field_name, _), codes_i) in sorted_vectors.iter().zip(codes.iter()) { output.write_all(&doc_id.to_le_bytes())?; - let field_name_bytes = field_name.as_bytes(); - output.write_all(&(field_name_bytes.len() as u32).to_le_bytes())?; - output.write_all(field_name_bytes)?; + output.write_all(&field_ids[field_name.as_str()].to_le_bytes())?; crate::vector::index::pq_fastscan_io::write_pq_fastscan_record( &mut output, codes_i, diff --git a/laurus/src/vector/index/ivf/reader.rs b/laurus/src/vector/index/ivf/reader.rs index 5e296497..287db453 100644 --- a/laurus/src/vector/index/ivf/reader.rs +++ b/laurus/src/vector/index/ivf/reader.rs @@ -9,7 +9,7 @@ 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}; +use crate::vector::index::format::{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::index::storage::VectorStorage; @@ -84,7 +84,7 @@ impl IvfIndexReader { path: &str, distance_metric: DistanceMetric, ) -> Result { - use crate::vector::index::alloc_bounds::{checked_capacity, checked_len}; + use crate::vector::index::alloc_bounds::checked_capacity; use std::io::{Read, Seek}; // Open the index file @@ -139,9 +139,11 @@ impl IvfIndexReader { // Read the Issue #481 Stage 1 vector segment header (LVS1) // before the inverted lists. Pre-Stage-1 segments are // rejected with IncompatibleFormat. + // Matched by reference so `header` (version + field dictionary, + // Issue #633) stays alive for the record parse below. let header = VectorSegmentHeader::read_from(&mut input)?; - let params = match header.quant { - QuantHeader::Scalar8Bit(p) => p, + let params = match &header.quant { + QuantHeader::Scalar8Bit(p) => *p, QuantHeader::ProductQuantization { .. } => { return Err(crate::error::LaurusError::NotImplemented( "Product quantization (Issue #481 Stage 3) is HNSW-only; \ @@ -166,7 +168,8 @@ impl IvfIndexReader { // meta), so `record_stride` also bounds the per-record int8 read. let lists_remaining = file_size.saturating_sub(input.stream_position().map_err(LaurusError::Io)?); - let record_stride = 12 + quantized_record_payload_size(dimension) as u64; + let record_stride = + record_prefix_size(header.version) + quantized_record_payload_size(dimension) as u64; // Read inverted lists, preserving per-cluster grouping. Each cluster // serializes at least its list_size (4 bytes). @@ -197,19 +200,11 @@ impl IvfIndexReader { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, lists_remaining, "ivf field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!( - "Invalid UTF-8 in field name: {}", - e - )) - })?; + let field_name = header.read_record_field( + &mut input, + lists_remaining, + "ivf field_name_len", + )?; // Read int8 + meta directly (no dequantize). let mut int8 = vec![0u8; dimension]; @@ -252,28 +247,22 @@ impl IvfIndexReader { let mut cluster_vecs = Vec::with_capacity(list_size); for _ in 0..list_size { - let start_offset = input.stream_position().map_err(LaurusError::Io)?; - let mut doc_id_buf = [0u8; 8]; input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, lists_remaining, "ivf field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!( - "Invalid UTF-8 in field name: {}", - e - )) - })?; + let field_name = header.read_record_field( + &mut input, + lists_remaining, + "ivf field_name_len", + )?; + // Offsets point at the payload start (right after the + // 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(), start_offset); + offsets.insert(key.clone(), payload_offset); cluster_vecs.push(key.clone()); vector_ids.push(key); diff --git a/laurus/src/vector/index/ivf/writer.rs b/laurus/src/vector/index/ivf/writer.rs index 0304f892..92325b40 100644 --- a/laurus/src/vector/index/ivf/writer.rs +++ b/laurus/src/vector/index/ivf/writer.rs @@ -10,9 +10,11 @@ use crate::storage::Storage; use crate::vector::core::quantization::ScalarQuantParams; use crate::vector::core::vector::Vector; use crate::vector::index::IvfIndexConfig; -use crate::vector::index::alloc_bounds::{checked_capacity, checked_len}; +use crate::vector::index::alloc_bounds::checked_capacity; use crate::vector::index::field::LegacyVectorFieldWriter; -use crate::vector::index::format::{QuantHeader, VectorSegmentHeader}; +use crate::vector::index::format::{ + QuantHeader, VERSION_FIELD_DICT, VectorSegmentHeader, build_field_dict, record_prefix_size, +}; use crate::vector::index::quantized_io::{ quantize_segment, quantized_record_payload_size, read_dequantized_vector, write_quantized_record, @@ -178,9 +180,11 @@ impl IvfIndexWriter { // Read the Issue #481 Stage 1 vector segment header (LVS1). // Pre-Stage-1 segments are rejected with IncompatibleFormat. + // Matched by reference so `header` (version + field dictionary, + // Issue #633) stays alive for the record parse below. let header = VectorSegmentHeader::read_from(&mut input)?; - let params = match header.quant { - QuantHeader::Scalar8Bit(p) => p, + let params = match &header.quant { + QuantHeader::Scalar8Bit(p) => *p, QuantHeader::ProductQuantization { .. } => { return Err(crate::error::LaurusError::NotImplemented( "Product quantization (Issue #481 Stage 3) is HNSW-only; \ @@ -206,7 +210,8 @@ impl IvfIndexWriter { // list_size (4 bytes). let lists_remaining = file_size.saturating_sub(input.stream_position().map_err(LaurusError::Io)?); - let record_stride = 12 + quantized_record_payload_size(dimension) as u64; + let record_stride = + record_prefix_size(header.version) + quantized_record_payload_size(dimension) as u64; checked_capacity(n_clusters, 4, lists_remaining, "ivf cluster lists")?; let mut inverted_lists = vec![Vec::new(); n_clusters]; for list in &mut inverted_lists { @@ -220,17 +225,9 @@ impl IvfIndexWriter { input.read_exact(&mut doc_id_buf)?; let doc_id = u64::from_le_bytes(doc_id_buf); - // Read field name - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - checked_len(field_name_len, lists_remaining, "ivf field_name_len")?; - - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - let field_name = String::from_utf8(field_name_buf).map_err(|e| { - LaurusError::InvalidOperation(format!("Invalid UTF-8 in field name: {}", e)) - })?; + // Field reference: dictionary id (v3+) or inline name. + let field_name = + header.read_record_field(&mut input, lists_remaining, "ivf field_name_len")?; // Read quantized payload + dequantize. let values = read_dequantized_vector(&mut input, dimension, ¶ms)?; @@ -1180,7 +1177,18 @@ impl VectorIndexWriter for IvfIndexWriter { } else { quantize_segment(&all_vectors, self.index_config.dimension)? }; - VectorSegmentHeader::scalar_8bit(params).write_to(&mut output)?; + // Per-segment field-name dictionary (Issue #633): ids assigned in + // first-appearance order over the cluster-grouped emission order + // below (the same order the flatten above walked). + let (field_dict, field_ids) = build_field_dict( + self.inverted_lists + .iter() + .flat_map(|list| list.iter().map(|(_, f, _)| f.as_str())), + )?; + VectorSegmentHeader::scalar_8bit(params) + .with_version(VERSION_FIELD_DICT) + .with_field_dict(field_dict) + .write_to(&mut output)?; // Write inverted lists with quantized records. The records // were produced in flatten order, so we step through them in @@ -1190,11 +1198,7 @@ impl VectorIndexWriter for IvfIndexWriter { output.write_all(&(list.len() as u32).to_le_bytes())?; for (doc_id, field_name, _) in list { output.write_all(&doc_id.to_le_bytes())?; - - // Write field name length and field name - let field_name_bytes = field_name.as_bytes(); - output.write_all(&(field_name_bytes.len() as u32).to_le_bytes())?; - output.write_all(field_name_bytes)?; + output.write_all(&field_ids[field_name.as_str()].to_le_bytes())?; // Write quantized payload (dim int8 + sum_q + norm_q). let (int8, meta) = record_iter diff --git a/laurus/src/vector/index/pq_fastscan_io.rs b/laurus/src/vector/index/pq_fastscan_io.rs index 6a7d20c5..11e80957 100644 --- a/laurus/src/vector/index/pq_fastscan_io.rs +++ b/laurus/src/vector/index/pq_fastscan_io.rs @@ -14,8 +14,8 @@ //! [ VectorSegmentHeader (LVS1 + PQ params + codebook, k = 16) ] //! repeat num_vectors times: //! [ doc_id u64 LE 8 bytes ] -//! [ field_name_len u32 LE 4 bytes ] -//! [ field_name field_name_len bytes (UTF-8) ] +//! [ field ref: v3+ = field_id u16 (per-segment dictionary, ] +//! [ Issue #633); v1/v2 = name_len u32 + UTF-8 name ] //! [ codes_packed ceil(m / 2) bytes (4-bit packed) ] //! ``` //! diff --git a/laurus/src/vector/index/pq_io.rs b/laurus/src/vector/index/pq_io.rs index 9ee05181..4e12cb1a 100644 --- a/laurus/src/vector/index/pq_io.rs +++ b/laurus/src/vector/index/pq_io.rs @@ -8,8 +8,8 @@ //! [ VectorSegmentHeader (LVS1 + PQ params + codebook) ] //! repeat num_vectors times: //! [ doc_id u64 LE 8 bytes ] -//! [ field_name_len u32 LE 4 bytes ] -//! [ field_name field_name_len bytes (UTF-8) ] +//! [ field ref: v3+ = field_id u16 (per-segment dictionary, ] +//! [ Issue #633); v1/v2 = name_len u32 + UTF-8 name ] //! [ codes m bytes (per sub-vector centroid index) ] //! ``` //! diff --git a/laurus/src/vector/index/quantized_io.rs b/laurus/src/vector/index/quantized_io.rs index 05c376dd..0e128338 100644 --- a/laurus/src/vector/index/quantized_io.rs +++ b/laurus/src/vector/index/quantized_io.rs @@ -16,8 +16,8 @@ //! [ VectorSegmentHeader 24 bytes ] <- written / read separately //! repeat num_vectors times: //! [ doc_id u64 LE 8 bytes ] -//! [ field_name_len u32 LE 4 bytes ] -//! [ field_name field_name_len bytes (UTF-8) ] +//! [ field ref: v3+ = field_id u16 (per-segment dictionary, ] +//! [ Issue #633); v1/v2 = name_len u32 + UTF-8 name ] //! [ int8 data dim bytes ] //! [ sum_q u32 LE 4 bytes ] //! [ norm_q f32 LE 4 bytes ] diff --git a/laurus/src/vector/index/storage.rs b/laurus/src/vector/index/storage.rs index 2e3b49e3..5a565403 100644 --- a/laurus/src/vector/index/storage.rs +++ b/laurus/src/vector/index/storage.rs @@ -239,20 +239,14 @@ impl VectorStorage { } }; + // Offsets point at the record's payload start (the readers + // compute them past the doc_id + field-reference prefix at + // load, Issue #633), so no per-access prefix re-parse is + // needed — one seek lands directly on the vector data. input .seek(SeekFrom::Start(offset)) .map_err(LaurusError::Io)?; - // Skip doc_id (8 bytes) + field_name (4 bytes length + variable) - let mut doc_id_buf = [0u8; 8]; - input.read_exact(&mut doc_id_buf)?; - - let mut field_name_len_buf = [0u8; 4]; - input.read_exact(&mut field_name_len_buf)?; - let field_name_len = u32::from_le_bytes(field_name_len_buf) as usize; - let mut field_name_buf = vec![0u8; field_name_len]; - input.read_exact(&mut field_name_buf)?; - // Read vector data — branch on the on-disk format. let values = match quant_params { Some(params) => {