Skip to main content

ailake_query/
index_loader.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Range-GET fast path: load just the HNSW/IVF-PQ index of a file's
3//! *primary* vector column, without ever downloading the tabular/vector data
4//! section. Used by `scanner.rs::search_one_file` when the query needs
5//! nothing else from the file (no rerank, hybrid, `score_fn`, equality
6//! deletes, or `column_filter` — see Fase 16 in `CLAUDE.md`).
7//!
8//! Offset discovery reads the `ailake.footer_offset` Parquet KV entry (via
9//! `ailake_parquet::ParquetVectorReader::kv_metadata`) from a speculative
10//! tail `get_range` of just the footer thrift, with an exact one-shot
11//! follow-up on the rare miss — never the whole file. This is the KV path,
12//! not `AilakeFileReader`'s `AilakeTrailer` bootstrap fallback: multi-column
13//! files (`AilakeFileWriter::write_multi`) write one self-pointing
14//! `AilakeTrailer` per column section, so the trailer physically nearest
15//! EOF belongs to whichever column was written *last* — not necessarily the
16//! primary one. The KV entry has no such ambiguity: `write_multi` always
17//! tags column `0` (primary) with the plain `ailake.footer_offset` key
18//! regardless of how many columns follow it.
19//!
20//! The AILK header and HNSW blob are sliced straight out of the same
21//! speculative tail buffer whenever they happen to fall inside it (common
22//! for small-to-medium files, or any file where the AILK section is smaller
23//! than the tail window) instead of issuing separate `get_range` calls —
24//! without this, those calls would frequently *re-fetch* bytes the tail read
25//! already has, since the tail window's whole purpose is landing on the
26//! footer thrift, which sits immediately after the AILK section.
27//!
28//! Only the primary column is supported — a secondary/multimodal column's
29//! offset lives behind `ailake.<col>.footer_offset` instead, and this module
30//! doesn't thread a column name through the request, since callers already
31//! gate on `vector_column == primary_col` before invoking it (see
32//! `scanner.rs::search_one_file`).
33//!
34//! Every failure here (parse error, missing KV, out-of-range GET) is
35//! recoverable by the caller falling back to the existing full-file GET —
36//! this module never turns a query that would have succeeded into one that
37//! fails; at worst it wastes one or two small extra `get_range` calls.
38
39use std::sync::Arc;
40
41use ailake_core::{AilakeError, AilakeResult};
42use ailake_file::{parquet_footer_start, AilakeHeader, Precision, FLAG_INDEX_IVF_PQ, HEADER_SIZE};
43use ailake_index::{AnyIndex, IvfPqSerializer, MmapLoader};
44use ailake_parquet::ParquetVectorReader;
45use ailake_store::Store;
46use bytes::Bytes;
47
48/// Speculative tail-`get_range` size for footer-thrift discovery. Sized
49/// generously for the KV metadata + schema + per-row-group column statistics
50/// (Fase 5's `column_stats`) that make up the footer thrift; `resolve_ailk_offset`
51/// falls back to one exact follow-up `get_range` on the rare miss (very wide
52/// schema, many row groups).
53const SPECULATIVE_TAIL_BYTES: u64 = 65_536;
54
55/// Bytes already fetched during offset discovery, kept around so the header
56/// and (if it fits) the HNSW blob reads can slice out of them directly
57/// instead of re-fetching overlapping bytes from the store.
58struct TailBuf {
59    /// Absolute file offset of `bytes[0]`.
60    base: u64,
61    bytes: Bytes,
62}
63
64impl TailBuf {
65    /// Returns the requested absolute `[start, end)` range, sliced from this
66    /// buffer if it's fully contained, without touching the store.
67    fn slice(&self, start: u64, end: u64) -> Option<Bytes> {
68        if start < self.base || end > self.base + self.bytes.len() as u64 {
69            return None;
70        }
71        let rel_start = (start - self.base) as usize;
72        let rel_end = (end - self.base) as usize;
73        Some(self.bytes.slice(rel_start..rel_end))
74    }
75}
76
77/// Loads the primary column's HNSW/IVF-PQ index via small `get_range` calls
78/// (footer-thrift discovery, 64-byte AILK header, index blob) instead of one
79/// whole-file `get`. Returns `Err` for any file this fast path can't handle —
80/// callers must treat that as "use the full-file path", not as a hard failure.
81pub async fn load_primary_index(store: &Arc<dyn Store>, path: &str) -> AilakeResult<AnyIndex> {
82    let file_size = store.file_size(path).await?;
83    let (ailk_offset, tail) = resolve_ailk_offset(store, path, file_size).await?;
84
85    let header_end = ailk_offset
86        .checked_add(HEADER_SIZE as u64)
87        .ok_or(AilakeError::NotAnAilakeFile)?;
88    let header_bytes = match tail.slice(ailk_offset, header_end) {
89        Some(b) => b,
90        None => store.get_range(path, ailk_offset..header_end).await?,
91    };
92    let header_arr: [u8; HEADER_SIZE] = header_bytes
93        .as_ref()
94        .try_into()
95        .map_err(|_| AilakeError::NotAnAilakeFile)?;
96    let header = AilakeHeader::from_bytes(&header_arr)?;
97
98    let index_start = ailk_offset
99        .checked_add(header.hnsw_offset)
100        .ok_or(AilakeError::NotAnAilakeFile)?;
101    let index_end = index_start
102        .checked_add(header.hnsw_len)
103        .ok_or(AilakeError::NotAnAilakeFile)?;
104    let index_bytes = match tail.slice(index_start, index_end) {
105        Some(b) => b,
106        None => store.get_range(path, index_start..index_end).await?,
107    };
108
109    if header.flags & FLAG_INDEX_IVF_PQ != 0 {
110        let idx = IvfPqSerializer::from_bytes(&index_bytes)?;
111        Ok(AnyIndex::IvfPq(idx))
112    } else {
113        let mut idx = MmapLoader::from_bytes(&index_bytes)?;
114        if header.precision == Precision::F16 {
115            idx.quantize_to_f16();
116        }
117        Ok(AnyIndex::Hnsw(idx))
118    }
119}
120
121/// Returns the absolute byte offset of the primary AILK section (read from
122/// the `ailake.footer_offset` Parquet KV entry) plus the tail buffer it was
123/// found in, so the caller can reuse those same bytes for the header/HNSW
124/// reads when they happen to fall inside it.
125///
126/// `parquet`'s own metadata parser (wrapped by `ParquetVectorReader::kv_metadata`)
127/// only ever reads backward from the end of the buffer it's given; as long as
128/// that buffer's own end aligns with the file's true EOF and is large enough
129/// to hold the full footer thrift, feeding it a tail slice (not the whole
130/// file) parses correctly — no need to fetch anything before the footer.
131async fn resolve_ailk_offset(
132    store: &Arc<dyn Store>,
133    path: &str,
134    file_size: u64,
135) -> AilakeResult<(u64, TailBuf)> {
136    let guess_len = SPECULATIVE_TAIL_BYTES.min(file_size);
137    let mut base = file_size - guess_len;
138    let mut tail = store.get_range(path, base..file_size).await?;
139
140    if parquet_footer_start(&tail).is_err() {
141        // Speculative window too small for the real footer thrift (or a
142        // corrupt/non-AI-Lake file — `kv_metadata` below will error out
143        // either way). We know the exact footer_thrift_len from the tail's
144        // own trailing 8 bytes, so the follow-up fetch is sized precisely —
145        // no further guessing, no retry loop.
146        if tail.len() < 8 {
147            return Err(AilakeError::NotAnAilakeFile);
148        }
149        let footer_thrift_len =
150            u32::from_le_bytes(tail[tail.len() - 8..tail.len() - 4].try_into().unwrap()) as u64;
151        let exact_len = (8 + footer_thrift_len).min(file_size);
152        base = file_size - exact_len;
153        tail = store.get_range(path, base..file_size).await?;
154    }
155
156    let reader = ParquetVectorReader::new(tail.clone(), "");
157    let ailk_offset = match reader.kv_metadata("ailake.footer_offset")? {
158        Some(v) => v.parse::<u64>().map_err(|_| AilakeError::NotAnAilakeFile)?,
159        None => return Err(AilakeError::NotAnAilakeFile),
160    };
161    Ok((ailk_offset, TailBuf { base, bytes: tail }))
162}