Skip to main content

ailake_file/
writer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use 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/// Which index algorithm to embed in the AILK section.
18#[derive(Debug, Clone)]
19pub enum IndexType {
20    /// HNSW (default). Best recall for in-memory workloads.
21    Hnsw(HnswConfig),
22    /// IVF-PQ. Best for S3: 10-100x smaller index, sequential inverted-list reads.
23    IvfPq(IvfPqConfig),
24    /// Detect hardware at write time and pick the best index automatically.
25    ///
26    /// Chooses IVF-PQ when a GPU or ≥8 CPU cores are available AND the dataset
27    /// has ≥5 000 vectors. Falls back to HNSW otherwise (local/low-power hardware).
28    Auto,
29}
30
31impl Default for IndexType {
32    fn default() -> Self {
33        IndexType::Hnsw(HnswConfig::default())
34    }
35}
36
37/// One vector column to embed in a multi-column write.
38pub 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    /// Pre-trained shared codebook. When set, skips k-means for IVF-PQ builds.
47    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    /// Use a pre-trained IVF-PQ codebook instead of running k-means.
60    /// Shards built from the same codebook produce comparable ADC distances.
61    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    /// Use `IndexType::Auto`: detect GPU / CPU cores at write time and pick the
82    /// best index. Equivalent to `.with_index_type(IndexType::Auto)`.
83    pub fn with_auto_index(mut self) -> Self {
84        self.index_type = IndexType::Auto;
85        self
86    }
87
88    /// Write RecordBatch + embeddings as plain Parquet, with no AILK section.
89    ///
90    /// Used by `TableWriter::write_batch_deferred()` to persist data immediately
91    /// while the HNSW index is built asynchronously in the background.
92    /// The resulting file is a valid Parquet readable by any standard reader,
93    /// but `AilakeFileReader::is_ailake_file()` returns false until the HNSW
94    /// section is appended by the background indexing task.
95    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    /// Write RecordBatch + embeddings into a single AI-Lake file.
106    ///
107    /// Layout:
108    ///   [PAR1][row groups][AILK header+centroid+HNSW+trailer][Parquet footer][footer_len][PAR1]
109    ///
110    /// Standard Parquet readers find PAR1 at the end, read the footer, skip directly to row
111    /// group offsets. The AILK section sits between row groups and footer and is never touched.
112    /// AI-Lake readers find the AILK section via `ailake.footer_offset` in the Parquet footer KV.
113    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    /// Write RecordBatch + multiple vector columns into a single AI-Lake file.
122    ///
123    /// Each column gets its own AILK section appended sequentially before the Parquet footer.
124    /// Offsets are recorded in Parquet KV metadata:
125    ///   - Primary (first) column: `ailake.footer_offset`
126    ///   - Additional columns: `ailake.{column_name}.footer_offset`
127    ///
128    /// Readers use the column-specific KV key to locate the right AILK section.
129    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        // Pass 1 — write Parquet without KV to measure the data section size.
146        let (parquet_v1, record_count) = parquet_writer.write_batch(batch, primary.embeddings)?;
147        let footer_start = parquet_footer_start(&parquet_v1)?;
148
149        // Build all AILK sections sequentially; track running absolute offset.
150        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        // Pass 2 — write Parquet with all AILK offset KVs embedded.
174        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        // Splice: [PAR1 + row groups] + [all AILK sections] + [Parquet footer + PAR1]
183        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
196/// Build a complete AILK section (header + centroid + index + trailer) for one vector column.
197fn 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    // Normalize to unit L2 when pre_normalize is set.
206    // Enables the NormalizedCosine fast path: 1-dot(a,b) instead of full cosine.
207    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(&centroid);
224
225    // Resolve Auto to a concrete variant before matching.
226    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            // Policy-level M/ef_construction override the IndexType defaults when set.
245            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(&centroid_bytes);
305    buf.put_slice(&index_bytes);
306    buf.put_slice(&trailer.to_bytes());
307    Ok(buf.freeze())
308}
309
310/// Returns the byte offset in `buf` where the Parquet footer thrift starts.
311/// Layout of buf tail: [...footer_thrift...][footer_len u32 LE][PAR1 4 bytes]
312fn 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: false,
353            pre_normalize: false,
354            hnsw_m: None,
355            hnsw_ef_construction: None,
356        }
357    }
358
359    #[test]
360    fn write_ends_with_par1() {
361        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
362        let batch =
363            RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
364        let embs: Vec<Vec<f32>> = (0..3).map(|_| vec![0.1, 0.2, 0.3, 0.4]).collect();
365
366        let writer = AilakeFileWriter::new(make_policy(4));
367        let file = writer.write(&batch, &embs).unwrap();
368
369        assert_eq!(&file[file.len() - 4..], b"PAR1");
370        assert_eq!(&file[..4], b"PAR1");
371        assert!(file.windows(4).any(|w| w == b"AILK"));
372    }
373
374    #[test]
375    fn write_multi_two_columns() {
376        use ailake_core::{VectorMetric, VectorPrecision};
377
378        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
379        let batch =
380            RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))]).unwrap();
381
382        let embs: Vec<Vec<f32>> = (0..3).map(|i| vec![i as f32, 0.0, 0.0, 0.0]).collect();
383        let ctx_embs: Vec<Vec<f32>> = (0..3).map(|i| vec![0.0, i as f32, 0.0, 0.0]).collect();
384
385        let policy1 = make_policy(4);
386        let policy2 = VectorStoragePolicy {
387            column_name: "context_embedding".to_string(),
388            dim: 4,
389            metric: VectorMetric::Cosine,
390            precision: VectorPrecision::F16,
391            pq: None,
392            keep_raw_for_reranking: false,
393            pre_normalize: false,
394            hnsw_m: None,
395            hnsw_ef_construction: None,
396        };
397
398        let writer = AilakeFileWriter::new(policy1.clone());
399        let file = writer
400            .write_multi(
401                &batch,
402                &[
403                    VectorColumnBatch {
404                        policy: &policy1,
405                        embeddings: &embs,
406                    },
407                    VectorColumnBatch {
408                        policy: &policy2,
409                        embeddings: &ctx_embs,
410                    },
411                ],
412            )
413            .unwrap();
414
415        // Valid Parquet envelope
416        assert_eq!(&file[..4], b"PAR1");
417        assert_eq!(&file[file.len() - 4..], b"PAR1");
418        // Two AILK sections — magic appears at least twice
419        let ailk_count = file.windows(4).filter(|w| *w == b"AILK").count();
420        assert!(
421            ailk_count >= 2,
422            "expected >= 2 AILK markers, got {ailk_count}"
423        );
424    }
425}