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}