1#[cfg(feature = "native")]
10use std::cmp::Reverse;
11#[cfg(feature = "native")]
12use std::collections::BinaryHeap;
13use std::io;
14#[cfg(feature = "native")]
15use std::io::Write;
16use std::ops::Range;
17
18#[cfg(feature = "native")]
19use byteorder::WriteBytesExt;
20use byteorder::{LittleEndian, ReadBytesExt};
21
22use crate::directories::OwnedBytes;
23use crate::dsl::IvfRoutingMode;
24
25#[cfg(feature = "native")]
26use crate::structures::BinaryIvfIndex;
27use crate::structures::vector::index::{BoundedAnnCollector, BoundedUniqueAnnCollector};
28
29type CombinedBinaryCandidates = (Vec<AnnDocumentCandidate>, Vec<(u32, u16, f32)>);
32
33const ANN_HEADER_MAGIC: u32 = 0x3152_4e41; const ANN_FOOTER_MAGIC: u32 = 0x3146_4e41; const ANN_DISK_VERSION: u16 = 1;
36const ANN_HEADER_SIZE: usize = 56;
37const ANN_RUN_SIZE: usize = 48;
38const ANN_FOOTER_SIZE: usize = 24;
39#[cfg(feature = "native")]
40const COPY_CHUNK: usize = 8 * 1024 * 1024;
41#[cfg(feature = "native")]
42const PREFETCH_COALESCE_GAP: usize = 4 * 1024;
43const BINARY_SCORE_BATCH: usize = 8_192;
44const TQ_PRUNE_ESTIMATE_BOUND: f32 = 1.3;
49#[cfg(feature = "native")]
55const TQ_PARALLEL_SCAN_MIN_VECTORS: usize = 65_536;
56#[cfg(feature = "native")]
57const TQ_PARALLEL_SCAN_CHUNK_BLOCKS: usize = 512;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub(crate) enum AnnKind {
61 BinaryIvf = 2,
64 TqFlat = 3,
66 IvfTq = 4,
68}
69
70impl AnnKind {
71 fn from_u8(value: u8) -> io::Result<Self> {
72 match value {
73 1 => Err(invalid_data(
74 "ANN kind 1 (IVF-PQ) is no longer supported; recreate the index \
75 with `ivf_tq` and reindex",
76 )),
77 2 => Ok(Self::BinaryIvf),
78 3 => Ok(Self::TqFlat),
79 4 => Ok(Self::IvfTq),
80 _ => Err(invalid_data(format!("unknown ANN kind {value}"))),
81 }
82 }
83}
84
85fn expected_codes_column_len(kind: AnnKind, count: usize, code_size: usize) -> io::Result<usize> {
89 match kind {
90 AnnKind::BinaryIvf => count
91 .checked_mul(code_size)
92 .ok_or_else(|| invalid_data("ANN code column size overflows usize")),
93 AnnKind::TqFlat => {
95 crate::structures::vector::quantization::tq_codes_column_len_checked(count, code_size)
96 .ok_or_else(|| invalid_data("TQ code column size overflows usize"))
97 }
98 AnnKind::IvfTq => crate::structures::vector::quantization::tq_ivf_codes_column_len_checked(
99 count, code_size,
100 )
101 .ok_or_else(|| invalid_data("IVF-TQ code column size overflows usize")),
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub(crate) struct AnnDiskHeader {
107 pub kind: AnnKind,
108 pub routing: IvfRoutingMode,
109 pub dim: usize,
110 pub code_size: usize,
111 pub num_clusters: u32,
112 pub quantizer_version: u64,
113 pub codebook_version: u64,
114 pub vector_count: usize,
115}
116
117#[derive(Debug)]
118struct AnnRun {
119 cluster_id: u32,
120 doc_base: u32,
121 max_doc_id: u32,
122 count: usize,
123 doc_ids: Range<usize>,
124 ordinals: Range<usize>,
125 codes: Range<usize>,
126}
127
128pub(crate) struct AnnDiskIndex {
131 #[cfg(feature = "native")]
133 heap_pins: crate::segment::pin::HeapPinSet,
134 raw: OwnedBytes,
135 header: AnnDiskHeader,
136 runs: Vec<AnnRun>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq)]
147pub struct AnnHealth {
148 pub vectors: u64,
150 pub clusters_nonempty: u32,
152 pub clusters_total: u32,
154 pub runs: u32,
156 pub largest_cluster: u32,
158 pub largest_cluster_vectors: u64,
159 pub imbalance: f64,
164 pub payload_bytes: u64,
166}
167
168impl AnnHealth {
169 pub fn fragmentation(&self) -> f64 {
173 if self.clusters_nonempty == 0 {
174 return 0.0;
175 }
176 f64::from(self.runs) / f64::from(self.clusters_nonempty)
177 }
178
179 pub fn largest_cluster_share(&self) -> f64 {
181 if self.vectors == 0 {
182 return 0.0;
183 }
184 self.largest_cluster_vectors as f64 / self.vectors as f64
185 }
186}
187
188const ANN_SKEW_WARN_SHARE: f64 = 0.05;
192const ANN_SKEW_WARN_MIN_VECTORS: u64 = 100_000;
193const ANN_FRAGMENTATION_WARN: f64 = 8.0;
196
197#[derive(Debug, Clone, Copy, PartialEq)]
203pub(crate) struct AnnDocumentCandidate {
204 pub(crate) doc_id: u32,
205 pub(crate) score: f32,
206}
207
208impl AnnDiskIndex {
209 pub(crate) fn open(
210 raw: OwnedBytes,
211 expected_kind: AnnKind,
212 total_docs: u32,
213 ) -> io::Result<Self> {
214 if raw.len() < ANN_HEADER_SIZE + ANN_FOOTER_SIZE {
215 return Err(invalid_data("ANN payload is shorter than header + footer"));
216 }
217 let bytes = raw.as_slice();
218 let mut header_cursor = std::io::Cursor::new(&bytes[..ANN_HEADER_SIZE]);
219 if header_cursor.read_u32::<LittleEndian>()? != ANN_HEADER_MAGIC {
220 return Err(invalid_data("ANN payload has unsupported header magic"));
221 }
222 let kind = AnnKind::from_u8(header_cursor.read_u8()?)?;
223 if kind != expected_kind {
224 return Err(invalid_data(format!(
225 "ANN payload kind {kind:?} does not match expected {expected_kind:?}"
226 )));
227 }
228 let routing = routing_from_u8(header_cursor.read_u8()?)?;
229 if header_cursor.read_u16::<LittleEndian>()? != ANN_DISK_VERSION {
230 return Err(invalid_data("ANN payload has unsupported format version"));
231 }
232 let dim = header_cursor.read_u32::<LittleEndian>()? as usize;
233 let code_size = header_cursor.read_u32::<LittleEndian>()? as usize;
234 let num_clusters = header_cursor.read_u32::<LittleEndian>()?;
235 if header_cursor.read_u32::<LittleEndian>()? != 0 {
236 return Err(invalid_data("ANN header reserved field is non-zero"));
237 }
238 let quantizer_version = header_cursor.read_u64::<LittleEndian>()?;
239 let codebook_version = header_cursor.read_u64::<LittleEndian>()?;
240 let vector_count = usize::try_from(header_cursor.read_u64::<LittleEndian>()?)
241 .map_err(|_| invalid_data("ANN vector count exceeds usize"))?;
242 if header_cursor.read_u64::<LittleEndian>()? != 0 {
243 return Err(invalid_data("ANN header tail is non-zero"));
244 }
245 let header = AnnDiskHeader {
246 kind,
247 routing,
248 dim,
249 code_size,
250 num_clusters,
251 quantizer_version,
252 codebook_version,
253 vector_count,
254 };
255 validate_header(&header)?;
256
257 let footer_start = bytes.len() - ANN_FOOTER_SIZE;
258 let mut footer_cursor = std::io::Cursor::new(&bytes[footer_start..]);
259 let directory_offset = usize::try_from(footer_cursor.read_u64::<LittleEndian>()?)
260 .map_err(|_| invalid_data("ANN directory offset exceeds usize"))?;
261 let num_runs = usize::try_from(footer_cursor.read_u64::<LittleEndian>()?)
262 .map_err(|_| invalid_data("ANN run count exceeds usize"))?;
263 if footer_cursor.read_u32::<LittleEndian>()? != ANN_FOOTER_MAGIC
264 || footer_cursor.read_u32::<LittleEndian>()? != u32::from(ANN_DISK_VERSION)
265 {
266 return Err(invalid_data("ANN payload has unsupported footer"));
267 }
268 if num_runs == 0 {
269 return Err(invalid_data("ANN payload has no cluster runs"));
270 }
271 let directory_len = num_runs
272 .checked_mul(ANN_RUN_SIZE)
273 .ok_or_else(|| invalid_data("ANN directory size overflows usize"))?;
274 if directory_offset < ANN_HEADER_SIZE
275 || directory_offset.checked_add(directory_len) != Some(footer_start)
276 {
277 return Err(invalid_data("ANN directory does not end at the footer"));
278 }
279
280 let mut runs = Vec::with_capacity(num_runs);
281 let mut directory_cursor = std::io::Cursor::new(&bytes[directory_offset..footer_start]);
282 let mut previous_cluster = None;
283 let mut counted_vectors = 0usize;
284 for _ in 0..num_runs {
285 let cluster_id = directory_cursor.read_u32::<LittleEndian>()?;
286 let doc_base = directory_cursor.read_u32::<LittleEndian>()?;
287 let count = directory_cursor.read_u32::<LittleEndian>()? as usize;
288 let max_doc_id = directory_cursor.read_u32::<LittleEndian>()?;
289 let doc_ids_offset = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
290 .map_err(|_| invalid_data("ANN doc-ID offset exceeds usize"))?;
291 let ordinals_offset = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
292 .map_err(|_| invalid_data("ANN ordinal offset exceeds usize"))?;
293 let codes_offset = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
294 .map_err(|_| invalid_data("ANN code offset exceeds usize"))?;
295 let codes_len = usize::try_from(directory_cursor.read_u64::<LittleEndian>()?)
296 .map_err(|_| invalid_data("ANN code length exceeds usize"))?;
297 if count == 0
298 || cluster_id >= num_clusters
299 || previous_cluster.is_some_and(|previous| previous > cluster_id)
300 || doc_base
301 .checked_add(max_doc_id)
302 .is_none_or(|doc_id| doc_id >= total_docs)
303 {
304 return Err(invalid_data("ANN run metadata is invalid"));
305 }
306 previous_cluster = Some(cluster_id);
307 let doc_ids_len = count
308 .checked_mul(std::mem::size_of::<u32>())
309 .ok_or_else(|| invalid_data("ANN doc-ID column size overflows usize"))?;
310 let ordinals_len = count
311 .checked_mul(std::mem::size_of::<u16>())
312 .ok_or_else(|| invalid_data("ANN ordinal column size overflows usize"))?;
313 let expected_codes_len = expected_codes_column_len(kind, count, code_size)?;
314 let doc_ids_end = doc_ids_offset
315 .checked_add(doc_ids_len)
316 .ok_or_else(|| invalid_data("ANN doc-ID range overflows usize"))?;
317 let ordinals_end = ordinals_offset
318 .checked_add(ordinals_len)
319 .ok_or_else(|| invalid_data("ANN ordinal range overflows usize"))?;
320 let codes_end = codes_offset
321 .checked_add(codes_len)
322 .ok_or_else(|| invalid_data("ANN code range overflows usize"))?;
323 if doc_ids_offset < ANN_HEADER_SIZE
324 || ordinals_offset != doc_ids_end
325 || codes_offset != ordinals_end
326 || codes_len != expected_codes_len
327 || codes_end > directory_offset
328 {
329 return Err(invalid_data("ANN run columns are not contiguous/in bounds"));
330 }
331 runs.push(AnnRun {
332 cluster_id,
333 doc_base,
334 max_doc_id,
335 count,
336 doc_ids: doc_ids_offset..doc_ids_end,
337 ordinals: ordinals_offset..ordinals_end,
338 codes: codes_offset..codes_end,
339 });
340 counted_vectors = counted_vectors
341 .checked_add(count)
342 .ok_or_else(|| invalid_data("ANN run vector count overflows usize"))?;
343 }
344 let mut payload_order: Vec<usize> = (0..runs.len()).collect();
345 payload_order.sort_unstable_by_key(|&index| runs[index].doc_ids.start);
346 let mut expected_payload_offset = ANN_HEADER_SIZE;
347 for index in payload_order {
348 let run = &runs[index];
349 if run.doc_ids.start != expected_payload_offset {
350 return Err(invalid_data("ANN payload runs overlap or contain gaps"));
351 }
352 expected_payload_offset = run.codes.end;
353 }
354 if expected_payload_offset != directory_offset || counted_vectors != vector_count {
355 return Err(invalid_data(
356 "ANN payload coverage/vector count is inconsistent",
357 ));
358 }
359
360 #[cfg(feature = "native")]
367 raw.madvise_range(
368 ANN_HEADER_SIZE..directory_offset,
369 if kind == AnnKind::TqFlat {
370 libc::MADV_SEQUENTIAL
371 } else {
372 libc::MADV_RANDOM
373 },
374 );
375
376 Ok(Self {
377 #[cfg(feature = "native")]
378 heap_pins: Default::default(),
379 raw,
380 header,
381 runs,
382 })
383 }
384
385 pub(crate) fn health(&self) -> AnnHealth {
388 let mut vectors = 0u64;
389 let mut clusters_nonempty = 0u32;
390 let mut payload_bytes = 0u64;
391 let mut largest = (0u32, 0u64);
392 let mut sum_squares = 0f64;
393 let mut index = 0usize;
395 while index < self.runs.len() {
396 let cluster_id = self.runs[index].cluster_id;
397 let mut cluster_vectors = 0u64;
398 while index < self.runs.len() && self.runs[index].cluster_id == cluster_id {
399 let run = &self.runs[index];
400 cluster_vectors += run.count as u64;
401 payload_bytes += (run.codes.end - run.codes.start) as u64;
402 index += 1;
403 }
404 vectors += cluster_vectors;
405 clusters_nonempty += 1;
406 sum_squares += (cluster_vectors as f64) * (cluster_vectors as f64);
407 if cluster_vectors > largest.1 {
408 largest = (cluster_id, cluster_vectors);
409 }
410 }
411 let imbalance = if vectors == 0 || clusters_nonempty == 0 {
412 0.0
413 } else {
414 f64::from(clusters_nonempty) * sum_squares / ((vectors as f64) * (vectors as f64))
415 };
416 AnnHealth {
417 vectors,
418 clusters_nonempty,
419 clusters_total: self.header.num_clusters,
420 runs: self.runs.len() as u32,
421 largest_cluster: largest.0,
422 largest_cluster_vectors: largest.1,
423 imbalance,
424 payload_bytes,
425 }
426 }
427
428 pub(crate) fn report_health(&self, index_label: &str, field_id: u32, segment_id: u128) {
433 let health = self.health();
434 let share = health.largest_cluster_share();
435 let fragmentation = health.fragmentation();
436 log::info!(
437 "[ann_health] index={index_label} field={field_id} segment={segment_id:016x}: \
438 vectors={} clusters={}/{} runs={} fragmentation={fragmentation:.2} \
439 imbalance={:.2} largest_leaf={:.2}% payload={}",
440 health.vectors,
441 health.clusters_nonempty,
442 health.clusters_total,
443 health.runs,
444 health.imbalance,
445 100.0 * share,
446 crate::format_bytes(health.payload_bytes),
447 );
448 crate::observe::ann_health(
449 index_label,
450 field_id,
451 health.imbalance,
452 fragmentation,
453 share,
454 );
455 if share >= ANN_SKEW_WARN_SHARE && health.vectors >= ANN_SKEW_WARN_MIN_VECTORS {
456 log::warn!(
457 "[ann_health] index={index_label} field={field_id} segment={segment_id:016x}: \
458 leaf {} holds {:.1}% of {} vectors — every query probing it scans that leaf \
459 in full; degenerate embeddings collapse into one leaf exactly like this",
460 health.largest_cluster,
461 100.0 * share,
462 health.vectors,
463 );
464 }
465 if fragmentation >= ANN_FRAGMENTATION_WARN {
466 log::warn!(
467 "[ann_health] index={index_label} field={field_id} segment={segment_id:016x}: \
468 {fragmentation:.1} extents per probed cluster ({} runs / {} clusters) — \
469 cold probes pay that many seeks; the next merge or vector-generation rewrite \
470 compacts to 1.0",
471 health.runs,
472 health.clusters_nonempty,
473 );
474 }
475 }
476
477 pub(crate) fn header(&self) -> &AnnDiskHeader {
478 &self.header
479 }
480
481 pub(crate) fn estimated_heap_bytes(&self) -> usize {
482 std::mem::size_of::<Self>() + self.runs.capacity() * std::mem::size_of::<AnnRun>()
483 }
484
485 #[cfg(feature = "native")]
486 pub(crate) fn pin_lookup_directory(
487 &mut self,
488 mode: crate::segment::pin::PinMode,
489 remaining: &mut u64,
490 report: &mut crate::segment::pin::PinReport,
491 ) {
492 let before = self.heap_pins.report();
493 self.heap_pins
494 .pin_slice(&self.runs, "ANN cluster-run directory", mode, remaining);
495 let after = self.heap_pins.report();
496 report.intended_bytes += after.intended_bytes - before.intended_bytes;
497 report.pinned_bytes += after.pinned_bytes - before.pinned_bytes;
498 report.skipped_budget_bytes += after.skipped_budget_bytes - before.skipped_budget_bytes;
499 report.failed_bytes += after.failed_bytes - before.failed_bytes;
500 report.heap_copy_bytes += after.heap_copy_bytes - before.heap_copy_bytes;
501 }
502
503 fn cluster_runs(&self, cluster_id: u32) -> &[AnnRun] {
504 let start = self.runs.partition_point(|run| run.cluster_id < cluster_id);
505 let end = self
506 .runs
507 .partition_point(|run| run.cluster_id <= cluster_id);
508 &self.runs[start..end]
509 }
510
511 #[cfg(feature = "native")]
518 fn prefetch_cluster_runs(&self, cluster_ids: &[u32]) {
519 if cluster_ids.is_empty() || !self.raw.is_mmap() {
520 return;
521 }
522 let mut ranges = Vec::with_capacity(cluster_ids.len());
523 for &cluster_id in cluster_ids {
524 ranges.extend(
525 self.cluster_runs(cluster_id)
526 .iter()
527 .map(|run| run.doc_ids.start..run.codes.end),
528 );
529 }
530 coalesce_prefetch_ranges(&mut ranges);
531 for range in ranges {
532 self.raw.madvise_range(range, libc::MADV_WILLNEED);
533 }
534 }
535
536 pub(crate) fn search_tq_combined_documents(
541 &self,
542 k: usize,
543 plan: &crate::structures::TqQueryPlan,
544 combiner: crate::query::MultiValueCombiner,
545 ) -> io::Result<Vec<AnnDocumentCandidate>> {
546 use crate::structures::vector::quantization::{TQ_BLOCK_LANES, tq_block_bytes};
547
548 combiner
549 .validate()
550 .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?;
551 if plan.padded_dim() != self.header.code_size * 2 {
552 return Err(io::Error::new(
553 io::ErrorKind::InvalidInput,
554 "TQ query plan does not match the payload dimension",
555 ));
556 }
557 if k == 0 {
558 return Ok(Vec::new());
559 }
560
561 let block_bytes = tq_block_bytes(self.header.code_size);
562 let bytes = self.raw.as_slice();
563 let mut top_documents = BoundedAnnCollector::<true, true>::new(k);
564 let mut ordinal_scores = Vec::new();
565 let mut scores = [0.0f32; TQ_BLOCK_LANES];
566
567 for run in &self.runs {
568 let mut current_doc = None;
569 let codes = &bytes[run.codes.clone()];
570 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
571 crate::structures::vector::quantization::tq_score_block(plan, block, &mut scores);
572 let lane_base = block_index * TQ_BLOCK_LANES;
573 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
574 for (lane, &score) in scores.iter().enumerate().take(lanes) {
575 let index = lane_base + lane;
576 let doc_id = run_doc_id(bytes, run, index)?;
577 if current_doc.is_some_and(|previous| doc_id < previous) {
578 return Err(invalid_data("flat TQ run is not grouped by document ID"));
579 }
580 if current_doc.is_some_and(|previous| doc_id != previous) {
581 retain_combined_document(
582 &mut top_documents,
583 current_doc.expect("current document is present"),
584 &ordinal_scores,
585 combiner,
586 );
587 ordinal_scores.clear();
588 }
589 current_doc = Some(doc_id);
590 if score.is_finite() {
591 ordinal_scores.push((
592 u32::from(read_u16(bytes, run.ordinals.start + index * 2)),
593 score,
594 ));
595 }
596 }
597 }
598 if let Some(doc_id) = current_doc {
599 retain_combined_document(&mut top_documents, doc_id, &ordinal_scores, combiner);
600 ordinal_scores.clear();
601 }
602 }
603
604 Ok(top_documents
605 .into_sorted_results()
606 .into_iter()
607 .map(|(doc_id, _, score)| AnnDocumentCandidate { doc_id, score })
608 .collect())
609 }
610
611 pub(crate) fn search_ivf_tq_combined_documents(
619 &self,
620 k: usize,
621 plan: &crate::structures::TqIvfQueryPlan,
622 combiner: crate::query::MultiValueCombiner,
623 ) -> io::Result<Vec<AnnDocumentCandidate>> {
624 use crate::structures::vector::quantization::{
625 TQ_BLOCK_LANES, tq_ivf_block_bytes, tq_score_ivf_block,
626 };
627
628 validate_combined_search(combiner)?;
629 let tq_plan = plan.tq_plan();
630 self.validate_ivf_tq_query_plan(plan)?;
631 if k == 0 {
632 return Ok(Vec::new());
633 }
634 #[cfg(feature = "native")]
635 self.prefetch_cluster_runs(&plan.cluster_ids);
636
637 let block_bytes = tq_ivf_block_bytes(self.header.code_size);
638 let bytes = self.raw.as_slice();
639 let mut ordinal_scores = Vec::new();
640 ordinal_scores
641 .try_reserve_exact(probed_posting_count(self, &plan.cluster_ids)?)
642 .map_err(|_| invalid_data("IVF-TQ combined score buffer allocation failed"))?;
643 let mut scores = [0.0f32; TQ_BLOCK_LANES];
644 for (cluster_id, cluster_dot) in plan.cluster_dots() {
645 for run in self.cluster_runs(cluster_id) {
646 let codes = &bytes[run.codes.clone()];
647 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
648 tq_score_ivf_block(tq_plan, block, cluster_dot, &mut scores);
649 let lane_base = block_index * TQ_BLOCK_LANES;
650 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
651 for (lane, &score) in scores.iter().enumerate().take(lanes) {
652 if !score.is_finite() {
653 continue;
654 }
655 let index = lane_base + lane;
656 ordinal_scores.push((
657 run_doc_id(bytes, run, index)?,
658 read_u16(bytes, run.ordinals.start + index * 2),
659 score,
660 ));
661 }
662 }
663 }
664 }
665 Ok(combine_scored_ordinals(ordinal_scores, k, combiner))
666 }
667
668 pub(crate) fn search_binary_combined_documents(
677 &self,
678 k: usize,
679 query: &[u8],
680 cluster_ids: &[u32],
681 combiner: crate::query::MultiValueCombiner,
682 ) -> io::Result<CombinedBinaryCandidates> {
683 validate_combined_search(combiner)?;
684 if query.len() != self.header.code_size {
685 return Err(io::Error::new(
686 io::ErrorKind::InvalidInput,
687 "binary ANN query has the wrong byte length",
688 ));
689 }
690 if k == 0 {
691 return Ok((Vec::new(), Vec::new()));
692 }
693 #[cfg(feature = "native")]
694 self.prefetch_cluster_runs(cluster_ids);
695
696 let bytes = self.raw.as_slice();
697 let mut score_batch = vec![0.0f32; BINARY_SCORE_BATCH.min(self.header.vector_count)];
698 let mut ordinal_scores = Vec::new();
699 ordinal_scores
700 .try_reserve_exact(probed_posting_count(self, cluster_ids)?)
701 .map_err(|_| invalid_data("binary combined score buffer allocation failed"))?;
702 score_binary_cluster_runs(
703 self,
704 bytes,
705 query,
706 cluster_ids,
707 &mut score_batch,
708 &mut ordinal_scores,
709 )?;
710 Ok(combine_scored_ordinals_retaining(
711 ordinal_scores,
712 k,
713 combiner,
714 ))
715 }
716
717 pub(crate) fn search_tq_distinct(
720 &self,
721 k: usize,
722 plan: &crate::structures::TqQueryPlan,
723 ) -> io::Result<Vec<(u32, u16, f32)>> {
724 use crate::structures::vector::quantization::{TQ_BLOCK_LANES, tq_block_bytes};
725
726 if plan.padded_dim() != self.header.code_size * 2 {
727 return Err(io::Error::new(
728 io::ErrorKind::InvalidInput,
729 "TQ query plan does not match the payload dimension",
730 ));
731 }
732 let block_bytes = tq_block_bytes(self.header.code_size);
733 let bytes = self.raw.as_slice();
734
735 #[cfg(feature = "native")]
740 if self.header.vector_count >= TQ_PARALLEL_SCAN_MIN_VECTORS {
741 use rayon::prelude::*;
742 let collector = self
743 .runs
744 .par_iter()
745 .flat_map(|run| {
746 let codes = &bytes[run.codes.clone()];
747 let blocks = codes.len() / block_bytes;
748 (0..blocks.div_ceil(TQ_PARALLEL_SCAN_CHUNK_BLOCKS))
749 .into_par_iter()
750 .map(move |chunk| (run, chunk * TQ_PARALLEL_SCAN_CHUNK_BLOCKS, blocks))
751 })
752 .try_fold(
753 || BoundedAnnCollector::<true, true>::new(k),
754 |mut collector, (run, first_block, total_blocks)| {
755 let codes = &bytes[run.codes.clone()];
756 let last_block =
757 (first_block + TQ_PARALLEL_SCAN_CHUNK_BLOCKS).min(total_blocks);
758 let mut scores = [0.0f32; TQ_BLOCK_LANES];
759 for block_index in first_block..last_block {
760 let block = &codes[block_index * block_bytes..][..block_bytes];
761 crate::structures::vector::quantization::tq_score_block(
762 plan,
763 block,
764 &mut scores,
765 );
766 let lane_base = block_index * TQ_BLOCK_LANES;
767 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
768 for (lane, &score) in scores.iter().enumerate().take(lanes) {
769 let index = lane_base + lane;
770 collector.insert(
771 run_doc_id(bytes, run, index)?,
772 read_u16(bytes, run.ordinals.start + index * 2),
773 score,
774 );
775 }
776 }
777 Ok::<_, io::Error>(collector)
778 },
779 )
780 .try_reduce(
781 || BoundedAnnCollector::<true, true>::new(k),
782 |mut collector, partial| {
783 collector.merge_from(partial);
784 Ok(collector)
785 },
786 )?;
787 return Ok(collector.into_sorted_results());
788 }
789
790 let mut collector = BoundedAnnCollector::<true, true>::new(k);
791 let mut scores = [0.0f32; TQ_BLOCK_LANES];
792 for run in &self.runs {
793 let codes = &bytes[run.codes.clone()];
794 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
795 crate::structures::vector::quantization::tq_score_block(plan, block, &mut scores);
796 let lane_base = block_index * TQ_BLOCK_LANES;
797 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
798 for (lane, &score) in scores.iter().enumerate().take(lanes) {
799 let index = lane_base + lane;
800 collector.insert(
801 run_doc_id(bytes, run, index)?,
802 read_u16(bytes, run.ordinals.start + index * 2),
803 score,
804 );
805 }
806 }
807 }
808 Ok(collector.into_sorted_results())
809 }
810
811 pub(crate) fn search_ivf_tq_distinct(
819 &self,
820 k: usize,
821 plan: &crate::structures::TqIvfQueryPlan,
822 ) -> io::Result<Vec<(u32, u16, f32)>> {
823 use crate::structures::vector::quantization::{
824 TQ_BLOCK_LANES, tq_ivf_block_bytes, tq_score_ivf_block,
825 };
826
827 let tq_plan = plan.tq_plan();
828 self.validate_ivf_tq_query_plan(plan)?;
829 #[cfg(feature = "native")]
830 self.prefetch_cluster_runs(&plan.cluster_ids);
831 let block_bytes = tq_ivf_block_bytes(self.header.code_size);
832 let bytes = self.raw.as_slice();
833 let mut collector = BoundedAnnCollector::<true, true>::new(k);
834 let mut scores = [0.0f32; TQ_BLOCK_LANES];
835 let mut pruned_blocks = 0usize;
836 let mut scored_blocks = 0usize;
837 for (cluster_id, cluster_dot) in plan.cluster_dots() {
838 for run in self.cluster_runs(cluster_id) {
839 let codes = &bytes[run.codes.clone()];
840 let block_count = codes.len() / block_bytes;
841 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
842 if let Some(threshold) = collector.pruning_threshold() {
843 let max_scale = tq_ivf_block_max_scale(block);
844 if cluster_dot + max_scale * TQ_PRUNE_ESTIMATE_BOUND <= threshold {
845 pruned_blocks += block_count - block_index;
846 break;
847 }
848 }
849 scored_blocks += 1;
850 tq_score_ivf_block(tq_plan, block, cluster_dot, &mut scores);
851 let lane_base = block_index * TQ_BLOCK_LANES;
852 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
853 for (lane, &score) in scores.iter().enumerate().take(lanes) {
854 let index = lane_base + lane;
855 collector.insert(
856 run_doc_id(bytes, run, index)?,
857 read_u16(bytes, run.ordinals.start + index * 2),
858 score,
859 );
860 }
861 }
862 }
863 }
864 if pruned_blocks > 0 {
865 log::debug!(
866 "[search_ivf_tq] pruned {pruned_blocks} of {} blocks via scale bounds",
867 pruned_blocks + scored_blocks,
868 );
869 }
870 Ok(collector.into_sorted_results())
871 }
872
873 fn validate_ivf_tq_query_plan(
874 &self,
875 plan: &crate::structures::TqIvfQueryPlan,
876 ) -> io::Result<()> {
877 if self.header.kind != AnnKind::IvfTq
878 || !crate::structures::is_ivf_tq_cosine_generation(self.header.quantizer_version)
879 {
880 return Err(invalid_data(
881 "legacy raw IVF-TQ payloads cannot be searched; rebuild the index",
882 ));
883 }
884 if plan.tq_plan().padded_dim() != self.header.code_size * 2
885 || plan.quantizer_version != self.header.quantizer_version
886 || plan.fingerprint != self.header.codebook_version
887 {
888 return Err(io::Error::new(
889 io::ErrorKind::InvalidInput,
890 "IVF-TQ query plan does not match the payload generation",
891 ));
892 }
893 Ok(())
894 }
895
896 pub(crate) fn search_binary_clusters<const BY_DOCUMENT: bool>(
897 &self,
898 query: &[u8],
899 k: usize,
900 cluster_ids: &[u32],
901 ) -> io::Result<Vec<(u32, u16, f32)>> {
902 if query.len() != self.header.code_size {
903 return Err(io::Error::new(
904 io::ErrorKind::InvalidInput,
905 "binary ANN query has the wrong byte length",
906 ));
907 }
908 #[cfg(feature = "native")]
909 self.prefetch_cluster_runs(cluster_ids);
910 let bytes = self.raw.as_slice();
911 let mut scores = vec![0.0f32; BINARY_SCORE_BATCH.min(self.header.vector_count)];
912 if BY_DOCUMENT {
913 let mut collector = BoundedAnnCollector::<true, true>::new(k);
914 score_binary_cluster_runs(
915 self,
916 bytes,
917 query,
918 cluster_ids,
919 &mut scores,
920 &mut collector,
921 )?;
922 return Ok(collector.into_sorted_results());
923 }
924
925 debug_assert!(
929 {
930 let mut seen = rustc_hash::FxHashSet::default();
931 cluster_ids
932 .iter()
933 .all(|cluster_id| seen.insert(*cluster_id))
934 },
935 "an IVF probe plan must not repeat a cluster",
936 );
937 let mut collector = BoundedUniqueAnnCollector::<true>::new(k);
938 score_binary_cluster_runs(self, bytes, query, cluster_ids, &mut scores, &mut collector)?;
939 Ok(collector.into_sorted_results())
940 }
941}
942
943#[cfg(feature = "native")]
944fn coalesce_prefetch_ranges(ranges: &mut Vec<Range<usize>>) {
945 if ranges.len() < 2 {
946 return;
947 }
948 ranges.sort_unstable_by_key(|range| range.start);
949 let mut output_len = 1usize;
950 for input_index in 1..ranges.len() {
951 let next_start = ranges[input_index].start;
952 let next_end = ranges[input_index].end;
953 let previous = &mut ranges[output_len - 1];
954 if next_start <= previous.end.saturating_add(PREFETCH_COALESCE_GAP) {
955 previous.end = previous.end.max(next_end);
956 } else {
957 ranges[output_len] = next_start..next_end;
958 output_len += 1;
959 }
960 }
961 ranges.truncate(output_len);
962}
963
964trait AnnScoreSink {
965 fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32);
966}
967
968impl<const BY_DOCUMENT: bool> AnnScoreSink for BoundedAnnCollector<BY_DOCUMENT, true> {
969 #[inline]
970 fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32) {
971 self.insert(doc_id, ordinal, score);
972 }
973}
974
975impl AnnScoreSink for BoundedUniqueAnnCollector<true> {
976 #[inline]
977 fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32) {
978 self.insert(doc_id, ordinal, score);
979 }
980}
981
982impl AnnScoreSink for Vec<(u32, u16, f32)> {
983 #[inline]
984 fn insert_score(&mut self, doc_id: u32, ordinal: u16, score: f32) {
985 if score.is_finite() {
986 self.push((doc_id, ordinal, score));
987 }
988 }
989}
990
991fn probed_posting_count(index: &AnnDiskIndex, cluster_ids: &[u32]) -> io::Result<usize> {
992 cluster_ids.iter().try_fold(0usize, |count, &cluster_id| {
993 index
994 .cluster_runs(cluster_id)
995 .iter()
996 .try_fold(count, |count, run| {
997 count
998 .checked_add(run.count)
999 .ok_or_else(|| invalid_data("ANN probed posting count overflows usize"))
1000 })
1001 })
1002}
1003
1004fn score_binary_cluster_runs(
1005 index: &AnnDiskIndex,
1006 bytes: &[u8],
1007 query: &[u8],
1008 cluster_ids: &[u32],
1009 scores: &mut [f32],
1010 collector: &mut impl AnnScoreSink,
1011) -> io::Result<()> {
1012 for &cluster_id in cluster_ids {
1013 for run in index.cluster_runs(cluster_id) {
1014 score_binary_run(
1015 bytes,
1016 run,
1017 query,
1018 index.header.dim,
1019 index.header.code_size,
1020 scores,
1021 collector,
1022 )?;
1023 }
1024 }
1025 Ok(())
1026}
1027
1028fn score_binary_run(
1029 bytes: &[u8],
1030 run: &AnnRun,
1031 query: &[u8],
1032 dim_bits: usize,
1033 code_size: usize,
1034 scores: &mut [f32],
1035 collector: &mut impl AnnScoreSink,
1036) -> io::Result<()> {
1037 for batch_start in (0..run.count).step_by(BINARY_SCORE_BATCH) {
1038 let batch_count = BINARY_SCORE_BATCH.min(run.count - batch_start);
1039 let code_start = run.codes.start + batch_start * code_size;
1040 let code_end = code_start + batch_count * code_size;
1041 crate::structures::simd::batch_hamming_scores(
1042 query,
1043 &bytes[code_start..code_end],
1044 code_size,
1045 dim_bits,
1046 &mut scores[..batch_count],
1047 );
1048 for (batch_index, &score) in scores.iter().enumerate().take(batch_count) {
1049 let index = batch_start + batch_index;
1050 collector.insert_score(
1051 run_doc_id(bytes, run, index)?,
1052 read_u16(bytes, run.ordinals.start + index * 2),
1053 score,
1054 );
1055 }
1056 }
1057 Ok(())
1058}
1059
1060#[inline]
1061fn retain_combined_document(
1062 collector: &mut BoundedAnnCollector<true, true>,
1063 doc_id: u32,
1064 ordinal_scores: &[(u32, f32)],
1065 combiner: crate::query::MultiValueCombiner,
1066) {
1067 if !ordinal_scores.is_empty() {
1068 collector.insert(doc_id, 0, combiner.combine(ordinal_scores));
1069 }
1070}
1071
1072fn validate_combined_search(combiner: crate::query::MultiValueCombiner) -> io::Result<()> {
1073 combiner
1074 .validate()
1075 .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))
1076}
1077
1078fn combine_scored_ordinals(
1084 scores: Vec<(u32, u16, f32)>,
1085 k: usize,
1086 combiner: crate::query::MultiValueCombiner,
1087) -> Vec<AnnDocumentCandidate> {
1088 combine_scored_ordinals_retaining(scores, k, combiner).0
1089}
1090
1091fn combine_scored_ordinals_retaining(
1097 mut scores: Vec<(u32, u16, f32)>,
1098 k: usize,
1099 combiner: crate::query::MultiValueCombiner,
1100) -> CombinedBinaryCandidates {
1101 if k == 0 || scores.is_empty() {
1102 return (Vec::new(), Vec::new());
1103 }
1104 scores.retain(|entry| entry.2.is_finite());
1105 scores.sort_unstable_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
1106
1107 let mut unique_len = 0usize;
1112 for read_index in 0..scores.len() {
1113 let candidate = scores[read_index];
1114 if unique_len > 0
1115 && scores[unique_len - 1].0 == candidate.0
1116 && scores[unique_len - 1].1 == candidate.1
1117 {
1118 if candidate.2.total_cmp(&scores[unique_len - 1].2).is_gt() {
1119 scores[unique_len - 1].2 = candidate.2;
1120 }
1121 } else {
1122 scores[unique_len] = candidate;
1123 unique_len += 1;
1124 }
1125 }
1126 scores.truncate(unique_len);
1127
1128 let mut top_documents = BoundedAnnCollector::<true, true>::new(k);
1129 let mut current_doc = None;
1130 let mut ordinal_scores = Vec::new();
1131 for &(doc_id, ordinal, score) in &scores {
1132 if current_doc.is_some_and(|current| current != doc_id) {
1133 retain_combined_document(
1134 &mut top_documents,
1135 current_doc.expect("current document is present"),
1136 &ordinal_scores,
1137 combiner,
1138 );
1139 ordinal_scores.clear();
1140 }
1141 current_doc = Some(doc_id);
1142 ordinal_scores.push((u32::from(ordinal), score));
1143 }
1144 if let Some(doc_id) = current_doc {
1145 retain_combined_document(&mut top_documents, doc_id, &ordinal_scores, combiner);
1146 }
1147
1148 let candidates: Vec<AnnDocumentCandidate> = top_documents
1149 .into_sorted_results()
1150 .into_iter()
1151 .map(|(doc_id, _, score)| AnnDocumentCandidate { doc_id, score })
1152 .collect();
1153
1154 let mut retained_ids: Vec<u32> = candidates
1158 .iter()
1159 .map(|candidate| candidate.doc_id)
1160 .collect();
1161 retained_ids.sort_unstable();
1162 let mut retained = Vec::with_capacity(scores.len().min(retained_ids.len().saturating_mul(4)));
1163 let mut cursor = 0usize;
1164 for entry in scores {
1165 while cursor < retained_ids.len() && retained_ids[cursor] < entry.0 {
1166 cursor += 1;
1167 }
1168 if retained_ids.get(cursor) == Some(&entry.0) {
1169 retained.push(entry);
1170 }
1171 }
1172 (candidates, retained)
1173}
1174
1175#[cfg(feature = "native")]
1176pub(crate) fn write_built_binary_ivf(
1177 index: &BinaryIvfIndex,
1178 routing: IvfRoutingMode,
1179 writer: &mut (impl Write + ?Sized),
1180) -> io::Result<u64> {
1181 let runs: Vec<_> = index
1182 .clusters
1183 .iter()
1184 .map(|(cluster_id, cluster)| BuildRun {
1185 cluster_id: *cluster_id,
1186 doc_ids: &cluster.doc_ids,
1187 ordinals: &cluster.ordinals,
1188 codes: &cluster.codes,
1189 })
1190 .collect();
1191 write_built_runs(
1192 AnnDiskHeader {
1193 kind: AnnKind::BinaryIvf,
1194 routing,
1195 dim: index.dim_bits,
1196 code_size: index.dim_bits.div_ceil(8),
1197 num_clusters: index.num_clusters,
1198 quantizer_version: index.quantizer_version,
1199 codebook_version: 0,
1200 vector_count: index.len(),
1201 },
1202 &runs,
1203 writer,
1204 )
1205}
1206
1207#[cfg(feature = "native")]
1210pub(crate) fn write_built_ivf_tq(
1211 index: &crate::structures::IvfTqIndex,
1212 num_clusters: u32,
1213 writer: &mut (impl Write + ?Sized),
1214) -> io::Result<u64> {
1215 use crate::structures::vector::quantization::{TQ_BLOCK_LANES, tq_pack_ivf_block};
1216
1217 if !crate::structures::is_ivf_tq_cosine_generation(index.centroids_version) {
1218 return Err(invalid_data(
1219 "legacy raw IVF-TQ generations cannot be serialized; rebuild the index",
1220 ));
1221 }
1222 let codec = index.codec();
1223 let padded_dim = codec.padded_dim();
1224 let mut clusters: Vec<_> = index.clusters.iter().collect();
1225 clusters.sort_unstable_by_key(|(cluster_id, _)| **cluster_id);
1226 struct PackedCluster {
1230 cluster_id: u32,
1231 doc_ids: Vec<u32>,
1232 ordinals: Vec<u16>,
1233 codes: Vec<u8>,
1234 }
1235 let packed: Vec<PackedCluster> = clusters
1236 .iter()
1237 .map(|&(&cluster_id, cluster)| {
1238 let count = cluster.doc_ids.len();
1239 let mut order: Vec<usize> = (0..count).collect();
1240 order.sort_by(|&a, &b| {
1241 cluster.scales[b]
1242 .total_cmp(&cluster.scales[a])
1243 .then_with(|| a.cmp(&b))
1244 });
1245 let doc_ids: Vec<u32> = order.iter().map(|&i| cluster.doc_ids[i]).collect();
1246 let ordinals: Vec<u16> = order.iter().map(|&i| cluster.ordinals[i]).collect();
1247 let scales: Vec<f32> = order.iter().map(|&i| cluster.scales[i]).collect();
1248 let gammas: Vec<f32> = order.iter().map(|&i| cluster.gammas[i]).collect();
1249 let mut codes = Vec::with_capacity(
1250 crate::structures::vector::quantization::tq_ivf_codes_column_len_checked(
1251 count,
1252 codec.code_size(),
1253 )
1254 .unwrap_or_default(),
1255 );
1256 for block_start in (0..count).step_by(TQ_BLOCK_LANES) {
1257 let lanes = TQ_BLOCK_LANES.min(count - block_start);
1258 let rows: Vec<&[u8]> = order[block_start..block_start + lanes]
1259 .iter()
1260 .map(|&row| &cluster.rows[row * padded_dim..(row + 1) * padded_dim])
1261 .collect();
1262 tq_pack_ivf_block(
1263 &rows,
1264 &scales[block_start..block_start + lanes],
1265 &gammas[block_start..block_start + lanes],
1266 padded_dim,
1267 &mut codes,
1268 );
1269 }
1270 PackedCluster {
1271 cluster_id,
1272 doc_ids,
1273 ordinals,
1274 codes,
1275 }
1276 })
1277 .collect();
1278 let runs: Vec<_> = packed
1279 .iter()
1280 .map(|cluster| BuildRun {
1281 cluster_id: cluster.cluster_id,
1282 doc_ids: &cluster.doc_ids,
1283 ordinals: &cluster.ordinals,
1284 codes: &cluster.codes,
1285 })
1286 .collect();
1287 write_built_runs(
1288 AnnDiskHeader {
1289 kind: AnnKind::IvfTq,
1290 routing: index.routing,
1291 dim: index.dim,
1292 code_size: codec.code_size(),
1293 num_clusters,
1294 quantizer_version: index.centroids_version,
1295 codebook_version: codec.fingerprint(),
1296 vector_count: index.len(),
1297 },
1298 &runs,
1299 writer,
1300 )
1301}
1302
1303#[cfg(feature = "native")]
1305pub(crate) fn tq_builder_extra_run(builder: &crate::structures::TqFlatBuilder) -> BuildRun<'_> {
1306 BuildRun {
1307 cluster_id: 0,
1308 doc_ids: &builder.doc_ids,
1309 ordinals: &builder.ordinals,
1310 codes: &builder.codes,
1311 }
1312}
1313
1314#[cfg(feature = "native")]
1316pub(crate) fn write_built_tq_flat(
1317 builder: &crate::structures::TqFlatBuilder,
1318 writer: &mut (impl Write + ?Sized),
1319) -> io::Result<u64> {
1320 let codec = builder.codec();
1321 let runs = [BuildRun {
1322 cluster_id: 0,
1323 doc_ids: &builder.doc_ids,
1324 ordinals: &builder.ordinals,
1325 codes: &builder.codes,
1326 }];
1327 write_built_runs(
1328 AnnDiskHeader {
1329 kind: AnnKind::TqFlat,
1330 routing: IvfRoutingMode::Flat,
1331 dim: codec.dim(),
1332 code_size: codec.code_size(),
1333 num_clusters: 1,
1334 quantizer_version: codec.fingerprint(),
1335 codebook_version: 0,
1336 vector_count: builder.len(),
1337 },
1338 &runs,
1339 writer,
1340 )
1341}
1342
1343#[cfg(feature = "native")]
1344pub(crate) struct BuildRun<'a> {
1345 cluster_id: u32,
1346 doc_ids: &'a [u32],
1347 ordinals: &'a [u16],
1348 codes: &'a [u8],
1349}
1350
1351#[cfg(feature = "native")]
1352struct RunRecord {
1353 cluster_id: u32,
1354 doc_base: u32,
1355 count: u32,
1356 max_doc_id: u32,
1357 doc_ids_offset: u64,
1358 ordinals_offset: u64,
1359 codes_offset: u64,
1360 codes_len: u64,
1361}
1362
1363#[cfg(feature = "native")]
1364fn write_built_runs(
1365 header: AnnDiskHeader,
1366 runs: &[BuildRun<'_>],
1367 writer: &mut (impl Write + ?Sized),
1368) -> io::Result<u64> {
1369 if runs.is_empty() || header.vector_count == 0 {
1370 return Err(invalid_data("cannot write an empty ANN payload"));
1371 }
1372 validate_header(&header)?;
1373 write_header(writer, &header)?;
1374 let mut offset = ANN_HEADER_SIZE as u64;
1375 let mut records = Vec::with_capacity(runs.len());
1376 let mut counted = 0usize;
1377 let mut scratch = Vec::new();
1378 let mut previous_cluster = None;
1379 for run in runs {
1380 let count = run.doc_ids.len();
1381 if count == 0
1382 || run.cluster_id >= header.num_clusters
1383 || previous_cluster.is_some_and(|cluster| cluster >= run.cluster_id)
1384 || run.ordinals.len() != count
1385 || run.codes.len() != expected_codes_column_len(header.kind, count, header.code_size)?
1386 {
1387 return Err(invalid_data("ANN build run columns are inconsistent"));
1388 }
1389 previous_cluster = Some(run.cluster_id);
1390 let count_u32 = u32::try_from(count)
1391 .map_err(|_| invalid_data("ANN cluster run exceeds u32 vectors"))?;
1392 let max_doc_id = run.doc_ids.iter().copied().max().unwrap_or(0);
1393 let doc_ids_offset = offset;
1394 write_u32_column(writer, run.doc_ids, &mut scratch)?;
1395 offset = offset
1396 .checked_add(
1397 u64::try_from(count)
1398 .ok()
1399 .and_then(|count| count.checked_mul(4))
1400 .ok_or_else(|| invalid_data("ANN doc-ID output size overflows u64"))?,
1401 )
1402 .ok_or_else(|| invalid_data("ANN output offset overflow"))?;
1403 let ordinals_offset = offset;
1404 write_u16_column(writer, run.ordinals, &mut scratch)?;
1405 offset = offset
1406 .checked_add(
1407 u64::try_from(count)
1408 .ok()
1409 .and_then(|count| count.checked_mul(2))
1410 .ok_or_else(|| invalid_data("ANN ordinal output size overflows u64"))?,
1411 )
1412 .ok_or_else(|| invalid_data("ANN output offset overflow"))?;
1413 let codes_offset = offset;
1414 writer.write_all(run.codes)?;
1415 offset = offset
1416 .checked_add(
1417 u64::try_from(run.codes.len())
1418 .map_err(|_| invalid_data("ANN code output size exceeds u64"))?,
1419 )
1420 .ok_or_else(|| invalid_data("ANN output offset overflow"))?;
1421 records.push(RunRecord {
1422 cluster_id: run.cluster_id,
1423 doc_base: 0,
1424 count: count_u32,
1425 max_doc_id,
1426 doc_ids_offset,
1427 ordinals_offset,
1428 codes_offset,
1429 codes_len: u64::try_from(run.codes.len())
1430 .map_err(|_| invalid_data("ANN code output size exceeds u64"))?,
1431 });
1432 counted = counted
1433 .checked_add(count)
1434 .ok_or_else(|| invalid_data("ANN vector count overflow"))?;
1435 }
1436 if counted != header.vector_count {
1437 return Err(invalid_data("ANN header/build vector counts disagree"));
1438 }
1439 finish_layout(writer, offset, &records)
1440}
1441
1442#[cfg(all(feature = "native", test))]
1445pub(crate) fn write_merged_ann(
1446 sources: &[(&AnnDiskIndex, u32)],
1447 writer: &mut (impl Write + ?Sized),
1448) -> io::Result<u64> {
1449 write_merged_ann_impl(sources, &[], writer, None)
1450}
1451
1452#[cfg(feature = "native")]
1453pub(crate) fn write_merged_ann_cancellable(
1454 sources: &[(&AnnDiskIndex, u32)],
1455 writer: &mut (impl Write + ?Sized),
1456 cancellation: Option<&std::sync::atomic::AtomicBool>,
1457) -> io::Result<u64> {
1458 write_merged_ann_impl(sources, &[], writer, cancellation)
1459}
1460
1461#[cfg(feature = "native")]
1470pub(crate) fn predicted_merge_fragmentation(sources: &[(&AnnDiskIndex, u32)]) -> f64 {
1471 let mut total_runs = 0usize;
1472 let mut cursors: Vec<std::iter::Peekable<std::slice::Iter<'_, AnnRun>>> = sources
1475 .iter()
1476 .map(|(source, _)| {
1477 total_runs += source.runs.len();
1478 source.runs.iter().peekable()
1479 })
1480 .collect();
1481 let mut distinct = 0usize;
1482 while let Some(cluster) = cursors
1483 .iter_mut()
1484 .filter_map(|cursor| cursor.peek().map(|run| run.cluster_id))
1485 .min()
1486 {
1487 distinct += 1;
1488 for cursor in &mut cursors {
1489 while cursor.peek().is_some_and(|run| run.cluster_id == cluster) {
1490 cursor.next();
1491 }
1492 }
1493 }
1494 if distinct == 0 {
1495 0.0
1496 } else {
1497 total_runs as f64 / distinct as f64
1498 }
1499}
1500
1501#[cfg(feature = "native")]
1503const DOC_ID_REWRITE_CHUNK: usize = 64 * 1024;
1504
1505#[cfg(feature = "native")]
1532pub(crate) fn write_compacted_ann_cancellable(
1533 sources: &[(&AnnDiskIndex, u32)],
1534 writer: &mut (impl Write + ?Sized),
1535 cancellation: Option<&std::sync::atomic::AtomicBool>,
1536) -> io::Result<u64> {
1537 let Some((first, _)) = sources.first() else {
1538 return Err(invalid_data("cannot compact an empty ANN source list"));
1539 };
1540 if first.header.kind != AnnKind::BinaryIvf {
1541 return Err(invalid_data(
1542 "ANN run compaction is only defined for binary IVF payloads",
1543 ));
1544 }
1545 let mut header = first.header.clone();
1546 header.vector_count = 0;
1547 for &(source, _) in sources {
1548 if !headers_compatible(&first.header, &source.header) {
1549 return Err(invalid_data(
1550 "ANN compaction sources use incompatible generations",
1551 ));
1552 }
1553 header.vector_count = header
1554 .vector_count
1555 .checked_add(source.header.vector_count)
1556 .ok_or_else(|| invalid_data("compacted ANN vector count overflows usize"))?;
1557 }
1558 validate_header(&header)?;
1559 write_header(writer, &header)?;
1560
1561 let code_size = header.code_size;
1562 let mut offset = ANN_HEADER_SIZE as u64;
1563 let mut records: Vec<RunRecord> = Vec::new();
1564 let mut scratch = Vec::new();
1565 let mut cursors: Vec<usize> = vec![0; sources.len()];
1566
1567 loop {
1568 if cancellation.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed)) {
1569 return Err(io::Error::new(
1570 io::ErrorKind::Interrupted,
1571 "ANN compaction cancelled",
1572 ));
1573 }
1574 let Some(cluster_id) = sources
1576 .iter()
1577 .zip(&cursors)
1578 .filter_map(|((source, _), &cursor)| source.runs.get(cursor).map(|run| run.cluster_id))
1579 .min()
1580 else {
1581 break;
1582 };
1583
1584 let mut count = 0usize;
1589 let mut max_doc_id = 0u32;
1590 let doc_ids_offset = offset;
1591 for (source_index, &(source, segment_base)) in sources.iter().enumerate() {
1592 let mut cursor = cursors[source_index];
1593 while let Some(run) = source
1594 .runs
1595 .get(cursor)
1596 .filter(|run| run.cluster_id == cluster_id)
1597 {
1598 let base = run
1599 .doc_base
1600 .checked_add(segment_base)
1601 .ok_or_else(|| invalid_data("compacted ANN document base overflows u32"))?;
1602 let bytes = source.raw.as_slice();
1603 for chunk_start in (0..run.count).step_by(DOC_ID_REWRITE_CHUNK) {
1608 let chunk_end = (chunk_start + DOC_ID_REWRITE_CHUNK).min(run.count);
1609 scratch.clear();
1610 scratch.reserve((chunk_end - chunk_start) * 4);
1611 for index in chunk_start..chunk_end {
1612 let doc_id = run_doc_id_with_base(bytes, run, index, base)?;
1613 max_doc_id = max_doc_id.max(doc_id);
1614 scratch.extend_from_slice(&doc_id.to_le_bytes());
1615 }
1616 writer.write_all(&scratch)?;
1617 if cancellation
1618 .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Relaxed))
1619 {
1620 return Err(io::Error::new(
1621 io::ErrorKind::Interrupted,
1622 "ANN compaction cancelled",
1623 ));
1624 }
1625 }
1626 offset = checked_advance(offset, run.count * 4)?;
1627 count = count
1628 .checked_add(run.count)
1629 .ok_or_else(|| invalid_data("compacted ANN run count overflows usize"))?;
1630 cursor += 1;
1631 }
1632 }
1633
1634 let ordinals_offset = offset;
1636 for (source_index, &(source, _)) in sources.iter().enumerate() {
1637 let mut cursor = cursors[source_index];
1638 while let Some(run) = source
1639 .runs
1640 .get(cursor)
1641 .filter(|run| run.cluster_id == cluster_id)
1642 {
1643 copy_range(writer, &source.raw, run.ordinals.clone(), cancellation)?;
1644 offset = checked_advance(offset, run.ordinals.len())?;
1645 cursor += 1;
1646 }
1647 }
1648
1649 let codes_offset = offset;
1651 for (source_index, &(source, _)) in sources.iter().enumerate() {
1652 let mut cursor = cursors[source_index];
1653 while let Some(run) = source
1654 .runs
1655 .get(cursor)
1656 .filter(|run| run.cluster_id == cluster_id)
1657 {
1658 copy_range(writer, &source.raw, run.codes.clone(), cancellation)?;
1659 offset = checked_advance(offset, run.codes.len())?;
1660 cursor += 1;
1661 }
1662 }
1663
1664 for (source_index, &(source, _)) in sources.iter().enumerate() {
1666 while source
1667 .runs
1668 .get(cursors[source_index])
1669 .is_some_and(|run| run.cluster_id == cluster_id)
1670 {
1671 cursors[source_index] += 1;
1672 }
1673 }
1674
1675 records.push(RunRecord {
1676 cluster_id,
1677 doc_base: 0,
1678 count: u32::try_from(count)
1679 .map_err(|_| invalid_data("compacted ANN run exceeds u32 vectors"))?,
1680 max_doc_id,
1681 doc_ids_offset,
1682 ordinals_offset,
1683 codes_offset,
1684 codes_len: u64::try_from(expected_codes_column_len(
1685 AnnKind::BinaryIvf,
1686 count,
1687 code_size,
1688 )?)
1689 .map_err(|_| invalid_data("compacted ANN code length exceeds u64"))?,
1690 });
1691 }
1692
1693 if records.is_empty() {
1694 return Err(invalid_data("cannot compact an ANN payload with no runs"));
1695 }
1696 finish_layout(writer, offset, &records)
1697}
1698
1699#[cfg(feature = "native")]
1701fn run_doc_id_with_base(bytes: &[u8], run: &AnnRun, index: usize, base: u32) -> io::Result<u32> {
1702 let local_doc_id = read_u32(bytes, run.doc_ids.start + index * 4);
1703 if local_doc_id > run.max_doc_id {
1704 return Err(invalid_data(
1705 "ANN run contains a document above its declared maximum",
1706 ));
1707 }
1708 base.checked_add(local_doc_id)
1709 .ok_or_else(|| invalid_data("compacted ANN document ID overflows u32"))
1710}
1711
1712#[cfg(feature = "native")]
1716pub(crate) fn write_merged_ann_with_extra(
1717 sources: &[(&AnnDiskIndex, u32)],
1718 extra: &[BuildRun<'_>],
1719 writer: &mut (impl Write + ?Sized),
1720 cancellation: Option<&std::sync::atomic::AtomicBool>,
1721) -> io::Result<u64> {
1722 write_merged_ann_impl(sources, extra, writer, cancellation)
1723}
1724
1725#[cfg(feature = "native")]
1726fn write_merged_ann_impl(
1727 sources: &[(&AnnDiskIndex, u32)],
1728 extra: &[BuildRun<'_>],
1729 writer: &mut (impl Write + ?Sized),
1730 cancellation: Option<&std::sync::atomic::AtomicBool>,
1731) -> io::Result<u64> {
1732 let Some((first, _)) = sources.first() else {
1733 return Err(invalid_data("cannot merge an empty ANN source list"));
1734 };
1735 if first.header.kind == AnnKind::IvfTq
1736 && !crate::structures::is_ivf_tq_cosine_generation(first.header.quantizer_version)
1737 {
1738 return Err(invalid_data(
1739 "legacy raw IVF-TQ generations cannot be merged; rebuild the index",
1740 ));
1741 }
1742 let mut header = first.header.clone();
1743 header.vector_count = 0;
1744 for &(source, _) in sources {
1745 if !headers_compatible(&first.header, &source.header) {
1746 return Err(invalid_data(
1747 "ANN merge sources use incompatible generations",
1748 ));
1749 }
1750 header.vector_count = header
1751 .vector_count
1752 .checked_add(source.header.vector_count)
1753 .ok_or_else(|| invalid_data("merged ANN vector count overflows usize"))?;
1754 }
1755 for run in extra {
1756 if run.doc_ids.is_empty()
1757 || run.cluster_id >= header.num_clusters
1758 || run.ordinals.len() != run.doc_ids.len()
1759 || run.codes.len()
1760 != expected_codes_column_len(header.kind, run.doc_ids.len(), header.code_size)?
1761 {
1762 return Err(invalid_data("extra ANN merge run columns are inconsistent"));
1763 }
1764 header.vector_count = header
1765 .vector_count
1766 .checked_add(run.doc_ids.len())
1767 .ok_or_else(|| invalid_data("merged ANN vector count overflows usize"))?;
1768 }
1769 validate_header(&header)?;
1770 write_header(writer, &header)?;
1771 let mut offset = ANN_HEADER_SIZE as u64;
1772 let run_capacity = sources.iter().try_fold(extra.len(), |count, (source, _)| {
1773 count
1774 .checked_add(source.runs.len())
1775 .ok_or_else(|| invalid_data("merged ANN run count overflows usize"))
1776 })?;
1777 let mut output_payload_starts = Vec::with_capacity(sources.len());
1778 for &(source, _) in sources {
1779 let payload_end = source
1780 .runs
1781 .iter()
1782 .map(|run| run.codes.end)
1783 .max()
1784 .ok_or_else(|| invalid_data("ANN source has no payload runs"))?;
1785 let output_payload_start = offset;
1786 output_payload_starts.push(output_payload_start);
1787 copy_range(
1788 writer,
1789 &source.raw,
1790 ANN_HEADER_SIZE..payload_end,
1791 cancellation,
1792 )?;
1793 offset = checked_advance(offset, payload_end - ANN_HEADER_SIZE)?;
1794 }
1795
1796 let mut extra_records = Vec::with_capacity(extra.len());
1799 let mut scratch = Vec::new();
1800 for run in extra {
1801 let count = run.doc_ids.len();
1802 let doc_ids_offset = offset;
1803 write_u32_column(writer, run.doc_ids, &mut scratch)?;
1804 offset = checked_advance(offset, count * 4)?;
1805 let ordinals_offset = offset;
1806 write_u16_column(writer, run.ordinals, &mut scratch)?;
1807 offset = checked_advance(offset, count * 2)?;
1808 let codes_offset = offset;
1809 writer.write_all(run.codes)?;
1810 offset = checked_advance(offset, run.codes.len())?;
1811 extra_records.push(RunRecord {
1812 cluster_id: run.cluster_id,
1813 doc_base: 0,
1814 count: u32::try_from(count)
1815 .map_err(|_| invalid_data("extra ANN run exceeds u32 vectors"))?,
1816 max_doc_id: run.doc_ids.iter().copied().max().unwrap_or(0),
1817 doc_ids_offset,
1818 ordinals_offset,
1819 codes_offset,
1820 codes_len: u64::try_from(run.codes.len())
1821 .map_err(|_| invalid_data("extra ANN code length exceeds u64"))?,
1822 });
1823 }
1824
1825 let directory_offset = offset;
1831 let mut pending = BinaryHeap::with_capacity(sources.len() + 1);
1832 for (source_index, (source, _)) in sources.iter().enumerate() {
1833 pending.push(Reverse((source.runs[0].cluster_id, source_index, 0usize)));
1834 }
1835 if let Some(first_extra) = extra_records.first() {
1836 pending.push(Reverse((first_extra.cluster_id, sources.len(), 0usize)));
1837 }
1838 let mut written_runs = 0usize;
1839 while let Some(Reverse((_, source_index, run_index))) = pending.pop() {
1840 if source_index == sources.len() {
1841 write_run_record(writer, &extra_records[run_index])?;
1842 written_runs += 1;
1843 if let Some(next) = extra_records.get(run_index + 1) {
1844 pending.push(Reverse((next.cluster_id, source_index, run_index + 1)));
1845 }
1846 continue;
1847 }
1848 let (source, segment_base) = sources[source_index];
1849 let run = &source.runs[run_index];
1850 write_run_record(
1851 writer,
1852 &RunRecord {
1853 cluster_id: run.cluster_id,
1854 doc_base: run
1855 .doc_base
1856 .checked_add(segment_base)
1857 .ok_or_else(|| invalid_data("merged ANN document base overflows u32"))?,
1858 count: u32::try_from(run.count)
1859 .map_err(|_| invalid_data("ANN source run exceeds u32 vectors"))?,
1860 max_doc_id: run.max_doc_id,
1861 doc_ids_offset: relocate_payload_offset(
1862 output_payload_starts[source_index],
1863 run.doc_ids.start,
1864 )?,
1865 ordinals_offset: relocate_payload_offset(
1866 output_payload_starts[source_index],
1867 run.ordinals.start,
1868 )?,
1869 codes_offset: relocate_payload_offset(
1870 output_payload_starts[source_index],
1871 run.codes.start,
1872 )?,
1873 codes_len: u64::try_from(run.codes.len())
1874 .map_err(|_| invalid_data("ANN source code length exceeds u64"))?,
1875 },
1876 )?;
1877 written_runs = written_runs
1878 .checked_add(1)
1879 .ok_or_else(|| invalid_data("merged ANN run count overflows usize"))?;
1880 let next_run_index = run_index + 1;
1881 if let Some(next_run) = source.runs.get(next_run_index) {
1882 pending.push(Reverse((next_run.cluster_id, source_index, next_run_index)));
1883 }
1884 }
1885 if written_runs != run_capacity {
1886 return Err(invalid_data("merged ANN directory lost source runs"));
1887 }
1888 finish_footer(writer, directory_offset, written_runs)
1889}
1890
1891#[cfg(feature = "native")]
1892fn relocate_payload_offset(output_payload_start: u64, source_offset: usize) -> io::Result<u64> {
1893 let relative = source_offset
1894 .checked_sub(ANN_HEADER_SIZE)
1895 .ok_or_else(|| invalid_data("ANN source offset precedes its payload"))?;
1896 output_payload_start
1897 .checked_add(
1898 u64::try_from(relative)
1899 .map_err(|_| invalid_data("ANN source relative offset exceeds u64"))?,
1900 )
1901 .ok_or_else(|| invalid_data("merged ANN payload offset overflows u64"))
1902}
1903
1904#[cfg(feature = "native")]
1905fn headers_compatible(left: &AnnDiskHeader, right: &AnnDiskHeader) -> bool {
1906 left.kind == right.kind
1907 && left.routing == right.routing
1908 && left.dim == right.dim
1909 && left.code_size == right.code_size
1910 && left.num_clusters == right.num_clusters
1911 && left.quantizer_version == right.quantizer_version
1912 && left.codebook_version == right.codebook_version
1913}
1914
1915#[cfg(feature = "native")]
1916fn finish_layout(
1917 writer: &mut (impl Write + ?Sized),
1918 directory_offset: u64,
1919 records: &[RunRecord],
1920) -> io::Result<u64> {
1921 for record in records {
1922 write_run_record(writer, record)?;
1923 }
1924 finish_footer(writer, directory_offset, records.len())
1925}
1926
1927#[cfg(feature = "native")]
1928fn write_run_record(writer: &mut (impl Write + ?Sized), record: &RunRecord) -> io::Result<()> {
1929 writer.write_u32::<LittleEndian>(record.cluster_id)?;
1930 writer.write_u32::<LittleEndian>(record.doc_base)?;
1931 writer.write_u32::<LittleEndian>(record.count)?;
1932 writer.write_u32::<LittleEndian>(record.max_doc_id)?;
1933 writer.write_u64::<LittleEndian>(record.doc_ids_offset)?;
1934 writer.write_u64::<LittleEndian>(record.ordinals_offset)?;
1935 writer.write_u64::<LittleEndian>(record.codes_offset)?;
1936 writer.write_u64::<LittleEndian>(record.codes_len)?;
1937 Ok(())
1938}
1939
1940#[cfg(feature = "native")]
1941fn finish_footer(
1942 writer: &mut (impl Write + ?Sized),
1943 directory_offset: u64,
1944 num_records: usize,
1945) -> io::Result<u64> {
1946 writer.write_u64::<LittleEndian>(directory_offset)?;
1947 writer.write_u64::<LittleEndian>(
1948 u64::try_from(num_records).map_err(|_| invalid_data("ANN run count exceeds u64"))?,
1949 )?;
1950 writer.write_u32::<LittleEndian>(ANN_FOOTER_MAGIC)?;
1951 writer.write_u32::<LittleEndian>(u32::from(ANN_DISK_VERSION))?;
1952 let tail_size = num_records
1953 .checked_mul(ANN_RUN_SIZE)
1954 .and_then(|size| size.checked_add(ANN_FOOTER_SIZE))
1955 .and_then(|size| u64::try_from(size).ok())
1956 .ok_or_else(|| invalid_data("ANN final tail size overflows u64"))?;
1957 directory_offset
1958 .checked_add(tail_size)
1959 .ok_or_else(|| invalid_data("ANN final size overflows u64"))
1960}
1961
1962#[cfg(feature = "native")]
1963fn write_header(writer: &mut (impl Write + ?Sized), header: &AnnDiskHeader) -> io::Result<()> {
1964 writer.write_u32::<LittleEndian>(ANN_HEADER_MAGIC)?;
1965 writer.write_u8(header.kind as u8)?;
1966 writer.write_u8(routing_to_u8(header.routing))?;
1967 writer.write_u16::<LittleEndian>(ANN_DISK_VERSION)?;
1968 writer.write_u32::<LittleEndian>(
1969 u32::try_from(header.dim).map_err(|_| invalid_data("ANN dimension exceeds u32"))?,
1970 )?;
1971 writer.write_u32::<LittleEndian>(
1972 u32::try_from(header.code_size).map_err(|_| invalid_data("ANN code size exceeds u32"))?,
1973 )?;
1974 writer.write_u32::<LittleEndian>(header.num_clusters)?;
1975 writer.write_u32::<LittleEndian>(0)?;
1976 writer.write_u64::<LittleEndian>(header.quantizer_version)?;
1977 writer.write_u64::<LittleEndian>(header.codebook_version)?;
1978 writer.write_u64::<LittleEndian>(
1979 u64::try_from(header.vector_count)
1980 .map_err(|_| invalid_data("ANN vector count exceeds u64"))?,
1981 )?;
1982 writer.write_u64::<LittleEndian>(0)?;
1983 Ok(())
1984}
1985
1986#[cfg(feature = "native")]
1987fn write_u32_column(
1988 writer: &mut (impl Write + ?Sized),
1989 values: &[u32],
1990 scratch: &mut Vec<u8>,
1991) -> io::Result<()> {
1992 for chunk in values.chunks(64 * 1024) {
1993 scratch.clear();
1994 scratch.reserve(chunk.len() * 4);
1995 for value in chunk {
1996 scratch.extend_from_slice(&value.to_le_bytes());
1997 }
1998 writer.write_all(scratch)?;
1999 }
2000 Ok(())
2001}
2002
2003#[cfg(feature = "native")]
2004fn write_u16_column(
2005 writer: &mut (impl Write + ?Sized),
2006 values: &[u16],
2007 scratch: &mut Vec<u8>,
2008) -> io::Result<()> {
2009 for chunk in values.chunks(64 * 1024) {
2010 scratch.clear();
2011 scratch.reserve(chunk.len() * 2);
2012 for value in chunk {
2013 scratch.extend_from_slice(&value.to_le_bytes());
2014 }
2015 writer.write_all(scratch)?;
2016 }
2017 Ok(())
2018}
2019
2020#[cfg(feature = "native")]
2021fn copy_range(
2022 writer: &mut (impl Write + ?Sized),
2023 bytes: &OwnedBytes,
2024 range: Range<usize>,
2025 cancellation: Option<&std::sync::atomic::AtomicBool>,
2026) -> io::Result<()> {
2027 if range.is_empty() {
2028 return Ok(());
2029 }
2030 let range_end = range.end;
2031 let mut chunk_start = range.start;
2032 let first_end = chunk_start.saturating_add(COPY_CHUNK).min(range_end);
2033 bytes.madvise_range(chunk_start..first_end, libc::MADV_WILLNEED);
2034 while chunk_start < range_end {
2035 if cancellation
2036 .is_some_and(|cancelled| cancelled.load(std::sync::atomic::Ordering::Relaxed))
2037 {
2038 return Err(io::Error::new(
2039 io::ErrorKind::Interrupted,
2040 "ANN merge copy cancelled",
2041 ));
2042 }
2043 let chunk_end = chunk_start.saturating_add(COPY_CHUNK).min(range_end);
2044 let next_end = chunk_end.saturating_add(COPY_CHUNK).min(range_end);
2045 if chunk_end < next_end {
2046 bytes.madvise_range(chunk_end..next_end, libc::MADV_WILLNEED);
2049 }
2050 writer.write_all(&bytes.as_slice()[chunk_start..chunk_end])?;
2051 chunk_start = chunk_end;
2052 }
2053 Ok(())
2054}
2055
2056#[cfg(feature = "native")]
2057fn checked_advance(offset: u64, length: usize) -> io::Result<u64> {
2058 offset
2059 .checked_add(
2060 u64::try_from(length).map_err(|_| invalid_data("ANN copy length exceeds u64"))?,
2061 )
2062 .ok_or_else(|| invalid_data("ANN output offset overflows u64"))
2063}
2064
2065fn validate_header(header: &AnnDiskHeader) -> io::Result<()> {
2066 if header.kind == AnnKind::IvfTq
2067 && !crate::structures::is_ivf_tq_cosine_generation(header.quantizer_version)
2068 {
2069 return Err(invalid_data(
2070 "IVF-TQ payload uses an unsupported legacy generation; rebuild the index",
2071 ));
2072 }
2073 if header.dim == 0
2074 || header.code_size == 0
2075 || header.num_clusters == 0
2076 || header.quantizer_version == 0
2077 || header.vector_count == 0
2078 || (header.kind == AnnKind::BinaryIvf
2079 && (header.codebook_version != 0
2080 || !header.dim.is_multiple_of(8)
2081 || header.code_size != header.dim.div_ceil(8)))
2082 || (header.kind == AnnKind::TqFlat
2083 && (header.codebook_version != 0
2084 || header.num_clusters != 1
2085 || header.routing != IvfRoutingMode::Flat
2086 || header.code_size * 2
2087 != crate::structures::vector::quantization::tq_padded_dim(header.dim)))
2088 || (header.kind == AnnKind::IvfTq
2091 && (header.codebook_version == 0
2092 || header.code_size * 2
2093 != crate::structures::vector::quantization::tq_padded_dim(header.dim)))
2094 {
2095 return Err(invalid_data("ANN header contains invalid metadata"));
2096 }
2097 Ok(())
2098}
2099
2100fn read_u32(bytes: &[u8], offset: usize) -> u32 {
2101 u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
2102}
2103
2104fn read_u16(bytes: &[u8], offset: usize) -> u16 {
2105 u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap())
2106}
2107
2108#[inline]
2109fn tq_ivf_block_max_scale(block: &[u8]) -> f32 {
2110 f32::from_le_bytes(
2114 block[..size_of::<f32>()]
2115 .try_into()
2116 .expect("scale is one f32"),
2117 )
2118}
2119
2120fn run_doc_id(bytes: &[u8], run: &AnnRun, index: usize) -> io::Result<u32> {
2121 let local_doc_id = read_u32(bytes, run.doc_ids.start + index * 4);
2122 if local_doc_id > run.max_doc_id {
2123 return Err(invalid_data(
2124 "ANN run contains a document above its declared maximum",
2125 ));
2126 }
2127 run.doc_base
2128 .checked_add(local_doc_id)
2129 .ok_or_else(|| invalid_data("ANN run document ID overflows u32"))
2130}
2131
2132#[cfg(feature = "native")]
2133fn routing_to_u8(routing: IvfRoutingMode) -> u8 {
2134 match routing {
2135 IvfRoutingMode::Auto => 0,
2136 IvfRoutingMode::Flat => 1,
2137 IvfRoutingMode::TwoLevel => 2,
2138 IvfRoutingMode::Hnsw => 3,
2139 }
2140}
2141
2142fn routing_from_u8(value: u8) -> io::Result<IvfRoutingMode> {
2143 match value {
2144 0 => Ok(IvfRoutingMode::Auto),
2145 1 => Ok(IvfRoutingMode::Flat),
2146 2 => Ok(IvfRoutingMode::TwoLevel),
2147 3 => Ok(IvfRoutingMode::Hnsw),
2148 _ => Err(invalid_data(format!("unknown ANN routing mode {value}"))),
2149 }
2150}
2151
2152fn invalid_data(message: impl Into<String>) -> io::Error {
2153 io::Error::new(io::ErrorKind::InvalidData, message.into())
2154}
2155
2156#[cfg(all(test, feature = "native"))]
2157mod tests {
2158 use super::*;
2159
2160 #[test]
2165 fn compacted_merge_matches_byte_copy_and_resets_fragmentation() {
2166 let a0_docs = [0u32, 1, 2];
2170 let a0_ords = [0u16, 0, 1];
2171 let a0_codes = [0x11u8, 0x22, 0x33];
2172 let a5_docs = [3u32, 4];
2173 let a5_ords = [0u16; 2];
2174 let a5_codes = [0x44u8, 0x55];
2175 let a_runs = [
2176 BuildRun {
2177 cluster_id: 0,
2178 doc_ids: &a0_docs,
2179 ordinals: &a0_ords,
2180 codes: &a0_codes,
2181 },
2182 BuildRun {
2183 cluster_id: 5,
2184 doc_ids: &a5_docs,
2185 ordinals: &a5_ords,
2186 codes: &a5_codes,
2187 },
2188 ];
2189 let mut header = binary_header(5);
2190 header.num_clusters = 8;
2191 let mut a_bytes = Vec::new();
2192 write_built_runs(header.clone(), &a_runs, &mut a_bytes).unwrap();
2193 let a = AnnDiskIndex::open(OwnedBytes::new(a_bytes), AnnKind::BinaryIvf, 5).unwrap();
2194
2195 let b0_docs = [0u32, 2];
2196 let b0_ords = [1u16, 0];
2197 let b0_codes = [0x66u8, 0x77];
2198 let b2_docs = [1u32];
2199 let b2_ords = [0u16];
2200 let b2_codes = [0x88u8];
2201 let b_runs = [
2202 BuildRun {
2203 cluster_id: 0,
2204 doc_ids: &b0_docs,
2205 ordinals: &b0_ords,
2206 codes: &b0_codes,
2207 },
2208 BuildRun {
2209 cluster_id: 2,
2210 doc_ids: &b2_docs,
2211 ordinals: &b2_ords,
2212 codes: &b2_codes,
2213 },
2214 ];
2215 let mut header_b = binary_header(3);
2216 header_b.num_clusters = 8;
2217 let mut b_bytes = Vec::new();
2218 write_built_runs(header_b, &b_runs, &mut b_bytes).unwrap();
2219 let b = AnnDiskIndex::open(OwnedBytes::new(b_bytes), AnnKind::BinaryIvf, 3).unwrap();
2220
2221 let mut gen1_bytes = Vec::new();
2223 write_merged_ann(&[(&a, 0), (&b, 5)], &mut gen1_bytes).unwrap();
2224 let gen1 = AnnDiskIndex::open(OwnedBytes::new(gen1_bytes), AnnKind::BinaryIvf, 8).unwrap();
2225
2226 let sources: [(&AnnDiskIndex, u32); 2] = [(&gen1, 0), (&a, 8)];
2228 let predicted = predicted_merge_fragmentation(&sources);
2229 assert!((predicted - 2.0).abs() < 1e-9, "{predicted}");
2231
2232 let mut copied_bytes = Vec::new();
2233 write_merged_ann(&sources, &mut copied_bytes).unwrap();
2234 let copied =
2235 AnnDiskIndex::open(OwnedBytes::new(copied_bytes), AnnKind::BinaryIvf, 13).unwrap();
2236 let mut compacted_bytes = Vec::new();
2237 write_compacted_ann_cancellable(&sources, &mut compacted_bytes, None).unwrap();
2238 let compacted =
2239 AnnDiskIndex::open(OwnedBytes::new(compacted_bytes), AnnKind::BinaryIvf, 13).unwrap();
2240
2241 let copied_health = copied.health();
2243 let compacted_health = compacted.health();
2244 assert!((copied_health.fragmentation() - 2.0).abs() < 1e-9);
2245 assert!((compacted_health.fragmentation() - 1.0).abs() < 1e-9);
2246 assert_eq!(compacted_health.runs, 3, "one run per non-empty cluster");
2247 assert_eq!(copied_health.vectors, compacted_health.vectors);
2248 assert_eq!(copied_health.payload_bytes, compacted_health.payload_bytes);
2249 assert_eq!(
2250 copied_health.largest_cluster_vectors,
2251 compacted_health.largest_cluster_vectors
2252 );
2253
2254 for cluster in 0..8u32 {
2256 let query = [0x5Au8];
2257 let from_copy = copied
2258 .search_binary_clusters::<false>(&query, 16, &[cluster])
2259 .unwrap();
2260 let from_compact = compacted
2261 .search_binary_clusters::<false>(&query, 16, &[cluster])
2262 .unwrap();
2263 assert_eq!(from_copy, from_compact, "cluster {cluster} diverged");
2264 }
2265
2266 assert!(compacted.runs.iter().all(|run| run.doc_base == 0));
2268
2269 let mut generation3 = Vec::new();
2272 write_merged_ann(&[(&compacted, 0), (&b, 13)], &mut generation3).unwrap();
2273 let generation3 =
2274 AnnDiskIndex::open(OwnedBytes::new(generation3), AnnKind::BinaryIvf, 16).unwrap();
2275 assert_eq!(generation3.health().vectors, 16);
2276 let all: Vec<(u32, u16, f32)> = compacted
2278 .search_binary_clusters::<false>(&[0x5A], 32, &[0, 2, 5])
2279 .unwrap();
2280 let mut docs: Vec<u32> = all.iter().map(|&(doc, _, _)| doc).collect();
2281 docs.sort_unstable();
2282 assert_eq!(docs, (0..=12).collect::<Vec<u32>>());
2283 }
2284
2285 #[test]
2288 #[ignore]
2289 fn ann_merge_throughput_byte_copy_vs_compaction() {
2290 let code_size = 320usize;
2291 let clusters = 4_096u32;
2292 let vectors_per_source = 262_144usize;
2293 let sources_count = 4usize;
2294
2295 let mut sources_bytes = Vec::new();
2296 for source_index in 0..sources_count {
2297 let mut per_cluster: Vec<(Vec<u32>, Vec<u16>, Vec<u8>)> = Vec::new();
2298 let vectors_per_cluster = vectors_per_source / clusters as usize;
2299 let mut doc = 0u32;
2300 for cluster in 0..clusters {
2301 let mut docs = Vec::with_capacity(vectors_per_cluster);
2302 let mut ords = Vec::with_capacity(vectors_per_cluster);
2303 let mut codes = Vec::with_capacity(vectors_per_cluster * code_size);
2304 for _ in 0..vectors_per_cluster {
2305 docs.push(doc);
2306 ords.push(0u16);
2307 codes.extend(std::iter::repeat_n(
2308 (doc ^ cluster ^ source_index as u32) as u8,
2309 code_size,
2310 ));
2311 doc += 1;
2312 }
2313 per_cluster.push((docs, ords, codes));
2314 }
2315 let runs: Vec<BuildRun<'_>> = per_cluster
2316 .iter()
2317 .enumerate()
2318 .map(|(cluster, (docs, ords, codes))| BuildRun {
2319 cluster_id: cluster as u32,
2320 doc_ids: docs,
2321 ordinals: ords,
2322 codes,
2323 })
2324 .collect();
2325 let header = AnnDiskHeader {
2326 kind: AnnKind::BinaryIvf,
2327 routing: IvfRoutingMode::Hnsw,
2328 dim: code_size * 8,
2329 code_size,
2330 num_clusters: clusters,
2331 quantizer_version: 42,
2332 codebook_version: 0,
2333 vector_count: vectors_per_source,
2334 };
2335 let mut bytes = Vec::new();
2336 write_built_runs(header, &runs, &mut bytes).unwrap();
2337 sources_bytes.push(bytes);
2338 }
2339 let sources_open: Vec<AnnDiskIndex> = sources_bytes
2340 .iter()
2341 .map(|bytes| {
2342 AnnDiskIndex::open(
2343 OwnedBytes::new(bytes.clone()),
2344 AnnKind::BinaryIvf,
2345 (vectors_per_source * sources_count) as u32,
2346 )
2347 .unwrap()
2348 })
2349 .collect();
2350 let sources: Vec<(&AnnDiskIndex, u32)> = sources_open
2351 .iter()
2352 .enumerate()
2353 .map(|(index, source)| (source, (index * vectors_per_source) as u32))
2354 .collect();
2355 let payload_bytes = sources_bytes.iter().map(Vec::len).sum::<usize>();
2356
2357 let mut out = vec![0u8; payload_bytes + (1 << 20)];
2363 out.clear();
2364 let mut copy_secs = f64::INFINITY;
2367 let mut compact_secs = f64::INFINITY;
2368 let mut compacted_bytes = Vec::new();
2369 for _ in 0..3 {
2370 out.clear();
2371 let start = std::time::Instant::now();
2372 write_merged_ann(&sources, &mut out).unwrap();
2373 copy_secs = copy_secs.min(start.elapsed().as_secs_f64());
2374
2375 out.clear();
2376 let start = std::time::Instant::now();
2377 write_compacted_ann_cancellable(&sources, &mut out, None).unwrap();
2378 compact_secs = compact_secs.min(start.elapsed().as_secs_f64());
2379 compacted_bytes = out.clone();
2380 }
2381 let compacted = AnnDiskIndex::open(
2382 OwnedBytes::new(compacted_bytes),
2383 AnnKind::BinaryIvf,
2384 (vectors_per_source * sources_count) as u32,
2385 )
2386 .unwrap();
2387 assert!((compacted.health().fragmentation() - 1.0).abs() < 1e-9);
2388
2389 let gib = payload_bytes as f64 / (1u64 << 30) as f64;
2390 println!(
2391 "ann merge {:.2} GiB: byte-copy {:.3}s ({:.2} GiB/s), compaction {:.3}s \
2392 ({:.2} GiB/s), overhead {:.1}%",
2393 gib,
2394 copy_secs,
2395 gib / copy_secs,
2396 compact_secs,
2397 gib / compact_secs,
2398 100.0 * (compact_secs - copy_secs) / copy_secs,
2399 );
2400 }
2401
2402 #[test]
2404 fn compaction_refuses_non_binary_payloads() {
2405 let codec = std::sync::Arc::new(crate::structures::TqCodec::new(8));
2411 let mut builder = crate::structures::TqFlatBuilder::new(codec);
2412 builder
2413 .add_batch(
2414 &[(0, 0), (1, 0)],
2415 &[
2416 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
2418 ],
2419 )
2420 .unwrap();
2421 builder.finish();
2422 let mut bytes = Vec::new();
2423 write_built_tq_flat(&builder, &mut bytes).unwrap();
2424 let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, 2).unwrap();
2425 let error = write_compacted_ann_cancellable(&[(&disk, 0)], &mut Vec::new(), None)
2426 .expect_err("TQ payloads must not compact");
2427 assert!(error.to_string().contains("binary"), "{error}");
2428 }
2429
2430 #[test]
2434 fn ann_health_measures_skew_and_fragmentation() {
2435 let a0_docs = [0u32, 1, 2, 3, 4, 5];
2440 let a0_ords = [0u16; 6];
2441 let a0_codes = [0xAAu8; 6];
2442 let a3_docs = [6u32, 7];
2443 let a3_ords = [0u16; 2];
2444 let a3_codes = [0x0Fu8; 2];
2445 let a_runs = [
2446 BuildRun {
2447 cluster_id: 0,
2448 doc_ids: &a0_docs,
2449 ordinals: &a0_ords,
2450 codes: &a0_codes,
2451 },
2452 BuildRun {
2453 cluster_id: 3,
2454 doc_ids: &a3_docs,
2455 ordinals: &a3_ords,
2456 codes: &a3_codes,
2457 },
2458 ];
2459 let mut header = binary_header(8);
2460 header.num_clusters = 8;
2461 let mut a_bytes = Vec::new();
2462 write_built_runs(header, &a_runs, &mut a_bytes).unwrap();
2463 let a = AnnDiskIndex::open(OwnedBytes::new(a_bytes), AnnKind::BinaryIvf, 8).unwrap();
2464
2465 let b0_docs = [0u32, 1];
2466 let b0_ords = [0u16; 2];
2467 let b0_codes = [0xBBu8; 2];
2468 let b_runs = [BuildRun {
2469 cluster_id: 0,
2470 doc_ids: &b0_docs,
2471 ordinals: &b0_ords,
2472 codes: &b0_codes,
2473 }];
2474 let mut header_b = binary_header(2);
2475 header_b.num_clusters = 8;
2476 let mut b_bytes = Vec::new();
2477 write_built_runs(header_b, &b_runs, &mut b_bytes).unwrap();
2478 let b = AnnDiskIndex::open(OwnedBytes::new(b_bytes), AnnKind::BinaryIvf, 2).unwrap();
2479
2480 let mut merged_bytes = Vec::new();
2481 write_merged_ann(&[(&a, 0), (&b, 8)], &mut merged_bytes).unwrap();
2482 let disk =
2483 AnnDiskIndex::open(OwnedBytes::new(merged_bytes), AnnKind::BinaryIvf, 10).unwrap();
2484
2485 let health = disk.health();
2486 assert_eq!(health.vectors, 10);
2487 assert_eq!(health.clusters_nonempty, 2);
2488 assert_eq!(health.clusters_total, 8);
2489 assert_eq!(health.runs, 3);
2490 assert_eq!(health.largest_cluster, 0);
2491 assert_eq!(health.largest_cluster_vectors, 8);
2492 assert!((health.largest_cluster_share() - 0.8).abs() < 1e-9);
2493 assert!((health.fragmentation() - 1.5).abs() < 1e-9);
2495 assert!(
2497 (health.imbalance - 1.36).abs() < 1e-9,
2498 "{}",
2499 health.imbalance
2500 );
2501 assert_eq!(health.payload_bytes, 10);
2503 }
2504
2505 #[test]
2506 fn ann_health_is_balanced_at_one() {
2507 let docs: Vec<Vec<u32>> = (0..4).map(|c| vec![c * 2, c * 2 + 1]).collect();
2508 let ords = [0u16; 2];
2509 let codes = [0x55u8; 2];
2510 let runs: Vec<BuildRun<'_>> = docs
2511 .iter()
2512 .enumerate()
2513 .map(|(cluster, doc_ids)| BuildRun {
2514 cluster_id: cluster as u32,
2515 doc_ids,
2516 ordinals: &ords,
2517 codes: &codes,
2518 })
2519 .collect();
2520 let mut header = binary_header(8);
2521 header.num_clusters = 4;
2522 let mut bytes = Vec::new();
2523 write_built_runs(header, &runs, &mut bytes).unwrap();
2524 let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::BinaryIvf, 8).unwrap();
2525 let health = disk.health();
2526 assert!((health.imbalance - 1.0).abs() < 1e-9);
2527 assert!((health.fragmentation() - 1.0).abs() < 1e-9);
2528 assert!((health.largest_cluster_share() - 0.25).abs() < 1e-9);
2529 }
2530
2531 fn binary_header(vector_count: usize) -> AnnDiskHeader {
2532 AnnDiskHeader {
2533 kind: AnnKind::BinaryIvf,
2534 routing: IvfRoutingMode::Hnsw,
2535 dim: 8,
2536 code_size: 1,
2537 num_clusters: 2,
2538 quantizer_version: 42,
2539 codebook_version: 0,
2540 vector_count,
2541 }
2542 }
2543
2544 fn payload_end(index: &AnnDiskIndex) -> usize {
2545 index.runs.iter().map(|run| run.codes.end).max().unwrap()
2546 }
2547
2548 #[test]
2549 fn ann_prefetch_ranges_are_sorted_and_only_merge_page_near_extents() {
2550 let mut ranges = vec![
2551 15_000..16_000,
2552 0..1_000,
2553 9_000..10_000,
2554 1_000..2_000,
2555 7_000..8_000,
2556 ];
2557 coalesce_prefetch_ranges(&mut ranges);
2558 assert_eq!(ranges, [0..2_000, 7_000..10_000, 15_000..16_000]);
2559 }
2560
2561 #[test]
2562 fn normal_merge_copies_ann_payload_columns_byte_for_byte() {
2563 let first_doc_0 = [0u32];
2564 let first_doc_1 = [1u32];
2565 let first_ord_0 = [0u16];
2566 let first_ord_1 = [2u16];
2567 let first_code_0 = [0x00u8];
2568 let first_code_1 = [0xffu8];
2569 let first_runs = [
2570 BuildRun {
2571 cluster_id: 0,
2572 doc_ids: &first_doc_0,
2573 ordinals: &first_ord_0,
2574 codes: &first_code_0,
2575 },
2576 BuildRun {
2577 cluster_id: 1,
2578 doc_ids: &first_doc_1,
2579 ordinals: &first_ord_1,
2580 codes: &first_code_1,
2581 },
2582 ];
2583 let mut first_bytes = Vec::new();
2584 write_built_runs(binary_header(2), &first_runs, &mut first_bytes).unwrap();
2585 let first = AnnDiskIndex::open(OwnedBytes::new(first_bytes.clone()), AnnKind::BinaryIvf, 2)
2586 .unwrap();
2587
2588 let second_docs = [0u32, 1u32];
2589 let second_ords = [1u16, 0u16];
2590 let second_codes = [0x0fu8, 0xf0u8];
2591 let second_runs = [BuildRun {
2592 cluster_id: 0,
2593 doc_ids: &second_docs,
2594 ordinals: &second_ords,
2595 codes: &second_codes,
2596 }];
2597 let mut second_bytes = Vec::new();
2598 write_built_runs(binary_header(2), &second_runs, &mut second_bytes).unwrap();
2599 let second =
2600 AnnDiskIndex::open(OwnedBytes::new(second_bytes.clone()), AnnKind::BinaryIvf, 2)
2601 .unwrap();
2602
2603 let mut merged_bytes = Vec::new();
2604 write_merged_ann(&[(&first, 0), (&second, 2)], &mut merged_bytes).unwrap();
2605 let merged =
2606 AnnDiskIndex::open(OwnedBytes::new(merged_bytes.clone()), AnnKind::BinaryIvf, 4)
2607 .unwrap();
2608
2609 let mut expected_payload = first_bytes[ANN_HEADER_SIZE..payload_end(&first)].to_vec();
2610 expected_payload.extend_from_slice(&second_bytes[ANN_HEADER_SIZE..payload_end(&second)]);
2611 assert_eq!(
2612 &merged_bytes[ANN_HEADER_SIZE..payload_end(&merged)],
2613 expected_payload.as_slice(),
2614 "normal merge must not decode or rewrite any corpus-sized ANN column",
2615 );
2616
2617 let mut docs: Vec<u32> = merged
2618 .search_binary_clusters::<false>(&[0], 4, &[0, 1])
2619 .unwrap()
2620 .into_iter()
2621 .map(|result| result.0)
2622 .collect();
2623 docs.sort_unstable();
2624 assert_eq!(docs, [0, 1, 2, 3]);
2625
2626 let mut second_merge_bytes = Vec::new();
2630 write_merged_ann(&[(&merged, 0), (&first, 4)], &mut second_merge_bytes).unwrap();
2631 let second_merge = AnnDiskIndex::open(
2632 OwnedBytes::new(second_merge_bytes.clone()),
2633 AnnKind::BinaryIvf,
2634 6,
2635 )
2636 .unwrap();
2637 let mut expected_second_payload =
2638 merged_bytes[ANN_HEADER_SIZE..payload_end(&merged)].to_vec();
2639 expected_second_payload
2640 .extend_from_slice(&first_bytes[ANN_HEADER_SIZE..payload_end(&first)]);
2641 assert_eq!(
2642 &second_merge_bytes[ANN_HEADER_SIZE..payload_end(&second_merge)],
2643 expected_second_payload.as_slice(),
2644 );
2645 let mut docs: Vec<u32> = second_merge
2646 .search_binary_clusters::<false>(&[0], 6, &[0, 1])
2647 .unwrap()
2648 .into_iter()
2649 .map(|result| result.0)
2650 .collect();
2651 docs.sort_unstable();
2652 assert_eq!(docs, [0, 1, 2, 3, 4, 5]);
2653 }
2654
2655 #[test]
2656 fn legacy_ivf_tq_payload_is_rejected_while_opening() {
2657 let dim = 8;
2658 let marked_version = crate::structures::mark_ivf_tq_cosine_generation(7);
2659 let centroids = crate::structures::CoarseCentroids {
2660 num_clusters: 1,
2661 dim,
2662 centroids: vec![0.0; dim],
2663 version: marked_version,
2664 soar_config: None,
2665 routing_index: None,
2666 };
2667 let mut bytes = crate::segment::ann_build::build_ivf_tq(
2668 dim,
2669 IvfRoutingMode::Flat,
2670 ¢roids,
2671 &[(0, 0)],
2672 &[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
2673 )
2674 .unwrap();
2675
2676 bytes[24..32].copy_from_slice(&7u64.to_le_bytes());
2678 let error = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::IvfTq, 1)
2679 .err()
2680 .expect("legacy IVF-TQ payload must fail while opening")
2681 .to_string();
2682 assert!(error.contains("unsupported legacy generation"), "{error}");
2683 }
2684
2685 #[test]
2686 fn binary_combined_search_deduplicates_soar_and_bounds_document_results() {
2687 let cluster_0_docs = [0u32, 0, 1];
2691 let cluster_0_ordinals = [0u16, 1, 0];
2692 let cluster_0_codes = [0x00u8, 0xff, 0x03];
2693 let cluster_1_docs = [0u32, 1, 2];
2694 let cluster_1_ordinals = [0u16, 1, 0];
2695 let cluster_1_codes = [0x00u8, 0x0c, 0xf0];
2696 let runs = [
2697 BuildRun {
2698 cluster_id: 0,
2699 doc_ids: &cluster_0_docs,
2700 ordinals: &cluster_0_ordinals,
2701 codes: &cluster_0_codes,
2702 },
2703 BuildRun {
2704 cluster_id: 1,
2705 doc_ids: &cluster_1_docs,
2706 ordinals: &cluster_1_ordinals,
2707 codes: &cluster_1_codes,
2708 },
2709 ];
2710 let mut bytes = Vec::new();
2711 write_built_runs(binary_header(6), &runs, &mut bytes).unwrap();
2712 let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::BinaryIvf, 3).unwrap();
2713
2714 let sum = crate::query::MultiValueCombiner::Sum;
2718 let (result, probed) = disk
2719 .search_binary_combined_documents(1, &[0], &[0, 1], sum)
2720 .unwrap();
2721 assert_eq!(result.len(), 1, "combined search must honor k");
2722 assert_eq!(
2723 result[0].doc_id, 1,
2724 "SOAR duplicate changed {sum:?} ranking: {result:?}",
2725 );
2726 assert_eq!(
2729 probed
2730 .iter()
2731 .map(|&(doc_id, ordinal, _)| (doc_id, ordinal))
2732 .collect::<Vec<_>>(),
2733 vec![(1, 0), (1, 1)],
2734 "{sum:?}",
2735 );
2736
2737 let smooth_max = crate::query::MultiValueCombiner::default();
2743 let (result, probed) = disk
2744 .search_binary_combined_documents(1, &[0], &[0, 1], smooth_max)
2745 .unwrap();
2746 assert_eq!(result.len(), 1, "combined search must honor k");
2747 assert_eq!(result[0].doc_id, 0, "{result:?}");
2748 let expected = smooth_max.combine(&[(0, 1.0), (1, 0.0)]);
2749 assert!(
2750 (result[0].score - expected).abs() < 1e-6,
2751 "SOAR duplicate leaked into {smooth_max:?}: got {}, expected {expected}",
2752 result[0].score,
2753 );
2754 assert_eq!(
2755 probed
2756 .iter()
2757 .map(|&(doc_id, ordinal, _)| (doc_id, ordinal))
2758 .collect::<Vec<_>>(),
2759 vec![(0, 0), (0, 1)],
2760 "{smooth_max:?}",
2761 );
2762
2763 let (top_two, probed) = disk
2764 .search_binary_combined_documents(
2765 2,
2766 &[0],
2767 &[0, 1],
2768 crate::query::MultiValueCombiner::Sum,
2769 )
2770 .unwrap();
2771 assert_eq!(
2772 top_two
2773 .iter()
2774 .map(|candidate| candidate.doc_id)
2775 .collect::<Vec<_>>(),
2776 vec![1, 0],
2777 );
2778 assert_eq!(top_two.len(), 2, "full probing must still return at most k");
2779 assert_eq!(
2782 probed
2783 .iter()
2784 .map(|&(doc_id, ordinal, _)| (doc_id, ordinal))
2785 .collect::<Vec<_>>(),
2786 vec![(0, 0), (0, 1), (1, 0), (1, 1)],
2787 );
2788 }
2789
2790 #[test]
2791 fn combined_ordinal_reduction_handles_out_of_order_runs_for_every_combiner() {
2792 let out_of_order_with_duplicate = vec![
2793 (7, 1, 0.4),
2794 (3, 0, 0.8),
2795 (7, 0, 0.6),
2796 (3, 1, 0.2),
2797 (7, 1, 0.5), ];
2799 for combiner in [
2800 crate::query::MultiValueCombiner::Max,
2801 crate::query::MultiValueCombiner::Sum,
2802 crate::query::MultiValueCombiner::Avg,
2803 crate::query::MultiValueCombiner::default(),
2804 crate::query::MultiValueCombiner::WeightedTopK { k: 2, decay: 0.7 },
2805 ] {
2806 let actual = combine_scored_ordinals(out_of_order_with_duplicate.clone(), 2, combiner);
2807 let mut expected = vec![
2808 AnnDocumentCandidate {
2809 doc_id: 3,
2810 score: combiner.combine(&[(0, 0.8), (1, 0.2)]),
2811 },
2812 AnnDocumentCandidate {
2813 doc_id: 7,
2814 score: combiner.combine(&[(0, 0.6), (1, 0.5)]),
2815 },
2816 ];
2817 expected.sort_unstable_by(|left, right| {
2818 right
2819 .score
2820 .total_cmp(&left.score)
2821 .then_with(|| left.doc_id.cmp(&right.doc_id))
2822 });
2823 assert_eq!(actual, expected, "combiner {combiner:?}");
2824 }
2825 }
2826
2827 fn build_tq_payload(dim: usize, count: usize, seed: u64) -> (Vec<u8>, Vec<Vec<f32>>) {
2828 let codec = std::sync::Arc::new(crate::structures::TqCodec::new(dim));
2829 let mut builder = crate::structures::TqFlatBuilder::new(std::sync::Arc::clone(&codec));
2830 let mut state = seed;
2831 let mut vectors = Vec::new();
2832 let mut flat = Vec::new();
2833 for _ in 0..count {
2834 let vector: Vec<f32> = (0..dim)
2835 .map(|_| {
2836 state = state
2837 .wrapping_mul(6364136223846793005)
2838 .wrapping_add(1442695040888963407);
2839 ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
2840 })
2841 .collect();
2842 flat.extend_from_slice(&vector);
2843 vectors.push(vector);
2844 }
2845 let labels: Vec<(u32, u16)> = (0..count).map(|index| (index as u32, 0)).collect();
2846 builder.add_batch(&labels, &flat).unwrap();
2847 builder.finish();
2848 let mut bytes = Vec::new();
2849 write_built_tq_flat(&builder, &mut bytes).unwrap();
2850 (bytes, vectors)
2851 }
2852
2853 #[test]
2854 fn tq_combined_scan_ranks_complete_documents_instead_of_individual_values() {
2855 let dim = 8;
2856 let codec = std::sync::Arc::new(crate::structures::TqCodec::new(dim));
2857 let mut builder = crate::structures::TqFlatBuilder::new(std::sync::Arc::clone(&codec));
2858 let labels = [(0u32, 0u16), (1, 0), (1, 1)];
2859 let vectors = [
2860 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.8, 0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.8, -0.6, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
2863 ];
2864 builder.add_batch(&labels, &vectors).unwrap();
2865 builder.finish();
2866 let mut bytes = Vec::new();
2867 write_built_tq_flat(&builder, &mut bytes).unwrap();
2868 let disk = AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, 2).unwrap();
2869 let query = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2870 let plan = crate::structures::TqQueryPlan::build(&codec, &query);
2871
2872 let max = disk.search_tq_distinct(1, &plan).unwrap();
2873 let sum = disk
2874 .search_tq_combined_documents(1, &plan, crate::query::MultiValueCombiner::Sum)
2875 .unwrap();
2876 let default_combiner = disk
2877 .search_tq_combined_documents(1, &plan, crate::query::MultiValueCombiner::default())
2878 .unwrap();
2879 assert_eq!(max[0].0, 0, "fixture must favor doc 0 by Max: {max:?}");
2880 assert_eq!(
2881 sum[0].doc_id, 1,
2882 "two complete values must make doc 1 win by Sum: {sum:?}"
2883 );
2884 assert_eq!(
2888 default_combiner[0].doc_id, 0,
2889 "default smooth-max must follow the best value: {default_combiner:?}"
2890 );
2891 assert!(sum[0].score > max[0].2);
2892
2893 let (single_bytes, _) = build_tq_payload(dim, 1, 91);
2897 let single = AnnDiskIndex::open(OwnedBytes::new(single_bytes), AnnKind::TqFlat, 1).unwrap();
2898 let mut merged_bytes = Vec::new();
2899 write_merged_ann(&[(&disk, 1), (&single, 0)], &mut merged_bytes).unwrap();
2900 let merged = AnnDiskIndex::open(OwnedBytes::new(merged_bytes), AnnKind::TqFlat, 3).unwrap();
2901 let merged_sum = merged
2902 .search_tq_combined_documents(1, &plan, crate::query::MultiValueCombiner::Sum)
2903 .unwrap();
2904 assert_eq!(merged_sum[0].doc_id, 2);
2905 }
2906
2907 #[test]
2908 fn tq_payload_roundtrip_search_and_pure_copy_merge() {
2909 let dim = 20; let count = 21;
2911 let (bytes, vectors) = build_tq_payload(dim, count, 42);
2912 let index = AnnDiskIndex::open(
2913 OwnedBytes::new(bytes.clone()),
2914 AnnKind::TqFlat,
2915 count as u32,
2916 )
2917 .unwrap();
2918 assert_eq!(index.header().vector_count, count);
2919
2920 let codec = crate::structures::TqCodec::new(dim);
2922 for target in [0usize, 7, 20] {
2923 let plan = crate::structures::TqQueryPlan::build(&codec, &vectors[target]);
2924 let results = index.search_tq_distinct(3, &plan).unwrap();
2925 assert_eq!(
2926 results[0].0, target as u32,
2927 "query duplicating vector {target} must rank it first: {results:?}"
2928 );
2929 }
2930
2931 let (second_bytes, _) = build_tq_payload(dim, 5, 77);
2933 let second =
2934 AnnDiskIndex::open(OwnedBytes::new(second_bytes.clone()), AnnKind::TqFlat, 5).unwrap();
2935 let mut merged_bytes = Vec::new();
2936 write_merged_ann(&[(&index, 0), (&second, count as u32)], &mut merged_bytes).unwrap();
2937 let merged = AnnDiskIndex::open(
2938 OwnedBytes::new(merged_bytes.clone()),
2939 AnnKind::TqFlat,
2940 count as u32 + 5,
2941 )
2942 .unwrap();
2943 let mut expected_payload = bytes[ANN_HEADER_SIZE..payload_end(&index)].to_vec();
2944 expected_payload.extend_from_slice(&second_bytes[ANN_HEADER_SIZE..payload_end(&second)]);
2945 assert_eq!(
2946 &merged_bytes[ANN_HEADER_SIZE..payload_end(&merged)],
2947 expected_payload.as_slice(),
2948 "TQ merge must be a pure byte copy of the source columns",
2949 );
2950 let plan = crate::structures::TqQueryPlan::build(&codec, &vectors[7]);
2951 let results = merged.search_tq_distinct(1, &plan).unwrap();
2952 assert_eq!(results[0].0, 7, "merged payload must keep doc bases");
2953 }
2954
2955 #[test]
2956 fn tq_parallel_fold_matches_a_sequential_scan() {
2957 use crate::structures::vector::quantization::{
2958 TQ_BLOCK_LANES, tq_block_bytes, tq_score_block,
2959 };
2960
2961 let dim = 8;
2964 let count = TQ_PARALLEL_SCAN_MIN_VECTORS;
2965 let (bytes, vectors) = build_tq_payload(dim, count, 87);
2966 let disk =
2967 AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, count as u32).unwrap();
2968 let codec = crate::structures::TqCodec::new(dim);
2969 let plan = crate::structures::TqQueryPlan::build(&codec, &vectors[count / 3]);
2970 let k = 31;
2971
2972 let block_bytes = tq_block_bytes(disk.header().code_size);
2973 assert_eq!(
2974 disk.runs.len(),
2975 1,
2976 "the regression must parallelize chunks inside one run"
2977 );
2978 assert!(
2979 disk.runs[0].codes.len() / block_bytes > TQ_PARALLEL_SCAN_CHUNK_BLOCKS,
2980 "the single run must span multiple parallel chunks"
2981 );
2982 let raw = disk.raw.as_slice();
2983 let mut reference = BoundedAnnCollector::<true, true>::new(k);
2984 let mut scores = [0.0f32; TQ_BLOCK_LANES];
2985 for run in &disk.runs {
2986 let codes = &raw[run.codes.clone()];
2987 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
2988 tq_score_block(&plan, block, &mut scores);
2989 let lane_base = block_index * TQ_BLOCK_LANES;
2990 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
2991 for (lane, &score) in scores.iter().enumerate().take(lanes) {
2992 let index = lane_base + lane;
2993 reference.insert(
2994 run_doc_id(raw, run, index).unwrap(),
2995 read_u16(raw, run.ordinals.start + index * 2),
2996 score,
2997 );
2998 }
2999 }
3000 }
3001
3002 assert_eq!(
3003 disk.search_tq_distinct(k, &plan).unwrap(),
3004 reference.into_sorted_results(),
3005 );
3006 }
3007
3008 #[test]
3009 fn ivf_tq_scale_pruning_matches_the_unpruned_scan() {
3010 use crate::structures::vector::ivf::{CoarseCentroids, CoarseConfig};
3011 use crate::structures::vector::quantization::{
3012 TQ_BLOCK_LANES, tq_ivf_block_bytes, tq_score_ivf_block,
3013 };
3014 use crate::structures::{IvfTqIndex, TqCodec, TqIvfEncodeScratch, TqIvfQueryPlan};
3015
3016 let dim = 32;
3017 let count = 400usize;
3018 let codec = std::sync::Arc::new(TqCodec::new(dim));
3019 let mut state = 5u64;
3020 let mut next = move || {
3021 state = state
3022 .wrapping_mul(6364136223846793005)
3023 .wrapping_add(1442695040888963407);
3024 ((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
3025 };
3026 let vectors: Vec<Vec<f32>> = (0..count)
3027 .map(|_| {
3028 let mut v: Vec<f32> = (0..dim).map(|_| next()).collect();
3029 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
3030 v.iter_mut().for_each(|x| *x /= norm);
3031 v
3032 })
3033 .collect();
3034 let mut centroids = CoarseCentroids::train(&CoarseConfig::new(dim, 8), &vectors, "test");
3035 centroids.version = crate::structures::mark_ivf_tq_cosine_generation(centroids.version);
3036 let mut index = IvfTqIndex::new(
3037 dim,
3038 crate::dsl::IvfRoutingMode::Flat,
3039 centroids.version,
3040 std::sync::Arc::clone(&codec),
3041 );
3042 let mut scratch = TqIvfEncodeScratch::default();
3043 for (i, vector) in vectors.iter().enumerate() {
3044 index.add_vector(
3045 ¢roids,
3046 (i / 2) as u32,
3047 (i % 2) as u16,
3048 vector,
3049 &mut scratch,
3050 );
3051 }
3052 let mut bytes = Vec::new();
3053 write_built_ivf_tq(&index, centroids.num_clusters, &mut bytes).unwrap();
3054 let disk =
3055 AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::IvfTq, count as u32).unwrap();
3056
3057 let block_bytes = tq_ivf_block_bytes(disk.header().code_size);
3060 let raw = disk.raw.as_slice();
3061 for run in &disk.runs {
3062 let codes = &raw[run.codes.clone()];
3063 let mut previous_scale = f32::INFINITY;
3064 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
3065 let lane_base = block_index * TQ_BLOCK_LANES;
3066 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
3067 let mut block_scales = block[..TQ_BLOCK_LANES * size_of::<f32>()]
3068 .chunks_exact(size_of::<f32>())
3069 .take(lanes)
3070 .map(|lane| f32::from_le_bytes(lane.try_into().unwrap()));
3071 let first_scale = block_scales.next().unwrap();
3072 assert_eq!(tq_ivf_block_max_scale(block), first_scale);
3073 assert!(first_scale <= previous_scale);
3074 previous_scale = first_scale;
3075 for scale in block_scales {
3076 assert!(scale <= previous_scale);
3077 previous_scale = scale;
3078 }
3079 }
3080 }
3081 let k = 10;
3082 for query_seed in [1u64, 9, 42] {
3083 let mut qstate = query_seed;
3084 let mut qnext = move || {
3085 qstate = qstate
3086 .wrapping_mul(6364136223846793005)
3087 .wrapping_add(1442695040888963407);
3088 ((qstate >> 33) as f32 / (1u64 << 31) as f32) - 0.5
3089 };
3090 let query: Vec<f32> = (0..dim).map(|_| qnext()).collect();
3091 let plan = TqIvfQueryPlan::build(
3092 ¢roids,
3093 &codec,
3094 &query,
3095 8,
3096 crate::dsl::IvfRoutingMode::Flat,
3097 );
3098 let mut reference = BoundedAnnCollector::<true, true>::new(k);
3101 let mut unpruned_scores = Vec::new();
3102 let mut scores = [0.0f32; TQ_BLOCK_LANES];
3103 for (cluster_id, cluster_dot) in plan.cluster_dots() {
3104 for run in disk.cluster_runs(cluster_id) {
3105 let codes = &raw[run.codes.clone()];
3106 for (block_index, block) in codes.chunks_exact(block_bytes).enumerate() {
3107 tq_score_ivf_block(plan.tq_plan(), block, cluster_dot, &mut scores);
3108 let lane_base = block_index * TQ_BLOCK_LANES;
3109 let lanes = TQ_BLOCK_LANES.min(run.count.saturating_sub(lane_base));
3110 for (lane, &score) in scores.iter().enumerate().take(lanes) {
3111 let idx = lane_base + lane;
3112 reference.insert(
3113 run_doc_id(raw, run, idx).unwrap(),
3114 read_u16(raw, run.ordinals.start + idx * 2),
3115 score,
3116 );
3117 unpruned_scores.push((
3118 run_doc_id(raw, run, idx).unwrap(),
3119 read_u16(raw, run.ordinals.start + idx * 2),
3120 score,
3121 ));
3122 }
3123 }
3124 }
3125 }
3126
3127 let pruned = disk.search_ivf_tq_distinct(k, &plan).unwrap();
3128 let reference = reference.into_sorted_results();
3129 assert_eq!(
3130 pruned, reference,
3131 "scale-bound pruning must not change the estimated top-k (seed {query_seed})"
3132 );
3133 for combiner in [
3134 crate::query::MultiValueCombiner::Max,
3135 crate::query::MultiValueCombiner::Sum,
3136 crate::query::MultiValueCombiner::Avg,
3137 crate::query::MultiValueCombiner::default(),
3138 crate::query::MultiValueCombiner::WeightedTopK { k: 3, decay: 0.7 },
3139 ] {
3140 let expected = combine_scored_ordinals(unpruned_scores.clone(), k, combiner);
3141 let combined = disk
3142 .search_ivf_tq_combined_documents(k, &plan, combiner)
3143 .unwrap();
3144 assert_eq!(
3145 combined, expected,
3146 "combined IVF-TQ scan diverged from the unpruned reference \
3147 for {combiner:?} (seed {query_seed})",
3148 );
3149 assert!(combined.len() <= k);
3150 }
3151 }
3152 }
3153
3154 #[test]
3155 fn open_rejects_tq_payload_with_inconsistent_geometry() {
3156 let (bytes, _) = build_tq_payload(20, 4, 9);
3157 let mut corrupted = bytes.clone();
3159 corrupted[12..16].copy_from_slice(&15u32.to_le_bytes());
3160 assert!(
3161 AnnDiskIndex::open(OwnedBytes::new(corrupted), AnnKind::TqFlat, 4).is_err(),
3162 "TQ header with code_size != padded_dim/2 must be refused"
3163 );
3164
3165 let (short_bytes, _) = build_tq_payload(20, 4, 9);
3167 let mut wrong_kind = short_bytes.clone();
3168 wrong_kind[4] = AnnKind::BinaryIvf as u8;
3169 assert!(
3170 AnnDiskIndex::open(OwnedBytes::new(wrong_kind), AnnKind::BinaryIvf, 4).is_err(),
3171 "TQ block-padded columns must not validate under another kind"
3172 );
3173
3174 let (legacy_bytes, _) = build_tq_payload(20, 4, 9);
3176 let mut legacy_kind = legacy_bytes.clone();
3177 legacy_kind[4] = 1;
3178 let Err(error) = AnnDiskIndex::open(OwnedBytes::new(legacy_kind), AnnKind::TqFlat, 4)
3179 else {
3180 panic!("retired IVF-PQ payloads must not open");
3181 };
3182 assert!(
3183 error.to_string().contains("IVF-PQ"),
3184 "error must name the retired format: {error}"
3185 );
3186 assert!(AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::TqFlat, 4).is_ok());
3187 }
3188
3189 #[test]
3190 fn open_rejects_old_or_out_of_range_ann_payloads() {
3191 let mut legacy = vec![0u8; ANN_HEADER_SIZE + ANN_FOOTER_SIZE];
3192 legacy[..4].copy_from_slice(b"old!");
3193 assert!(AnnDiskIndex::open(OwnedBytes::new(legacy), AnnKind::BinaryIvf, 1).is_err());
3194
3195 let docs = [0u32];
3196 let ordinals = [0u16];
3197 let codes = [0u8];
3198 let runs = [BuildRun {
3199 cluster_id: 0,
3200 doc_ids: &docs,
3201 ordinals: &ordinals,
3202 codes: &codes,
3203 }];
3204 let mut bytes = Vec::new();
3205 write_built_runs(binary_header(1), &runs, &mut bytes).unwrap();
3206 let footer = bytes.len() - ANN_FOOTER_SIZE;
3207 let directory = usize::try_from(u64::from_le_bytes(
3208 bytes[footer..footer + 8].try_into().unwrap(),
3209 ))
3210 .unwrap();
3211 bytes[directory + 12..directory + 16].copy_from_slice(&10u32.to_le_bytes());
3212 assert!(AnnDiskIndex::open(OwnedBytes::new(bytes), AnnKind::BinaryIvf, 1).is_err());
3213 }
3214}