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, AILK_FTS_HEADER_SIZE, AILK_FTS_MAGIC, FLAG_INDEX_IVF_PQ, HEADER_SIZE,
15 KV_FTS_OFFSET, TRAILER_SIZE,
16};
17
18#[derive(Debug, Clone)]
20pub enum IndexType {
21 Hnsw(HnswConfig),
23 IvfPq(IvfPqConfig),
25 Auto,
30}
31
32impl Default for IndexType {
33 fn default() -> Self {
34 IndexType::Hnsw(HnswConfig::default())
35 }
36}
37
38pub struct VectorColumnBatch<'a> {
40 pub policy: &'a VectorStoragePolicy,
41 pub embeddings: &'a [Vec<f32>],
42}
43
44pub struct AilakeFileWriter {
45 policy: VectorStoragePolicy,
46 index_type: IndexType,
47 shared_codebook: Option<std::sync::Arc<IvfPqCodebook>>,
49 fts_config: Option<ailake_fts::FtsConfig>,
51 prebuilt_fts_blob: Option<Vec<u8>>,
53}
54
55impl AilakeFileWriter {
56 pub fn new(policy: VectorStoragePolicy) -> Self {
57 Self {
58 policy,
59 index_type: IndexType::default(),
60 shared_codebook: None,
61 fts_config: None,
62 prebuilt_fts_blob: None,
63 }
64 }
65
66 pub fn with_fts(mut self, config: ailake_fts::FtsConfig) -> Self {
68 self.fts_config = Some(config);
69 self
70 }
71
72 pub fn with_prebuilt_fts_blob(mut self, blob: Vec<u8>) -> Self {
74 self.prebuilt_fts_blob = Some(blob);
75 self
76 }
77
78 pub fn with_shared_ivf_codebook(mut self, codebook: std::sync::Arc<IvfPqCodebook>) -> Self {
81 self.shared_codebook = Some(codebook);
82 self
83 }
84
85 pub fn with_hnsw_config(mut self, config: HnswConfig) -> Self {
86 self.index_type = IndexType::Hnsw(config);
87 self
88 }
89
90 pub fn with_ivf_pq(mut self, config: IvfPqConfig) -> Self {
91 self.index_type = IndexType::IvfPq(config);
92 self
93 }
94
95 pub fn with_index_type(mut self, index_type: IndexType) -> Self {
96 self.index_type = index_type;
97 self
98 }
99
100 pub fn with_auto_index(mut self) -> Self {
103 self.index_type = IndexType::Auto;
104 self
105 }
106
107 pub fn write_with_prebuilt_hnsw(
117 &self,
118 batch: &RecordBatch,
119 embeddings: &[Vec<f32>],
120 hnsw: &ailake_index::HnswIndex,
121 ) -> AilakeResult<Bytes> {
122 use ailake_core::AilakeError;
123
124 let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
125
126 let (parquet_v1, record_count) = parquet_writer.write_batch(batch, embeddings)?;
128 let footer_start = parquet_footer_start(&parquet_v1)?;
129
130 let index_bytes = HnswSerializer::to_bytes(hnsw)?;
131 let ailk_section = build_ailk_section_from_index_bytes(
132 &self.policy,
133 embeddings,
134 record_count,
135 footer_start as u64,
136 &index_bytes,
137 0u16, )?;
139
140 let kv_val = footer_start.to_string();
141 let kv_refs: &[(&str, &str)] = &[("ailake.footer_offset", kv_val.as_str())];
142
143 let (parquet_v2, _) = parquet_writer.write_batch_with_kv(batch, embeddings, kv_refs)?;
145 let footer_start_v2 = parquet_footer_start(&parquet_v2).map_err(|e| {
146 AilakeError::Parquet(format!(
147 "footer_start unstable in write_with_prebuilt_hnsw: {e}"
148 ))
149 })?;
150 debug_assert_eq!(
151 footer_start, footer_start_v2,
152 "footer_start must be stable across KV injection"
153 );
154
155 let footer_len_v2 = parquet_v2.len() - footer_start_v2;
156 let mut out = BytesMut::with_capacity(footer_start + ailk_section.len() + footer_len_v2);
157 out.put_slice(&parquet_v1[..footer_start]);
158 drop(parquet_v1);
159 out.put(ailk_section);
160 out.put_slice(&parquet_v2[footer_start_v2..]);
161 Ok(out.freeze())
162 }
163
164 pub fn write_parquet_only(
172 &self,
173 batch: &RecordBatch,
174 embeddings: &[Vec<f32>],
175 ) -> AilakeResult<Bytes> {
176 let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
177 let (bytes, _) = parquet_writer.write_batch(batch, embeddings)?;
178 Ok(bytes)
179 }
180
181 pub fn write(&self, batch: &RecordBatch, embeddings: &[Vec<f32>]) -> AilakeResult<Bytes> {
190 let col = VectorColumnBatch {
191 policy: &self.policy,
192 embeddings,
193 };
194 self.write_multi(batch, &[col])
195 }
196
197 pub fn write_single_pass(
212 &self,
213 batch: &RecordBatch,
214 embeddings: &[Vec<f32>],
215 ) -> AilakeResult<Bytes> {
216 let col = VectorColumnBatch {
217 policy: &self.policy,
218 embeddings,
219 };
220 self.write_multi_single_pass(batch, &[col])
221 }
222
223 pub fn write_multi_single_pass(
225 &self,
226 batch: &RecordBatch,
227 columns: &[VectorColumnBatch<'_>],
228 ) -> AilakeResult<Bytes> {
229 use ailake_core::AilakeError;
230
231 if columns.is_empty() {
232 return Err(AilakeError::InvalidArgument(
233 "write_multi_single_pass requires at least one vector column".into(),
234 ));
235 }
236
237 let primary = &columns[0];
238 let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
239
240 let (parquet_bytes, record_count) =
243 parquet_writer.write_batch(batch, primary.embeddings)?;
244 let footer_start = parquet_footer_start(&parquet_bytes)?;
245
246 let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
249 let mut current_offset = footer_start as u64;
250 for col in columns.iter() {
251 let section = build_ailk_section(
252 col.policy,
253 col.embeddings,
254 record_count,
255 current_offset,
256 &self.index_type,
257 self.shared_codebook.as_deref(),
258 )?;
259 current_offset += section.len() as u64;
260 ailk_sections.push(section);
261 }
262
263 let total_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
266 let mut out = BytesMut::with_capacity(parquet_bytes.len() + total_ailk);
267 out.put_slice(&parquet_bytes[..footer_start]);
268 for section in ailk_sections {
269 out.put(section);
270 }
271 out.put_slice(&parquet_bytes[footer_start..]);
272
273 Ok(out.freeze())
274 }
275
276 pub fn write_multi(
285 &self,
286 batch: &RecordBatch,
287 columns: &[VectorColumnBatch<'_>],
288 ) -> AilakeResult<Bytes> {
289 use ailake_core::AilakeError;
290
291 if columns.is_empty() {
292 return Err(AilakeError::InvalidArgument(
293 "write_multi requires at least one vector column".into(),
294 ));
295 }
296
297 let primary = &columns[0];
298 let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
299
300 let (parquet_v1, record_count) = parquet_writer.write_batch(batch, primary.embeddings)?;
302 let footer_start = parquet_footer_start(&parquet_v1)?;
303
304 let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
306 let mut kv_owned: Vec<(String, String)> = Vec::with_capacity(columns.len());
307 let mut current_offset = footer_start as u64;
308
309 for (i, col) in columns.iter().enumerate() {
310 let section = build_ailk_section(
311 col.policy,
312 col.embeddings,
313 record_count,
314 current_offset,
315 &self.index_type,
316 self.shared_codebook.as_deref(),
317 )?;
318 let kv_key = if i == 0 {
319 "ailake.footer_offset".to_string()
320 } else {
321 format!("ailake.{}.footer_offset", col.policy.column_name)
322 };
323 kv_owned.push((kv_key, current_offset.to_string()));
324 current_offset += section.len() as u64;
325 ailk_sections.push(section);
326 }
327
328 let fts_blob: Option<Vec<u8>> = self.prebuilt_fts_blob.clone().or_else(|| {
331 self.fts_config
332 .as_ref()
333 .and_then(|cfg| ailake_fts::build_fts_blob_from_batch(cfg, batch).ok())
334 });
335 let fts_section: Option<Bytes> = fts_blob.map(|blob| {
336 let blob_len = blob.len() as u64;
338 let mut sec = BytesMut::with_capacity(AILK_FTS_HEADER_SIZE + blob.len());
339 sec.put_slice(&AILK_FTS_MAGIC);
340 sec.put_slice(&1u16.to_le_bytes()); sec.put_slice(&0u16.to_le_bytes()); sec.put_slice(&blob_len.to_le_bytes());
343 sec.put_slice(&blob);
344 sec.freeze()
345 });
346
347 if let Some(ref sec) = fts_section {
348 let fts_abs_offset = current_offset; kv_owned.push((KV_FTS_OFFSET.to_string(), fts_abs_offset.to_string()));
350 let _ = sec; }
352
353 let kv_refs: Vec<(&str, &str)> = kv_owned
359 .iter()
360 .map(|(k, v)| (k.as_str(), v.as_str()))
361 .collect();
362 let (parquet_v2, _) =
363 parquet_writer.write_batch_with_kv(batch, primary.embeddings, &kv_refs)?;
364 let footer_start_v2 = parquet_footer_start(&parquet_v2)?;
365 debug_assert_eq!(
366 footer_start, footer_start_v2,
367 "footer_start must be stable across KV injection (row groups unchanged)"
368 );
369
370 let total_vector_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
372 let total_fts: usize = fts_section.as_ref().map_or(0, |s| s.len());
373 let footer_len_v2 = parquet_v2.len() - footer_start_v2;
374 let total = footer_start + total_vector_ailk + total_fts + footer_len_v2;
375 let mut out = BytesMut::with_capacity(total);
376 out.put_slice(&parquet_v1[..footer_start]);
377 drop(parquet_v1);
378 for section in ailk_sections {
379 out.put(section);
380 }
381 if let Some(fts_sec) = fts_section {
382 out.put(fts_sec);
383 }
384 out.put_slice(&parquet_v2[footer_start_v2..]);
385
386 Ok(out.freeze())
387 }
388}
389
390fn build_ailk_section_from_index_bytes(
393 policy: &VectorStoragePolicy,
394 embeddings: &[Vec<f32>],
395 record_count: u64,
396 ailk_abs_offset: u64,
397 index_bytes: &[u8],
398 flags: u16,
399) -> AilakeResult<Bytes> {
400 let norm_storage: Vec<Vec<f32>>;
401 let (emb_for_centroid, centroid_metric) =
402 if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
403 norm_storage = embeddings
404 .iter()
405 .map(|v| ailake_vec::normalize_l2(v))
406 .collect();
407 (
408 norm_storage.as_slice(),
409 ailake_core::VectorMetric::NormalizedCosine,
410 )
411 } else {
412 (embeddings, policy.metric)
413 };
414
415 let centroid = compute_centroid_and_radius(emb_for_centroid, centroid_metric);
416 let centroid_bytes = encode_centroid(¢roid);
417
418 let centroid_offset = HEADER_SIZE as u64;
419 let centroid_len = centroid_bytes.len() as u64;
420 let index_offset_in_ailk = centroid_offset + centroid_len;
421 let index_len = index_bytes.len() as u64;
422 let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
423
424 let header = AilakeHeader {
425 format_version: AILAKE_FORMAT_VERSION,
426 flags,
427 dim: policy.dim,
428 precision: Precision::from(policy.precision),
429 distance_metric: DistanceMetric::from(policy.metric),
430 record_count,
431 centroid_offset,
432 centroid_len,
433 hnsw_offset: index_offset_in_ailk,
434 hnsw_len: index_len,
435 };
436 let trailer = AilakeTrailer {
437 footer_offset: ailk_abs_offset,
438 footer_len: ailk_total_len,
439 format_version: AILAKE_FORMAT_VERSION,
440 flags,
441 };
442
443 let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
444 buf.put_slice(&header.to_bytes());
445 buf.put_slice(¢roid_bytes);
446 buf.put_slice(index_bytes);
447 buf.put_slice(&trailer.to_bytes());
448 Ok(buf.freeze())
449}
450
451fn build_ailk_section(
453 policy: &VectorStoragePolicy,
454 embeddings: &[Vec<f32>],
455 record_count: u64,
456 ailk_abs_offset: u64,
457 index_type: &IndexType,
458 shared_codebook: Option<&IvfPqCodebook>,
459) -> AilakeResult<Bytes> {
460 let norm_storage: Vec<Vec<f32>>;
463 let (embeddings, hnsw_metric) =
464 if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
465 norm_storage = embeddings
466 .iter()
467 .map(|v| ailake_vec::normalize_l2(v))
468 .collect();
469 (
470 norm_storage.as_slice(),
471 ailake_core::VectorMetric::NormalizedCosine,
472 )
473 } else {
474 (embeddings, policy.metric)
475 };
476
477 let centroid: Centroid = compute_centroid_and_radius(embeddings, hnsw_metric);
478 let centroid_bytes = encode_centroid(¢roid);
479
480 let resolved: IndexType;
482 let index_type = if matches!(index_type, IndexType::Auto) {
483 let profile = ailake_index::HardwareProfile::detect();
484 resolved = if profile.recommend_ivf_pq(embeddings.len()) {
485 IndexType::IvfPq(ailake_index::IvfPqConfig::for_dataset(
486 policy.dim as usize,
487 embeddings.len(),
488 ))
489 } else {
490 IndexType::Hnsw(ailake_index::HnswConfig::default())
491 };
492 &resolved
493 } else {
494 index_type
495 };
496
497 let (index_bytes, flags) = match index_type {
498 IndexType::Hnsw(hnsw_config) => {
499 let config = HnswConfig {
501 m: policy.hnsw_m.map(|v| v as usize).unwrap_or(hnsw_config.m),
502 ef_construction: policy
503 .hnsw_ef_construction
504 .map(|v| v as usize)
505 .unwrap_or(hnsw_config.ef_construction),
506 max_elements: hnsw_config.max_elements,
507 };
508 let mut builder = HnswBuilder::new(policy.dim, hnsw_metric, config);
509 for (i, v) in embeddings.iter().enumerate() {
510 builder.insert(RowId::new(i as u64), v.clone());
511 }
512 let index = builder.build();
513 (HnswSerializer::to_bytes(&index)?, 0u16)
514 }
515 IndexType::IvfPq(ivf_config) => {
516 let row_ids: Vec<RowId> = (0..embeddings.len() as u64).map(RowId::new).collect();
517 let index = if let Some(cb) = shared_codebook {
518 IvfPqIndex::build_with_codebook(&row_ids, embeddings, cb)?
519 } else {
520 ailake_index::IvfPqIndex::train(
521 &row_ids,
522 embeddings,
523 policy.metric,
524 ivf_config.clone(),
525 )?
526 };
527 (IvfPqSerializer::to_bytes(&index)?, FLAG_INDEX_IVF_PQ)
528 }
529 IndexType::Auto => unreachable!("Auto resolved above"),
530 };
531
532 let centroid_offset = HEADER_SIZE as u64;
533 let centroid_len = centroid_bytes.len() as u64;
534 let index_offset_in_ailk = centroid_offset + centroid_len;
535 let index_len = index_bytes.len() as u64;
536 let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
537
538 let header = AilakeHeader {
539 format_version: AILAKE_FORMAT_VERSION,
540 flags,
541 dim: policy.dim,
542 precision: Precision::from(policy.precision),
543 distance_metric: DistanceMetric::from(policy.metric),
544 record_count,
545 centroid_offset,
546 centroid_len,
547 hnsw_offset: index_offset_in_ailk,
548 hnsw_len: index_len,
549 };
550 let trailer = AilakeTrailer {
551 footer_offset: ailk_abs_offset,
552 footer_len: ailk_total_len,
553 format_version: AILAKE_FORMAT_VERSION,
554 flags,
555 };
556
557 let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
558 buf.put_slice(&header.to_bytes());
559 buf.put_slice(¢roid_bytes);
560 buf.put_slice(&index_bytes);
561 buf.put_slice(&trailer.to_bytes());
562 Ok(buf.freeze())
563}
564
565fn encode_centroid(c: &Centroid) -> Vec<u8> {
566 let mut bytes = Vec::with_capacity(c.values.len() * 4 + 4);
567 for &v in &c.values {
568 bytes.extend_from_slice(&v.to_le_bytes());
569 }
570 bytes.extend_from_slice(&c.radius.to_le_bytes());
571 bytes
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577 use ailake_core::{VectorMetric, VectorPrecision};
578 use arrow_array::{Int32Array, RecordBatch};
579 use arrow_schema::{DataType, Field, Schema};
580 use std::sync::Arc;
581
582 fn make_policy(dim: u32) -> VectorStoragePolicy {
583 VectorStoragePolicy {
584 column_name: "embedding".to_string(),
585 dim,
586 metric: VectorMetric::Cosine,
587 precision: VectorPrecision::F16,
588 pq: None,
589 keep_raw_for_reranking: true,
590 pre_normalize: false,
591 hnsw_m: None,
592 hnsw_ef_construction: None,
593 ivf_residual: false,
594 embedding_model: None,
595 modality: None,
596 partition_by: None,
597 partition_value: None,
598 partition_column_type: None,
599 partition_fields: vec![],
600 }
601 }
602
603 #[test]
604 fn write_single_pass_valid_parquet_and_ailk() {
605 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
606 let batch =
607 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
608 let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
609
610 let writer = AilakeFileWriter::new(make_policy(4));
611 let file = writer.write_single_pass(&batch, &embs).unwrap();
612
613 assert_eq!(&file[..4], b"PAR1");
615 assert_eq!(&file[file.len() - 4..], b"PAR1");
616 assert!(file.windows(4).any(|w| w == b"AILK"));
618 }
619
620 #[test]
621 fn write_single_pass_reader_bootstrap_from_trailer() {
622 use crate::reader::AilakeFileReader;
623
624 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
625 let batch =
626 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![10, 20, 30]))])
627 .unwrap();
628 let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
629
630 let writer = AilakeFileWriter::new(make_policy(4));
631 let file_bytes = writer.write_single_pass(&batch, &embs).unwrap();
632
633 let reader = AilakeFileReader::new(file_bytes, "embedding", 4);
636 assert!(
637 reader.is_ailake_file(),
638 "single-pass file must be recognised as AI-Lake file via trailer bootstrap"
639 );
640 let header = reader.read_header().expect("must read AILK header");
641 assert_eq!(header.dim, 4);
642 assert_eq!(header.record_count, 3);
643 }
644
645 #[test]
646 fn write_and_write_single_pass_same_index() {
647 use crate::reader::AilakeFileReader;
650
651 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
652 let batch = RecordBatch::try_new(
653 schema,
654 vec![Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5]))],
655 )
656 .unwrap();
657 let embs: Vec<Vec<f32>> = vec![
658 vec![1.0, 0.0, 0.0, 0.0],
659 vec![0.0, 1.0, 0.0, 0.0],
660 vec![0.0, 0.0, 1.0, 0.0],
661 vec![0.0, 0.0, 0.0, 1.0],
662 vec![0.5, 0.5, 0.0, 0.0],
663 ];
664 let policy = make_policy(4);
665 let writer = AilakeFileWriter::new(policy);
666
667 let bytes_two_pass = writer.write(&batch, &embs).unwrap();
668 let bytes_single_pass = writer.write_single_pass(&batch, &embs).unwrap();
669
670 let query = vec![1.0f32, 0.0, 0.0, 0.0];
671
672 let reader_tp = AilakeFileReader::new(bytes_two_pass, "embedding", 4);
673 let reader_sp = AilakeFileReader::new(bytes_single_pass, "embedding", 4);
674
675 let idx_tp = reader_tp.load_index().unwrap();
676 let idx_sp = reader_sp.load_index().unwrap();
677
678 let res_tp = idx_tp.search(&query, 1, 50);
679 let res_sp = idx_sp.search(&query, 1, 50);
680
681 assert_eq!(res_tp[0].0, res_sp[0].0, "nearest neighbour must match");
682 }
683
684 #[test]
685 fn write_ends_with_par1() {
686 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
687 let batch =
688 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
689 let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
690
691 let writer = AilakeFileWriter::new(make_policy(4));
692 let file = writer.write(&batch, &embs).unwrap();
693
694 assert_eq!(&file[file.len() - 4..], b"PAR1");
695 assert_eq!(&file[..4], b"PAR1");
696 assert!(file.windows(4).any(|w| w == b"AILK"));
697 }
698
699 #[test]
700 fn write_multi_two_columns() {
701 use ailake_core::{VectorMetric, VectorPrecision};
702
703 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
704 let batch =
705 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
706
707 let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
708 let ctx_embs: Vec<Vec<f32>> = (0..3).map(|i| vec![0.0, i as f32, 0.0, 0.0]).collect();
709
710 let policy1 = make_policy(4);
711 let policy2 = VectorStoragePolicy {
712 column_name: "context_embedding".to_string(),
713 dim: 4,
714 metric: VectorMetric::Cosine,
715 precision: VectorPrecision::F16,
716 pq: None,
717 keep_raw_for_reranking: true,
718 pre_normalize: false,
719 hnsw_m: None,
720 hnsw_ef_construction: None,
721 ivf_residual: false,
722 embedding_model: None,
723 modality: None,
724 partition_by: None,
725 partition_value: None,
726 partition_column_type: None,
727 partition_fields: vec![],
728 };
729
730 let writer = AilakeFileWriter::new(policy1.clone());
731 let file = writer
732 .write_multi(
733 &batch,
734 &[
735 VectorColumnBatch {
736 policy: &policy1,
737 embeddings: &embs,
738 },
739 VectorColumnBatch {
740 policy: &policy2,
741 embeddings: &ctx_embs,
742 },
743 ],
744 )
745 .unwrap();
746
747 assert_eq!(&file[..4], b"PAR1");
749 assert_eq!(&file[file.len() - 4..], b"PAR1");
750 let ailk_count = file.windows(4).filter(|w| *w == b"AILK").count();
752 assert!(
753 ailk_count >= 2,
754 "expected >= 2 AILK markers, got {ailk_count}"
755 );
756 }
757}