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 AilakeHeader, AilakeTrailer, DistanceMetric, Precision, AILAKE_FORMAT_VERSION,
14 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_parquet_only(
96 &self,
97 batch: &RecordBatch,
98 embeddings: &[Vec<f32>],
99 ) -> AilakeResult<Bytes> {
100 let parquet_writer = ParquetVectorWriter::new(self.policy.clone());
101 let (bytes, _) = parquet_writer.write_batch(batch, embeddings)?;
102 Ok(bytes)
103 }
104
105 pub fn write(&self, batch: &RecordBatch, embeddings: &[Vec<f32>]) -> AilakeResult<Bytes> {
114 let col = VectorColumnBatch {
115 policy: &self.policy,
116 embeddings,
117 };
118 self.write_multi(batch, &[col])
119 }
120
121 pub fn write_multi(
130 &self,
131 batch: &RecordBatch,
132 columns: &[VectorColumnBatch<'_>],
133 ) -> AilakeResult<Bytes> {
134 use ailake_core::AilakeError;
135
136 if columns.is_empty() {
137 return Err(AilakeError::InvalidArgument(
138 "write_multi requires at least one vector column".into(),
139 ));
140 }
141
142 let primary = &columns[0];
143 let parquet_writer = ParquetVectorWriter::new(primary.policy.clone());
144
145 let (parquet_v1, record_count) = parquet_writer.write_batch(batch, primary.embeddings)?;
147 let footer_start = parquet_footer_start(&parquet_v1)?;
148
149 let mut ailk_sections: Vec<Bytes> = Vec::with_capacity(columns.len());
151 let mut kv_owned: Vec<(String, String)> = Vec::with_capacity(columns.len());
152 let mut current_offset = footer_start as u64;
153
154 for (i, col) in columns.iter().enumerate() {
155 let section = build_ailk_section(
156 col.policy,
157 col.embeddings,
158 record_count,
159 current_offset,
160 &self.index_type,
161 self.shared_codebook.as_deref(),
162 )?;
163 let kv_key = if i == 0 {
164 "ailake.footer_offset".to_string()
165 } else {
166 format!("ailake.{}.footer_offset", col.policy.column_name)
167 };
168 kv_owned.push((kv_key, current_offset.to_string()));
169 current_offset += section.len() as u64;
170 ailk_sections.push(section);
171 }
172
173 let kv_refs: Vec<(&str, &str)> = kv_owned
175 .iter()
176 .map(|(k, v)| (k.as_str(), v.as_str()))
177 .collect();
178 let (parquet_v2, _) =
179 parquet_writer.write_batch_with_kv(batch, primary.embeddings, &kv_refs)?;
180 let footer_start_v2 = parquet_footer_start(&parquet_v2)?;
181
182 let total_ailk: usize = ailk_sections.iter().map(|s| s.len()).sum();
184 let total = footer_start_v2 + total_ailk + (parquet_v2.len() - footer_start_v2);
185 let mut out = BytesMut::with_capacity(total);
186 out.put_slice(&parquet_v2[..footer_start_v2]);
187 for section in ailk_sections {
188 out.put(section);
189 }
190 out.put_slice(&parquet_v2[footer_start_v2..]);
191
192 Ok(out.freeze())
193 }
194}
195
196fn build_ailk_section(
198 policy: &VectorStoragePolicy,
199 embeddings: &[Vec<f32>],
200 record_count: u64,
201 ailk_abs_offset: u64,
202 index_type: &IndexType,
203 shared_codebook: Option<&IvfPqCodebook>,
204) -> AilakeResult<Bytes> {
205 let norm_storage: Vec<Vec<f32>>;
208 let (embeddings, hnsw_metric) =
209 if policy.pre_normalize && policy.metric == ailake_core::VectorMetric::Cosine {
210 norm_storage = embeddings
211 .iter()
212 .map(|v| ailake_vec::normalize_l2(v))
213 .collect();
214 (
215 norm_storage.as_slice(),
216 ailake_core::VectorMetric::NormalizedCosine,
217 )
218 } else {
219 (embeddings, policy.metric)
220 };
221
222 let centroid: Centroid = compute_centroid_and_radius(embeddings, hnsw_metric);
223 let centroid_bytes = encode_centroid(¢roid);
224
225 let resolved: IndexType;
227 let index_type = if matches!(index_type, IndexType::Auto) {
228 let profile = ailake_index::HardwareProfile::detect();
229 resolved = if profile.recommend_ivf_pq(embeddings.len()) {
230 IndexType::IvfPq(ailake_index::IvfPqConfig::for_dataset(
231 policy.dim as usize,
232 embeddings.len(),
233 ))
234 } else {
235 IndexType::Hnsw(ailake_index::HnswConfig::default())
236 };
237 &resolved
238 } else {
239 index_type
240 };
241
242 let (index_bytes, flags) = match index_type {
243 IndexType::Hnsw(hnsw_config) => {
244 let config = HnswConfig {
246 m: policy.hnsw_m.map(|v| v as usize).unwrap_or(hnsw_config.m),
247 ef_construction: policy
248 .hnsw_ef_construction
249 .map(|v| v as usize)
250 .unwrap_or(hnsw_config.ef_construction),
251 max_elements: hnsw_config.max_elements,
252 };
253 let mut builder = HnswBuilder::new(policy.dim, hnsw_metric, config);
254 for (i, v) in embeddings.iter().enumerate() {
255 builder.insert(RowId::new(i as u64), v.clone());
256 }
257 let index = builder.build();
258 (HnswSerializer::to_bytes(&index)?, 0u16)
259 }
260 IndexType::IvfPq(ivf_config) => {
261 let row_ids: Vec<RowId> = (0..embeddings.len() as u64).map(RowId::new).collect();
262 let index = if let Some(cb) = shared_codebook {
263 IvfPqIndex::build_with_codebook(&row_ids, embeddings, cb)?
264 } else {
265 ailake_index::IvfPqIndex::train(
266 &row_ids,
267 embeddings,
268 policy.metric,
269 ivf_config.clone(),
270 )?
271 };
272 (IvfPqSerializer::to_bytes(&index)?, FLAG_INDEX_IVF_PQ)
273 }
274 IndexType::Auto => unreachable!("Auto resolved above"),
275 };
276
277 let centroid_offset = HEADER_SIZE as u64;
278 let centroid_len = centroid_bytes.len() as u64;
279 let index_offset_in_ailk = centroid_offset + centroid_len;
280 let index_len = index_bytes.len() as u64;
281 let ailk_total_len = HEADER_SIZE as u64 + centroid_len + index_len + TRAILER_SIZE as u64;
282
283 let header = AilakeHeader {
284 format_version: AILAKE_FORMAT_VERSION,
285 flags,
286 dim: policy.dim,
287 precision: Precision::from(policy.precision),
288 distance_metric: DistanceMetric::from(policy.metric),
289 record_count,
290 centroid_offset,
291 centroid_len,
292 hnsw_offset: index_offset_in_ailk,
293 hnsw_len: index_len,
294 };
295 let trailer = AilakeTrailer {
296 footer_offset: ailk_abs_offset,
297 footer_len: ailk_total_len,
298 format_version: AILAKE_FORMAT_VERSION,
299 flags,
300 };
301
302 let mut buf = BytesMut::with_capacity(ailk_total_len as usize);
303 buf.put_slice(&header.to_bytes());
304 buf.put_slice(¢roid_bytes);
305 buf.put_slice(&index_bytes);
306 buf.put_slice(&trailer.to_bytes());
307 Ok(buf.freeze())
308}
309
310fn parquet_footer_start(buf: &[u8]) -> AilakeResult<usize> {
313 use ailake_core::AilakeError;
314 let len = buf.len();
315 if len < 8 {
316 return Err(AilakeError::Parquet("file too small".into()));
317 }
318 if &buf[len - 4..] != b"PAR1" {
319 return Err(AilakeError::Parquet("missing PAR1 footer magic".into()));
320 }
321 let footer_thrift_len = u32::from_le_bytes(buf[len - 8..len - 4].try_into().unwrap()) as usize;
322 let start = len
323 .checked_sub(8 + footer_thrift_len)
324 .ok_or_else(|| AilakeError::Parquet("footer length overflow".into()))?;
325 Ok(start)
326}
327
328fn encode_centroid(c: &Centroid) -> Vec<u8> {
329 let mut bytes = Vec::with_capacity(c.values.len() * 4 + 4);
330 for &v in &c.values {
331 bytes.extend_from_slice(&v.to_le_bytes());
332 }
333 bytes.extend_from_slice(&c.radius.to_le_bytes());
334 bytes
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use ailake_core::{VectorMetric, VectorPrecision};
341 use arrow_array::{Int32Array, RecordBatch};
342 use arrow_schema::{DataType, Field, Schema};
343 use std::sync::Arc;
344
345 fn make_policy(dim: u32) -> VectorStoragePolicy {
346 VectorStoragePolicy {
347 column_name: "embedding".to_string(),
348 dim,
349 metric: VectorMetric::Cosine,
350 precision: VectorPrecision::F16,
351 pq: None,
352 keep_raw_for_reranking: true,
353 pre_normalize: false,
354 hnsw_m: None,
355 hnsw_ef_construction: None,
356 ivf_residual: false,
357 embedding_model: None,
358 modality: None,
359 }
360 }
361
362 #[test]
363 fn write_ends_with_par1() {
364 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
365 let batch =
366 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
367 let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
368
369 let writer = AilakeFileWriter::new(make_policy(4));
370 let file = writer.write(&batch, &embs).unwrap();
371
372 assert_eq!(&file[file.len() - 4..], b"PAR1");
373 assert_eq!(&file[..4], b"PAR1");
374 assert!(file.windows(4).any(|w| w == b"AILK"));
375 }
376
377 #[test]
378 fn write_multi_two_columns() {
379 use ailake_core::{VectorMetric, VectorPrecision};
380
381 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
382 let batch =
383 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
384
385 let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
386 let ctx_embs: Vec<Vec<f32>> = (0..3).map(|i| vec![0.0, i as f32, 0.0, 0.0]).collect();
387
388 let policy1 = make_policy(4);
389 let policy2 = VectorStoragePolicy {
390 column_name: "context_embedding".to_string(),
391 dim: 4,
392 metric: VectorMetric::Cosine,
393 precision: VectorPrecision::F16,
394 pq: None,
395 keep_raw_for_reranking: true,
396 pre_normalize: false,
397 hnsw_m: None,
398 hnsw_ef_construction: None,
399 ivf_residual: false,
400 embedding_model: None,
401 modality: None,
402 };
403
404 let writer = AilakeFileWriter::new(policy1.clone());
405 let file = writer
406 .write_multi(
407 &batch,
408 &[
409 VectorColumnBatch {
410 policy: &policy1,
411 embeddings: &embs,
412 },
413 VectorColumnBatch {
414 policy: &policy2,
415 embeddings: &ctx_embs,
416 },
417 ],
418 )
419 .unwrap();
420
421 assert_eq!(&file[..4], b"PAR1");
423 assert_eq!(&file[file.len() - 4..], b"PAR1");
424 let ailk_count = file.windows(4).filter(|w| *w == b"AILK").count();
426 assert!(
427 ailk_count >= 2,
428 "expected >= 2 AILK markers, got {ailk_count}"
429 );
430 }
431}