1use crate::footer::Precision;
3use ailake_core::{AilakeError, AilakeResult, Centroid, VectorMetric};
4use ailake_index::{AnyIndex, HnswIndex, IvfPqSerializer, MmapLoader};
5use ailake_parquet::ParquetVectorReader;
6use arrow_array::RecordBatch;
7use bytes::Bytes;
8
9use crate::footer::{
10 parquet_footer_start, AilakeHeader, AilakeTrailer, DistanceMetric, AILK_FTS_HEADER_SIZE,
11 AILK_FTS_MAGIC, FLAG_INDEX_IVF_PQ, HEADER_SIZE, KV_FTS_OFFSET, TRAILER_SIZE,
12};
13
14pub struct AilakeFileReader {
15 bytes: Bytes,
16 vector_column: String,
17 #[allow(dead_code)]
18 dim: u32,
19}
20
21impl AilakeFileReader {
22 pub fn new(bytes: Bytes, vector_column: &str, dim: u32) -> Self {
23 Self {
24 bytes,
25 vector_column: vector_column.to_string(),
26 dim,
27 }
28 }
29
30 pub fn ailk_offset(&self) -> AilakeResult<u64> {
36 let reader = ParquetVectorReader::new(self.bytes.clone(), &self.vector_column);
37 if let Some(val) = reader.kv_metadata("ailake.footer_offset")? {
38 return val.parse::<u64>().map_err(|_| AilakeError::NotAnAilakeFile);
39 }
40 self.ailk_offset_from_trailer()
41 }
42
43 pub fn ailk_offset_for_column(&self, column: &str) -> AilakeResult<u64> {
50 let reader = ParquetVectorReader::new(self.bytes.clone(), column);
51 let col_key = format!("ailake.{column}.footer_offset");
52 if let Some(val) = reader.kv_metadata(&col_key)? {
53 return val.parse::<u64>().map_err(|_| AilakeError::NotAnAilakeFile);
54 }
55 if let Some(val) = reader.kv_metadata("ailake.footer_offset")? {
56 return val.parse::<u64>().map_err(|_| AilakeError::NotAnAilakeFile);
57 }
58 self.ailk_offset_from_trailer()
59 }
60
61 fn ailk_offset_from_trailer(&self) -> AilakeResult<u64> {
68 let buf = self.bytes.as_ref();
69 let footer_start = parquet_footer_start(buf)?;
70 if footer_start < TRAILER_SIZE {
71 return Err(AilakeError::NotAnAilakeFile);
72 }
73 let trailer_start = footer_start - TRAILER_SIZE;
74 let trailer_bytes: &[u8; TRAILER_SIZE] = buf[trailer_start..footer_start]
75 .try_into()
76 .map_err(|_| AilakeError::NotAnAilakeFile)?;
77 let trailer = AilakeTrailer::from_bytes(trailer_bytes)?;
78 Ok(trailer.footer_offset)
79 }
80
81 pub fn is_ailake_file(&self) -> bool {
83 self.ailk_offset().is_ok()
84 }
85
86 pub fn read_header(&self) -> AilakeResult<AilakeHeader> {
88 self.read_header_at_offset(self.ailk_offset()?)
89 }
90
91 pub fn read_header_for_column(&self, column: &str) -> AilakeResult<AilakeHeader> {
96 self.read_header_at_offset(self.ailk_offset_for_column(column)?)
97 }
98
99 fn read_header_at_offset(&self, offset: u64) -> AilakeResult<AilakeHeader> {
100 let offset = offset as usize;
101 if offset + HEADER_SIZE > self.bytes.len() {
102 return Err(AilakeError::NotAnAilakeFile);
103 }
104 let header_bytes: &[u8; HEADER_SIZE] = self.bytes[offset..offset + HEADER_SIZE]
105 .try_into()
106 .map_err(|_| AilakeError::NotAnAilakeFile)?;
107 AilakeHeader::from_bytes(header_bytes)
108 }
109
110 pub fn get_centroid(&self) -> AilakeResult<Centroid> {
112 let ailk_start = self.ailk_offset()? as usize;
113 let header = self.read_header()?;
114 let centroid_start = ailk_start + header.centroid_offset as usize;
115 let centroid_end = centroid_start + header.centroid_len as usize;
116
117 if centroid_end > self.bytes.len() {
118 return Err(AilakeError::NotAnAilakeFile);
119 }
120
121 let centroid_data = &self.bytes[centroid_start..centroid_end];
122 let dim = header.dim as usize;
123 let expected_len = dim.checked_mul(4).and_then(|v| v.checked_add(4)).ok_or(
124 AilakeError::InvalidCentroidLength {
125 expected_dim: header.dim,
126 actual: centroid_data.len(),
127 },
128 )?;
129 if centroid_data.len() != expected_len {
130 return Err(AilakeError::InvalidCentroidLength {
131 expected_dim: header.dim,
132 actual: centroid_data.len(),
133 });
134 }
135
136 let values: Vec<f32> = centroid_data[..dim * 4]
137 .chunks_exact(4)
138 .map(|b| {
139 f32::from_le_bytes(
140 b.try_into()
141 .expect("chunks_exact(4) guarantees 4-byte slices"),
142 )
143 })
144 .collect();
145 let radius = f32::from_le_bytes(
146 centroid_data[dim * 4..]
147 .try_into()
148 .expect("invariant: validated len == dim*4 + 4 above"),
149 );
150 let metric = distance_metric_to_vector_metric(header.distance_metric);
151
152 Ok(Centroid {
153 values,
154 radius,
155 metric,
156 })
157 }
158
159 pub fn load_index(&self) -> AilakeResult<HnswIndex> {
161 self.load_index_for_column(&self.vector_column.clone())
162 }
163
164 pub fn load_index_for_column(&self, column: &str) -> AilakeResult<HnswIndex> {
169 let ailk_start = self.ailk_offset_for_column(column)? as usize;
170
171 if ailk_start + HEADER_SIZE > self.bytes.len() {
172 return Err(AilakeError::NotAnAilakeFile);
173 }
174 let header_bytes: &[u8; HEADER_SIZE] = self.bytes[ailk_start..ailk_start + HEADER_SIZE]
175 .try_into()
176 .map_err(|_| AilakeError::NotAnAilakeFile)?;
177 let header = AilakeHeader::from_bytes(header_bytes)?;
178
179 let hnsw_start = ailk_start + header.hnsw_offset as usize;
180 let hnsw_end = hnsw_start + header.hnsw_len as usize;
181
182 if hnsw_end > self.bytes.len() {
183 return Err(AilakeError::NotAnAilakeFile);
184 }
185 let mut idx = MmapLoader::from_bytes(&self.bytes[hnsw_start..hnsw_end])?;
186 if header.precision == Precision::F16 {
187 idx.quantize_to_f16();
188 }
189 Ok(idx)
190 }
191
192 pub fn load_any_index(&self) -> AilakeResult<AnyIndex> {
194 self.load_any_index_for_column(&self.vector_column.clone())
195 }
196
197 pub fn load_any_index_for_column(&self, column: &str) -> AilakeResult<AnyIndex> {
199 let ailk_start = self.ailk_offset_for_column(column)? as usize;
200
201 if ailk_start + HEADER_SIZE > self.bytes.len() {
202 return Err(AilakeError::NotAnAilakeFile);
203 }
204 let header_bytes: &[u8; HEADER_SIZE] = self.bytes[ailk_start..ailk_start + HEADER_SIZE]
205 .try_into()
206 .map_err(|_| AilakeError::NotAnAilakeFile)?;
207 let header = AilakeHeader::from_bytes(header_bytes)?;
208
209 let index_start = ailk_start + header.hnsw_offset as usize;
210 let index_end = index_start + header.hnsw_len as usize;
211
212 if index_end > self.bytes.len() {
213 return Err(AilakeError::NotAnAilakeFile);
214 }
215 let index_bytes = &self.bytes[index_start..index_end];
216
217 if header.flags & FLAG_INDEX_IVF_PQ != 0 {
218 let idx = IvfPqSerializer::from_bytes(index_bytes)?;
219 Ok(AnyIndex::IvfPq(idx))
220 } else {
221 let mut idx = MmapLoader::from_bytes(index_bytes)?;
222 if header.precision == Precision::F16 {
223 idx.quantize_to_f16();
224 }
225 Ok(AnyIndex::Hnsw(idx))
226 }
227 }
228
229 pub fn read_parquet(&self) -> AilakeResult<(RecordBatch, Vec<Vec<f32>>)> {
232 let reader = ParquetVectorReader::new(self.bytes.clone(), &self.vector_column);
233 reader.read_all()
234 }
235
236 pub fn load_fts_blob(&self) -> AilakeResult<Option<Bytes>> {
241 let pq_reader = ParquetVectorReader::new(self.bytes.clone(), &self.vector_column);
242 let fts_offset_str = match pq_reader.kv_metadata(KV_FTS_OFFSET)? {
243 Some(s) => s,
244 None => return Ok(None),
245 };
246 let fts_abs: usize = fts_offset_str
247 .parse::<u64>()
248 .map_err(|e| {
249 AilakeError::Fts(format!(
250 "invalid FTS section offset '{fts_offset_str}': {e}"
251 ))
252 })?
253 .try_into()
254 .map_err(|_| AilakeError::Fts("FTS section offset exceeds address space".into()))?;
255
256 if fts_abs + AILK_FTS_HEADER_SIZE > self.bytes.len() {
258 return Err(AilakeError::Fts("AILK_FTS header out of bounds".into()));
259 }
260 let hdr = &self.bytes[fts_abs..fts_abs + AILK_FTS_HEADER_SIZE];
261 if hdr[0..4] != AILK_FTS_MAGIC {
262 return Err(AilakeError::Fts(format!(
263 "bad AILK_FTS magic: {:?}",
264 &hdr[0..4]
265 )));
266 }
267 let blob_len = u64::from_le_bytes(hdr[8..16].try_into().unwrap()) as usize;
268 let blob_start = fts_abs + AILK_FTS_HEADER_SIZE;
269 let blob_end = blob_start + blob_len;
270 if blob_end > self.bytes.len() {
271 return Err(AilakeError::Fts("AILK_FTS blob out of bounds".into()));
272 }
273 Ok(Some(self.bytes.slice(blob_start..blob_end)))
274 }
275
276 pub fn verify_integrity(&self) -> AilakeResult<()> {
278 let header = self.read_header()?;
279 let index = self.load_index()?;
280 let reader = ParquetVectorReader::new(self.bytes.clone(), &self.vector_column);
281 let parquet_count = reader.record_count()?;
282
283 if parquet_count != index.node_count() {
284 return Err(AilakeError::RowCountMismatch {
285 parquet: parquet_count,
286 hnsw: index.node_count(),
287 });
288 }
289 if parquet_count != header.record_count {
290 return Err(AilakeError::RowCountMismatch {
291 parquet: parquet_count,
292 hnsw: header.record_count,
293 });
294 }
295 Ok(())
296 }
297}
298
299fn distance_metric_to_vector_metric(dm: DistanceMetric) -> VectorMetric {
300 match dm {
301 DistanceMetric::Cosine => VectorMetric::Cosine,
302 DistanceMetric::Euclidean => VectorMetric::Euclidean,
303 DistanceMetric::DotProduct => VectorMetric::DotProduct,
304 DistanceMetric::NormalizedCosine => VectorMetric::NormalizedCosine,
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::writer::AilakeFileWriter;
312 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
313 use arrow_array::{Int32Array, RecordBatch};
314 use arrow_schema::{DataType, Field, Schema};
315 use std::sync::Arc;
316
317 fn make_policy(dim: u32) -> VectorStoragePolicy {
318 VectorStoragePolicy {
319 column_name: "embedding".to_string(),
320 dim,
321 metric: VectorMetric::Cosine,
322 precision: VectorPrecision::F16,
323 pq: None,
324 keep_raw_for_reranking: true,
325 pre_normalize: false,
326 hnsw_m: None,
327 hnsw_ef_construction: None,
328 ivf_residual: false,
329 embedding_model: None,
330 modality: None,
331 partition_by: None,
332 partition_value: None,
333 partition_column_type: None,
334 partition_fields: vec![],
335 }
336 }
337
338 fn write_file(rows: usize, dim: u32) -> Bytes {
339 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
340 let ids: Vec<i32> = (0..rows as i32).collect();
341 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
342 let embs: Vec<Vec<f32>> = (0..rows)
343 .map(|i| {
344 let mut v = vec![0.0f32; dim as usize];
345 v[i % dim as usize] = 1.0;
346 v
347 })
348 .collect();
349 AilakeFileWriter::new(make_policy(dim))
350 .write(&batch, &embs)
351 .unwrap()
352 }
353
354 #[test]
355 fn is_ailake_file() {
356 let file = write_file(3, 4);
357 let reader = AilakeFileReader::new(file, "embedding", 4);
358 assert!(reader.is_ailake_file());
359 }
360
361 #[test]
362 fn integrity_check_passes() {
363 let file = write_file(10, 8);
364 let reader = AilakeFileReader::new(file, "embedding", 8);
365 reader.verify_integrity().unwrap();
366 }
367
368 #[test]
369 fn centroid_has_correct_dim() {
370 let file = write_file(5, 4);
371 let reader = AilakeFileReader::new(file, "embedding", 4);
372 let centroid = reader.get_centroid().unwrap();
373 assert_eq!(centroid.values.len(), 4);
374 }
375
376 #[test]
377 fn search_finds_nearest() {
378 let dim = 4u32;
379 let file = write_file(4, dim);
380 let reader = AilakeFileReader::new(file, "embedding", dim);
381 let index = reader.load_index().unwrap();
382 let query = vec![1.0f32, 0.0, 0.0, 0.0];
383 let results = index.search(&query, 1, 50);
384 assert_eq!(results.len(), 1);
385 assert_eq!(results[0].0, ailake_core::RowId::new(0));
386 }
387
388 #[test]
389 fn parquet_read_returns_tabular_data() {
390 let file = write_file(3, 4);
391 let reader = AilakeFileReader::new(file, "embedding", 4);
392 let (batch, embs) = reader.read_parquet().unwrap();
393 assert_eq!(batch.num_rows(), 3);
394 assert_eq!(embs.len(), 3);
395 }
396}