1use ailake_core::{AilakeResult, Centroid, RowId, VectorStoragePolicy};
3use ailake_index::{
4 HnswBuilder, HnswConfig, HnswSerializer, IvfPqCodebook, IvfPqConfig, IvfPqIndex,
5 IvfPqSerializer,
6};
7use ailake_parquet::ParquetVectorWriter;
8use ailake_vec::compute_centroid_and_radius;
9use arrow_array::RecordBatch;
10use bytes::{BufMut, Bytes, BytesMut};
11
12use crate::footer::{
13 parquet_footer_start, AilakeHeader, AilakeTrailer, DistanceMetric, Precision,
14 AILAKE_FORMAT_VERSION, FLAG_INDEX_IVF_PQ, HEADER_SIZE, TRAILER_SIZE,
15};
16
17#[derive(Debug, Clone)]
19pub enum IndexType {
20 Hnsw(HnswConfig),
22 IvfPq(IvfPqConfig),
24 Auto,
29}
30
31impl Default for IndexType {
32 fn default() -> Self {
33 IndexType::Hnsw(HnswConfig::default())
34 }
35}
36
37pub struct VectorColumnBatch<'a> {
39 pub policy: &'a VectorStoragePolicy,
40 pub embeddings: &'a [Vec<f32>],
41}
42
43pub struct AilakeFileWriter {
44 policy: VectorStoragePolicy,
45 index_type: IndexType,
46 shared_codebook: Option<std::sync::Arc<IvfPqCodebook>>,
48}
49
50impl AilakeFileWriter {
51 pub fn new(policy: VectorStoragePolicy) -> Self {
52 Self {
53 policy,
54 index_type: IndexType::default(),
55 shared_codebook: None,
56 }
57 }
58
59 pub fn with_shared_ivf_codebook(mut self, codebook: std::sync::Arc<IvfPqCodebook>) -> Self {
62 self.shared_codebook = Some(codebook);
63 self
64 }
65
66 pub fn with_hnsw_config(mut self, config: HnswConfig) -> Self {
67 self.index_type = IndexType::Hnsw(config);
68 self
69 }
70
71 pub fn with_ivf_pq(mut self, config: IvfPqConfig) -> Self {
72 self.index_type = IndexType::IvfPq(config);
73 self
74 }
75
76 pub fn with_index_type(mut self, index_type: IndexType) -> Self {
77 self.index_type = index_type;
78 self
79 }
80
81 pub fn with_auto_index(mut self) -> Self {
84 self.index_type = IndexType::Auto;
85 self
86 }
87
88 pub fn write_with_prebuilt_hnsw(
98 &self,
99 batch: &RecordBatch,
100 embeddings: &[Vec<f32>],
101 hnsw: &ailake_index::HnswIndex,
102 ) -> AilakeResult<Bytes> {
103 use ailake_core::AilakeError;
104
105 let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
106
107 let (parquet_v1, record_count) = parquet_writer.write_batch(batch, embeddings)?;
109 let footer_start = parquet_footer_start(&parquet_v1)?;
110
111 let index_bytes = HnswSerializer::to_bytes(hnsw)?;
112 let ailk_section = build_ailk_section_from_index_bytes(
113 &self.policy,
114 embeddings,
115 record_count,
116 footer_start as u64,
117 &index_bytes,
118 0u16, )?;
120
121 let kv_val = footer_start.to_string();
122 let kv_refs: &[(&str, &str)] = &[("ailake.footer_offset", kv_val.as_str())];
123
124 let (parquet_v2, _) = parquet_writer.write_batch_with_kv(batch, embeddings, kv_refs)?;
126 let footer_start_v2 = parquet_footer_start(&parquet_v2).map_err(|e| {
127 AilakeError::Parquet(format!(
128 "footer_start unstable in write_with_prebuilt_hnsw: {e}"
129 ))
130 })?;
131 debug_assert_eq!(
132 footer_start, footer_start_v2,
133 "footer_start must be stable across KV injection"
134 );
135
136 let footer_len_v2 = parquet_v2.len() - footer_start_v2;
137 let mut out = BytesMut::with_capacity(footer_start + ailk_section.len() + footer_len_v2);
138 out.put_slice(&parquet_v1[..footer_start]);
139 drop(parquet_v1);
140 out.put(ailk_section);
141 out.put_slice(&parquet_v2[footer_start_v2..]);
142 Ok(out.freeze())
143 }
144
145 pub fn write_parquet_only(
153 &self,
154 batch: &RecordBatch,
155 embeddings: &[Vec<f32>],
156 ) -> AilakeResult<Bytes> {
157 let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
158 let (bytes, _) = parquet_writer.write_batch(batch, embeddings)?;
159 Ok(bytes)
160 }
161
162 pub fn write(&self, batch: &RecordBatch, embeddings: &[Vec<f32>]) -> AilakeResult<Bytes> {
171 let col = VectorColumnBatch {
172 policy: &self.policy,
173 embeddings,
174 };
175 self.write_multi(batch, &[col])
176 }
177
178 pub fn write_single_pass(
193 &self,
194 batch: &RecordBatch,
195 embeddings: &[Vec<f32>],
196 ) -> AilakeResult<Bytes> {
197 let col = VectorColumnBatch {
198 policy: &self.policy,
199 embeddings,
200 };
201 self.write_multi_single_pass(batch, &[col])
202 }
203
204 pub fn write_multi_single_pass(
206 &self,
207 batch: &RecordBatch,
208 columns: &[VectorColumnBatch<'_>],
209 ) -> AilakeResult<Bytes> {
210 use ailake_core::AilakeError;
211
212 if columns.is_empty() {
213 return Err(AilakeError::InvalidArgument(
214 "write_multi_single_pass requires at least one vector column".into(),
215 ));
216 }
217
218 let primary = &columns[0];
219 let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
220
221 let (parquet_bytes, record_count) =
224 parquet_writer.write_batch(batch, primary.embeddings)?;
225 let footer_start = parquet_footer_start(&parquet_bytes)?;
226
227 let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
230 let mut current_offset = footer_start as u64;
231 for col in columns.iter() {
232 let section = build_ailk_section(
233 col.policy,
234 col.embeddings,
235 record_count,
236 current_offset,
237 &self.index_type,
238 self.shared_codebook.as_deref(),
239 )?;
240 current_offset += section.len() as u64;
241 ailk_sections.push(section);
242 }
243
244 let total_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
247 let mut out = BytesMut::with_capacity(parquet_bytes.len() + total_ailk);
248 out.put_slice(&parquet_bytes[..footer_start]);
249 for section in ailk_sections {
250 out.put(section);
251 }
252 out.put_slice(&parquet_bytes[footer_start..]);
253
254 Ok(out.freeze())
255 }
256
257 pub fn write_multi(
266 &self,
267 batch: &RecordBatch,
268 columns: &[VectorColumnBatch<'_>],
269 ) -> AilakeResult<Bytes> {
270 use ailake_core::AilakeError;
271
272 if columns.is_empty() {
273 return Err(AilakeError::InvalidArgument(
274 "write_multi requires at least one vector column".into(),
275 ));
276 }
277
278 let primary = &columns[0];
279 let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
280
281 let (parquet_v1, record_count) = parquet_writer.write_batch(batch, primary.embeddings)?;
283 let footer_start = parquet_footer_start(&parquet_v1)?;
284
285 let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
287 let mut kv_owned: Vec<(String, String)> = Vec::with_capacity(columns.len());
288 let mut current_offset = footer_start as u64;
289
290 for (i, col) in columns.iter().enumerate() {
291 let section = build_ailk_section(
292 col.policy,
293 col.embeddings,
294 record_count,
295 current_offset,
296 &self.index_type,
297 self.shared_codebook.as_deref(),
298 )?;
299 let kv_key = if i == 0 {
300 "ailake.footer_offset".to_string()
301 } else {
302 format!("ailake.{}.footer_offset", col.policy.column_name)
303 };
304 kv_owned.push((kv_key, current_offset.to_string()));
305 current_offset += section.len() as u64;
306 ailk_sections.push(section);
307 }
308
309 let kv_refs: Vec<(&str, &str)> = kv_owned
315 .iter()
316 .map(|(k, v)| (k.as_str(), v.as_str()))
317 .collect();
318 let (parquet_v2, _) =
319 parquet_writer.write_batch_with_kv(batch, primary.embeddings, &kv_refs)?;
320 let footer_start_v2 = parquet_footer_start(&parquet_v2)?;
321 debug_assert_eq!(
322 footer_start, footer_start_v2,
323 "footer_start must be stable across KV injection (row groups unchanged)"
324 );
325
326 let total_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
329 let footer_len_v2 = parquet_v2.len() - footer_start_v2;
330 let total = footer_start + total_ailk + footer_len_v2;
331 let mut out = BytesMut::with_capacity(total);
332 out.put_slice(&parquet_v1[..footer_start]);
333 drop(parquet_v1); for section in ailk_sections {
335 out.put(section);
336 }
337 out.put_slice(&parquet_v2[footer_start_v2..]);
338
339 Ok(out.freeze())
340 }
341}
342
343fn build_ailk_section_from_index_bytes(
346 policy: &VectorStoragePolicy,
347 embeddings: &[Vec<f32>],
348 record_count: u64,
349 ailk_abs_offset: u64,
350 index_bytes: &[u8],
351 flags: u16,
352) -> AilakeResult<Bytes> {
353 let norm_storage: Vec<Vec<f32>>;
354 let (emb_for_centroid, centroid_metric) =
355 if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
356 norm_storage = embeddings
357 .iter()
358 .map(|v| ailake_vec::normalize_l2(v))
359 .collect();
360 (
361 norm_storage.as_slice(),
362 ailake_core::VectorMetric::NormalizedCosine,
363 )
364 } else {
365 (embeddings, policy.metric)
366 };
367
368 let centroid = compute_centroid_and_radius(emb_for_centroid, centroid_metric);
369 let centroid_bytes = encode_centroid(¢roid);
370
371 let centroid_offset = HEADER_SIZE as u64;
372 let centroid_len = centroid_bytes.len() as u64;
373 let index_offset_in_ailk = centroid_offset + centroid_len;
374 let index_len = index_bytes.len() as u64;
375 let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
376
377 let header = AilakeHeader {
378 format_version: AILAKE_FORMAT_VERSION,
379 flags,
380 dim: policy.dim,
381 precision: Precision::from(policy.precision),
382 distance_metric: DistanceMetric::from(policy.metric),
383 record_count,
384 centroid_offset,
385 centroid_len,
386 hnsw_offset: index_offset_in_ailk,
387 hnsw_len: index_len,
388 };
389 let trailer = AilakeTrailer {
390 footer_offset: ailk_abs_offset,
391 footer_len: ailk_total_len,
392 format_version: AILAKE_FORMAT_VERSION,
393 flags,
394 };
395
396 let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
397 buf.put_slice(&header.to_bytes());
398 buf.put_slice(¢roid_bytes);
399 buf.put_slice(index_bytes);
400 buf.put_slice(&trailer.to_bytes());
401 Ok(buf.freeze())
402}
403
404fn build_ailk_section(
406 policy: &VectorStoragePolicy,
407 embeddings: &[Vec<f32>],
408 record_count: u64,
409 ailk_abs_offset: u64,
410 index_type: &IndexType,
411 shared_codebook: Option<&IvfPqCodebook>,
412) -> AilakeResult<Bytes> {
413 let norm_storage: Vec<Vec<f32>>;
416 let (embeddings, hnsw_metric) =
417 if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
418 norm_storage = embeddings
419 .iter()
420 .map(|v| ailake_vec::normalize_l2(v))
421 .collect();
422 (
423 norm_storage.as_slice(),
424 ailake_core::VectorMetric::NormalizedCosine,
425 )
426 } else {
427 (embeddings, policy.metric)
428 };
429
430 let centroid: Centroid = compute_centroid_and_radius(embeddings, hnsw_metric);
431 let centroid_bytes = encode_centroid(¢roid);
432
433 let resolved: IndexType;
435 let index_type = if matches!(index_type, IndexType::Auto) {
436 let profile = ailake_index::HardwareProfile::detect();
437 resolved = if profile.recommend_ivf_pq(embeddings.len()) {
438 IndexType::IvfPq(ailake_index::IvfPqConfig::for_dataset(
439 policy.dim as usize,
440 embeddings.len(),
441 ))
442 } else {
443 IndexType::Hnsw(ailake_index::HnswConfig::default())
444 };
445 &resolved
446 } else {
447 index_type
448 };
449
450 let (index_bytes, flags) = match index_type {
451 IndexType::Hnsw(hnsw_config) => {
452 let config = HnswConfig {
454 m: policy.hnsw_m.map(|v| v as usize).unwrap_or(hnsw_config.m),
455 ef_construction: policy
456 .hnsw_ef_construction
457 .map(|v| v as usize)
458 .unwrap_or(hnsw_config.ef_construction),
459 max_elements: hnsw_config.max_elements,
460 };
461 let mut builder = HnswBuilder::new(policy.dim, hnsw_metric, config);
462 for (i, v) in embeddings.iter().enumerate() {
463 builder.insert(RowId::new(i as u64), v.clone());
464 }
465 let index = builder.build();
466 (HnswSerializer::to_bytes(&index)?, 0u16)
467 }
468 IndexType::IvfPq(ivf_config) => {
469 let row_ids: Vec<RowId> = (0..embeddings.len() as u64).map(RowId::new).collect();
470 let index = if let Some(cb) = shared_codebook {
471 IvfPqIndex::build_with_codebook(&row_ids, embeddings, cb)?
472 } else {
473 ailake_index::IvfPqIndex::train(
474 &row_ids,
475 embeddings,
476 policy.metric,
477 ivf_config.clone(),
478 )?
479 };
480 (IvfPqSerializer::to_bytes(&index)?, FLAG_INDEX_IVF_PQ)
481 }
482 IndexType::Auto => unreachable!("Auto resolved above"),
483 };
484
485 let centroid_offset = HEADER_SIZE as u64;
486 let centroid_len = centroid_bytes.len() as u64;
487 let index_offset_in_ailk = centroid_offset + centroid_len;
488 let index_len = index_bytes.len() as u64;
489 let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
490
491 let header = AilakeHeader {
492 format_version: AILAKE_FORMAT_VERSION,
493 flags,
494 dim: policy.dim,
495 precision: Precision::from(policy.precision),
496 distance_metric: DistanceMetric::from(policy.metric),
497 record_count,
498 centroid_offset,
499 centroid_len,
500 hnsw_offset: index_offset_in_ailk,
501 hnsw_len: index_len,
502 };
503 let trailer = AilakeTrailer {
504 footer_offset: ailk_abs_offset,
505 footer_len: ailk_total_len,
506 format_version: AILAKE_FORMAT_VERSION,
507 flags,
508 };
509
510 let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
511 buf.put_slice(&header.to_bytes());
512 buf.put_slice(¢roid_bytes);
513 buf.put_slice(&index_bytes);
514 buf.put_slice(&trailer.to_bytes());
515 Ok(buf.freeze())
516}
517
518fn encode_centroid(c: &Centroid) -> Vec<u8> {
519 let mut bytes = Vec::with_capacity(c.values.len() * 4 + 4);
520 for &v in &c.values {
521 bytes.extend_from_slice(&v.to_le_bytes());
522 }
523 bytes.extend_from_slice(&c.radius.to_le_bytes());
524 bytes
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use ailake_core::{VectorMetric, VectorPrecision};
531 use arrow_array::{Int32Array, RecordBatch};
532 use arrow_schema::{DataType, Field, Schema};
533 use std::sync::Arc;
534
535 fn make_policy(dim: u32) -> VectorStoragePolicy {
536 VectorStoragePolicy {
537 column_name: "embedding".to_string(),
538 dim,
539 metric: VectorMetric::Cosine,
540 precision: VectorPrecision::F16,
541 pq: None,
542 keep_raw_for_reranking: true,
543 pre_normalize: false,
544 hnsw_m: None,
545 hnsw_ef_construction: None,
546 ivf_residual: false,
547 embedding_model: None,
548 modality: None,
549 partition_by: None,
550 partition_value: None,
551 partition_column_type: None,
552 partition_fields: vec![],
553 }
554 }
555
556 #[test]
557 fn write_single_pass_valid_parquet_and_ailk() {
558 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
559 let batch =
560 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
561 let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
562
563 let writer = AilakeFileWriter::new(make_policy(4));
564 let file = writer.write_single_pass(&batch, &embs).unwrap();
565
566 assert_eq!(&file[..4], b"PAR1");
568 assert_eq!(&file[file.len() - 4..], b"PAR1");
569 assert!(file.windows(4).any(|w| w == b"AILK"));
571 }
572
573 #[test]
574 fn write_single_pass_reader_bootstrap_from_trailer() {
575 use crate::reader::AilakeFileReader;
576
577 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
578 let batch =
579 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![10, 20, 30]))])
580 .unwrap();
581 let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
582
583 let writer = AilakeFileWriter::new(make_policy(4));
584 let file_bytes = writer.write_single_pass(&batch, &embs).unwrap();
585
586 let reader = AilakeFileReader::new(file_bytes, "embedding", 4);
589 assert!(
590 reader.is_ailake_file(),
591 "single-pass file must be recognised as AI-Lake file via trailer bootstrap"
592 );
593 let header = reader.read_header().expect("must read AILK header");
594 assert_eq!(header.dim, 4);
595 assert_eq!(header.record_count, 3);
596 }
597
598 #[test]
599 fn write_and_write_single_pass_same_index() {
600 use crate::reader::AilakeFileReader;
603
604 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
605 let batch = RecordBatch::try_new(
606 schema,
607 vec![Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5]))],
608 )
609 .unwrap();
610 let embs: Vec<Vec<f32>> = vec![
611 vec![1.0, 0.0, 0.0, 0.0],
612 vec![0.0, 1.0, 0.0, 0.0],
613 vec![0.0, 0.0, 1.0, 0.0],
614 vec![0.0, 0.0, 0.0, 1.0],
615 vec![0.5, 0.5, 0.0, 0.0],
616 ];
617 let policy = make_policy(4);
618 let writer = AilakeFileWriter::new(policy);
619
620 let bytes_two_pass = writer.write(&batch, &embs).unwrap();
621 let bytes_single_pass = writer.write_single_pass(&batch, &embs).unwrap();
622
623 let query = vec![1.0f32, 0.0, 0.0, 0.0];
624
625 let reader_tp = AilakeFileReader::new(bytes_two_pass, "embedding", 4);
626 let reader_sp = AilakeFileReader::new(bytes_single_pass, "embedding", 4);
627
628 let idx_tp = reader_tp.load_index().unwrap();
629 let idx_sp = reader_sp.load_index().unwrap();
630
631 let res_tp = idx_tp.search(&query, 1, 50);
632 let res_sp = idx_sp.search(&query, 1, 50);
633
634 assert_eq!(res_tp[0].0, res_sp[0].0, "nearest neighbour must match");
635 }
636
637 #[test]
638 fn write_ends_with_par1() {
639 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
640 let batch =
641 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
642 let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
643
644 let writer = AilakeFileWriter::new(make_policy(4));
645 let file = writer.write(&batch, &embs).unwrap();
646
647 assert_eq!(&file[file.len() - 4..], b"PAR1");
648 assert_eq!(&file[..4], b"PAR1");
649 assert!(file.windows(4).any(|w| w == b"AILK"));
650 }
651
652 #[test]
653 fn write_multi_two_columns() {
654 use ailake_core::{VectorMetric, VectorPrecision};
655
656 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
657 let batch =
658 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
659
660 let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
661 let ctx_embs: Vec<Vec<f32>> = (0..3).map(|i| vec![0.0, i as f32, 0.0, 0.0]).collect();
662
663 let policy1 = make_policy(4);
664 let policy2 = VectorStoragePolicy {
665 column_name: "context_embedding".to_string(),
666 dim: 4,
667 metric: VectorMetric::Cosine,
668 precision: VectorPrecision::F16,
669 pq: None,
670 keep_raw_for_reranking: true,
671 pre_normalize: false,
672 hnsw_m: None,
673 hnsw_ef_construction: None,
674 ivf_residual: false,
675 embedding_model: None,
676 modality: None,
677 partition_by: None,
678 partition_value: None,
679 partition_column_type: None,
680 partition_fields: vec![],
681 };
682
683 let writer = AilakeFileWriter::new(policy1.clone());
684 let file = writer
685 .write_multi(
686 &batch,
687 &[
688 VectorColumnBatch {
689 policy: &policy1,
690 embeddings: &embs,
691 },
692 VectorColumnBatch {
693 policy: &policy2,
694 embeddings: &ctx_embs,
695 },
696 ],
697 )
698 .unwrap();
699
700 assert_eq!(&file[..4], b"PAR1");
702 assert_eq!(&file[file.len() - 4..], b"PAR1");
703 let ailk_count = file.windows(4).filter(|w| *w == b"AILK").count();
705 assert!(
706 ailk_count >= 2,
707 "expected >= 2 AILK markers, got {ailk_count}"
708 );
709 }
710}