Skip to main content

ailake_query/
scanner.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::sync::Arc;
3
4use futures::future::try_join_all;
5use rayon::prelude::*;
6use tracing::{debug, error, warn};
7
8use ailake_catalog::{
9    CatalogProvider, DataFileEntry, IndexStatus, SchemaField, TableIdent, TableMetadata,
10};
11use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, RowId, VectorMetric};
12use ailake_file::AilakeFileReader;
13use ailake_index::AnyIndex;
14use ailake_store::Store;
15use ailake_vec::exact_distance;
16use arrow_array::{Array, RecordBatch};
17use bytes::Bytes;
18
19use crate::equality_delete::EqualityDeleteFilter;
20use crate::pruner::{BloomPruner, VectorPruner};
21use crate::schema_filler::SchemaFiller;
22
23/// Injectable per-result scoring function for hybrid ranking.
24///
25/// Called after HNSW retrieval with the HNSW distance and a single-row
26/// `RecordBatch` containing all Parquet columns for that result. Returns a
27/// replacement score (lower = better rank, same convention as distance).
28///
29/// Typical use: combine HNSW distance with recency and importance signals
30/// from the `episodic_columns` for agent memory tables:
31///
32/// ```rust,no_run
33/// use ailake_core::{hybrid_score, episodic_columns};
34/// use ailake_query::scanner::ScoreFn;
35/// use arrow_array::{RecordBatch, cast::AsArray};
36/// use arrow_array::types::Float32Type;
37///
38/// let score_fn = ScoreFn::new(|distance, row| {
39///     let recency = row
40///         .column_by_name(episodic_columns::RECENCY_WEIGHT)
41///         .and_then(|c| c.as_primitive_opt::<Float32Type>())
42///         .and_then(|a| a.iter().next().flatten())
43///         .unwrap_or(1.0);
44///     let importance = row
45///         .column_by_name(episodic_columns::IMPORTANCE_SCORE)
46///         .and_then(|c| c.as_primitive_opt::<Float32Type>())
47///         .and_then(|a| a.iter().next().flatten())
48///         .unwrap_or(1.0);
49///     hybrid_score(distance, recency, importance)
50/// });
51/// ```
52#[allow(clippy::type_complexity)]
53pub struct ScoreFn(pub std::sync::Arc<dyn Fn(f32, &RecordBatch) -> f32 + Send + Sync>);
54
55impl ScoreFn {
56    pub fn new(f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static) -> Self {
57        Self(std::sync::Arc::new(f))
58    }
59
60    #[inline]
61    pub fn call(&self, distance: f32, row: &RecordBatch) -> f32 {
62        (self.0)(distance, row)
63    }
64}
65
66impl Clone for ScoreFn {
67    fn clone(&self) -> Self {
68        Self(std::sync::Arc::clone(&self.0))
69    }
70}
71
72impl std::fmt::Debug for ScoreFn {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str("ScoreFn(<fn>)")
75    }
76}
77
78#[derive(Debug, Clone)]
79pub struct SearchConfig {
80    pub top_k: usize,
81    pub ef_search: usize,
82    /// Maximum distance from query to file centroid edge for a file to be searched.
83    /// Files where `distance(query, centroid) - radius > pruning_threshold` are skipped.
84    /// Set to `f32::INFINITY` to disable pruning (scan all files).
85    pub pruning_threshold: f32,
86    /// When `Some(factor)`, fetch `top_k * factor` candidates from the HNSW index and
87    /// rerank them using exact F32 distances before truncating to `top_k`.
88    /// Corrects the approximation error introduced by PQ-compressed HNSW distances.
89    /// `None` (default) disables reranking.
90    pub rerank_factor: Option<usize>,
91    /// Hybrid BM25+vector search configuration.
92    ///
93    /// When set, the pipeline loads global IDF stats from the table's BM25 stats file,
94    /// fetches a larger candidate pool from HNSW (`candidate_pool` or `10 * top_k`),
95    /// scores each candidate with BM25 against `query_text`, then fuses vector distance
96    /// and BM25 score via RRF (default) or linear combination.
97    ///
98    /// The BM25 stats file (`metadata/ailake_bm25_stats.bin`) is populated automatically
99    /// by `TableWriter` when `bm25_text_column` is configured. If absent, pure vector
100    /// distances are used (BM25 scores default to 0).
101    pub hybrid: Option<crate::bm25::HybridConfig>,
102    /// Optional scoring function for hybrid ranking.
103    ///
104    /// When set, the search pipeline reads the Parquet row for each HNSW
105    /// candidate and calls `score_fn(distance, &single_row_batch)`. The
106    /// returned value replaces `distance` in `SearchResult` and determines
107    /// final ranking (lower = better).
108    ///
109    /// If `rerank_factor` is also set, `score_fn` receives the exact
110    /// (non-approximated) distance from the reranking step.
111    ///
112    /// Use `ScoreFn::new(|d, row| ...)` to construct. See `ScoreFn` docs
113    /// for an example using `hybrid_score` with episodic memory columns.
114    pub score_fn: Option<ScoreFn>,
115    /// Partition filter: only search files whose `DataFileEntry::partition_value`
116    /// matches this string. `None` searches all files (no partition pruning).
117    /// Set to `agent_id` in Agent.recall() for per-agent isolated search.
118    pub partition_filter: Option<String>,
119    /// Column-level predicate pushed down to the Parquet read of each surviving
120    /// file, via `AilakeFileReader::read_parquet_filtered` (row-group statistics
121    /// skip + exact Arrow `RowFilter` — see `ailake-parquet::ParquetVectorReader`).
122    /// Applies only where a Parquet read already happens (flat-scan fallback,
123    /// reranking, hybrid, `score_fn`, or equality-delete row checks) — it does
124    /// not gate which files survive centroid/partition pruning, and it isn't
125    /// applied against columns only materialized later by `SchemaFiller`
126    /// (schema-evolution defaults). `None` disables pushdown (default).
127    pub column_filter: Option<ailake_core::ColumnFilter>,
128}
129
130impl Default for SearchConfig {
131    fn default() -> Self {
132        Self {
133            top_k: 10,
134            ef_search: 50,
135            pruning_threshold: f32::INFINITY,
136            rerank_factor: None,
137            score_fn: None,
138            partition_filter: None,
139            hybrid: None,
140            column_filter: None,
141        }
142    }
143}
144
145impl SearchConfig {
146    pub fn with_column_filter(mut self, filter: ailake_core::ColumnFilter) -> Self {
147        self.column_filter = Some(filter);
148        self
149    }
150
151    pub fn with_pruning(mut self, threshold: f32) -> Self {
152        self.pruning_threshold = threshold;
153        self
154    }
155
156    pub fn with_reranking(mut self, factor: usize) -> Self {
157        self.rerank_factor = Some(factor);
158        self
159    }
160
161    pub fn with_score_fn(
162        mut self,
163        f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static,
164    ) -> Self {
165        self.score_fn = Some(ScoreFn::new(f));
166        self
167    }
168
169    pub fn with_hybrid(mut self, cfg: crate::bm25::HybridConfig) -> Self {
170        self.hybrid = Some(cfg);
171        self
172    }
173}
174
175#[derive(Debug)]
176pub struct SearchResult {
177    pub row_id: RowId,
178    pub distance: f32,
179    pub file_path: String,
180}
181
182/// Search across all files in the latest snapshot, with geometric pruning.
183///
184/// Flow:
185/// 1. Load file list from catalog (includes centroid metadata)
186/// 2. Prune files whose centroid + radius cannot contain a result within `pruning_threshold`
187/// 3. For surviving files: load bytes, deserialize HNSW, run top-k search
188/// 4. Global merge of all per-file top-k lists, return global top-k
189pub async fn search(
190    table: &TableIdent,
191    query: &[f32],
192    config: SearchConfig,
193    vector_column: &str,
194    dim: u32,
195    catalog: Arc<dyn CatalogProvider>,
196    store: Arc<dyn Store>,
197) -> AilakeResult<Vec<SearchResult>> {
198    // Get file metadata (includes centroid info) without reading any data files
199    let all_files = catalog.list_files(table, None).await?;
200
201    // Determine vector metric from table metadata for correct distance computation
202    let table_meta = catalog.load_table(table).await?;
203
204    // Validate query dim against the column's stored dim.
205    // Primary column: use `ailake.vector-dim`. Secondary columns: use `ailake.dim-<col>`.
206    // Skip validation when the column has no stored dim (e.g. old tables written before
207    // multi-column support).
208    let primary_col = table_meta
209        .properties
210        .get("ailake.vector-column")
211        .map(String::as_str)
212        .unwrap_or("");
213    let stored_dim_key = if vector_column == primary_col {
214        "ailake.vector-dim".to_string()
215    } else {
216        format!("ailake.dim-{vector_column}")
217    };
218    if let Some(table_dim_str) = table_meta.properties.get(&stored_dim_key) {
219        if let Ok(table_dim) = table_dim_str.parse::<u32>() {
220            let query_dim = query.len() as u32;
221            if query_dim != table_dim {
222                let table_model = table_meta
223                    .properties
224                    .get(EmbeddingModelInfo::property_key())
225                    .cloned()
226                    .unwrap_or_else(|| format!("dim={}", table_dim));
227                return Err(AilakeError::ModelMismatch {
228                    table_model,
229                    table_dim,
230                    batch_model: format!("query dim={}", query_dim),
231                    batch_dim: query_dim,
232                });
233            }
234        }
235    }
236
237    // Metric: prefer per-column `ailake.metric-<col>`, fall back to primary metric.
238    let metric_key = if vector_column == primary_col {
239        "ailake.vector-metric".to_string()
240    } else {
241        format!("ailake.metric-{vector_column}")
242    };
243    let metric = parse_metric(
244        table_meta
245            .properties
246            .get(&metric_key)
247            .or_else(|| table_meta.properties.get("ailake.vector-metric"))
248            .map(String::as_str)
249            .unwrap_or("cosine"),
250    );
251
252    // Partition pruning: skip files not belonging to the requested partition value.
253    let all_files = if let Some(ref pv) = config.partition_filter {
254        let before = all_files.len();
255        let filtered: Vec<_> = all_files
256            .into_iter()
257            .filter(|f| f.partition_value.as_deref() == Some(pv.as_str()))
258            .collect();
259        debug!(
260            "ailake: partition pruning '{}' — {}/{} files survive",
261            pv,
262            filtered.len(),
263            before
264        );
265        filtered
266    } else {
267        all_files
268    };
269
270    // Geometric pruning: skip files whose centroid is too far from the query
271    let total_files = all_files.len();
272    let surviving_files = VectorPruner::prune(all_files, query, metric, config.pruning_threshold);
273    debug!(
274        "ailake: geometric pruning — {}/{} files survive (threshold={})",
275        surviving_files.len(),
276        total_files,
277        config.pruning_threshold
278    );
279
280    // Phase F — Bloom pruning: for hybrid queries, load per-file Bloom filters from
281    // the Puffin stats file and skip files where no query term can be present.
282    let surviving_files = if let Some(ref h) = config.hybrid {
283        let bloom_map = load_bloom_map(&table_meta, store.as_ref()).await;
284        if !bloom_map.is_empty() {
285            BloomPruner::prune(surviving_files, &h.query_text, &bloom_map)
286        } else {
287            surviving_files
288        }
289    } else {
290        surviving_files
291    };
292
293    // Phase H: load equality delete filter for this snapshot.
294    // Reads delete manifests from the catalog and downloads each equality delete Avro file.
295    // Empty filter is a no-op. On error: warn and continue with empty filter (data visible).
296    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
297        Ok(edfs) if !edfs.is_empty() => {
298            match EqualityDeleteFilter::from_files(&store, &edfs).await {
299                Ok(f) => f,
300                Err(e) => {
301                    warn!("ailake: equality delete filter build failed: {e} — rows may appear");
302                    EqualityDeleteFilter::empty()
303                }
304            }
305        }
306        _ => EqualityDeleteFilter::empty(),
307    };
308
309    // Compute candidate pool: hybrid needs a larger pool for BM25 re-ranking.
310    let candidate_k = match (&config.hybrid, config.rerank_factor) {
311        (Some(h), rf) => {
312            let pool = h.candidate_pool.unwrap_or(config.top_k * 10);
313            pool.max(rf.map_or(config.top_k, |f| f * config.top_k))
314        }
315        (None, Some(factor)) => config.top_k * factor,
316        (None, None) => config.top_k,
317    };
318
319    let use_hybrid = config.hybrid.is_some();
320
321    // Load BM25 stats from the table's stats file when hybrid search is active.
322    let bm25_stats: Option<crate::bm25::IdfStats> = if let Some(ref h) = config.hybrid {
323        if h.text_columns.is_empty() {
324            None
325        } else {
326            let stats_path = table_meta
327                .properties
328                .get(crate::bm25::BM25_STATS_PATH_PROP)
329                .map(String::as_str)
330                .unwrap_or(crate::bm25::BM25_STATS_FILE);
331            match store.get(stats_path).await {
332                Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).ok(),
333                Err(_) => {
334                    debug!(
335                        "ailake: BM25 stats not found at '{}' — falling back to empty corpus IDF",
336                        stats_path
337                    );
338                    None
339                }
340            }
341        }
342    } else {
343        None
344    };
345
346    // raw_candidates: (row_id, vec_dist, file_path, bm25_text) for hybrid re-ranking.
347    // Only populated when use_hybrid = true; otherwise all_results is populated directly.
348    let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
349    let mut all_results: Vec<SearchResult> = Vec::new();
350
351    // Observability for the flat-scan fallback below: `deferred` counts files still
352    // being indexed by our own deferred-write path (expected, transient). `unexpected`
353    // counts files with no AI-Lake index that are NOT in that state — most likely
354    // rewritten by a generic Iceberg engine (Spark/Trino OPTIMIZE, DuckDB) with no
355    // knowledge of AI-Lake. Those files still return correct results (flat scan is
356    // exact), just O(N) instead of O(log N), and silently forever unless recompacted.
357    let mut flat_scan_deferred = 0usize;
358    let mut flat_scan_unexpected = 0usize;
359
360    // Fetch + search each surviving file concurrently instead of one at a time —
361    // the dominant cost per file is a network round-trip (`store.get`), so this
362    // overlaps their latencies instead of serializing them. `try_join_all` runs
363    // all of them concurrently on the current task (no OS-thread parallelism,
364    // no `tokio::spawn`); at the post-pruning scale this operates on (dozens of
365    // files, see `VectorPruner` — geometric pruning is designed to cut a
366    // 10k-file table down to ~50-100 survivors before this point), that's the
367    // right trade-off: no bound needed, and no per-task spawn overhead.
368    let outcomes: Vec<FileSearchOutcome> = try_join_all(surviving_files.iter().map(|file_entry| {
369        search_one_file(
370            file_entry,
371            query,
372            candidate_k,
373            metric,
374            &table_meta,
375            &config,
376            vector_column,
377            dim,
378            &store,
379            &eq_del_filter,
380            use_hybrid,
381        )
382    }))
383    .await?;
384
385    for outcome in outcomes {
386        match outcome.flat_scan {
387            Some(FlatScanKind::Deferred) => flat_scan_deferred += 1,
388            Some(FlatScanKind::Unexpected) => flat_scan_unexpected += 1,
389            None => {}
390        }
391        all_results.extend(outcome.results);
392        raw_candidates.extend(outcome.candidates);
393    }
394
395    if flat_scan_unexpected > 0 {
396        warn!(
397            "ailake: search degraded — {}/{} files scanned without an AI-Lake index \
398             (unexpected — likely external rewrites; {} more in expected deferred-indexing \
399             state). Run compaction to restore O(log N) search on affected files",
400            flat_scan_unexpected,
401            surviving_files.len(),
402            flat_scan_deferred
403        );
404    } else if flat_scan_deferred > 0 {
405        debug!(
406            "ailake: search — {}/{} files scanned via flat fallback (deferred indexing)",
407            flat_scan_deferred,
408            surviving_files.len()
409        );
410    }
411
412    // Hybrid BM25 fusion: applied after all HNSW candidates are collected.
413    if let Some(ref h) = config.hybrid {
414        let empty_stats = crate::bm25::IdfStats::default();
415        let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
416        let scorer = crate::bm25::BM25Scorer::new(stats);
417
418        // Compute BM25 scores before sorting so they stay positionally aligned.
419        let bm25_scores_pre: Vec<f32> = raw_candidates
420            .iter()
421            .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
422            .collect();
423
424        // Zip BM25 scores into candidates so they sort together — avoids index mismatch
425        // when raw_candidates is reordered by distance below.
426        let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
427            raw_candidates.into_iter().zip(bm25_scores_pre).collect();
428        candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
429        let n = candidates_with_bm25.len();
430
431        let vec_ranks: Vec<usize> = (0..n).collect();
432
433        // Rank by BM25 score descending using post-sort aligned scores.
434        let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
435            .iter()
436            .map(|(_, b)| *b)
437            .enumerate()
438            .collect();
439        bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
440        let mut bm25_rank_of = vec![0usize; n];
441        for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
442            bm25_rank_of[*idx] = rank;
443        }
444
445        use crate::bm25::{linear_score, rrf_score, HybridFusion};
446
447        let fused: Vec<f32> = match h.fusion {
448            HybridFusion::Rrf => vec_ranks
449                .iter()
450                .enumerate()
451                .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
452                .collect(),
453            HybridFusion::Linear => {
454                let min_d = candidates_with_bm25
455                    .iter()
456                    .map(|(r, _)| r.1)
457                    .fold(f32::INFINITY, f32::min);
458                let max_d = candidates_with_bm25
459                    .iter()
460                    .map(|(r, _)| r.1)
461                    .fold(f32::NEG_INFINITY, f32::max);
462                let min_b = candidates_with_bm25
463                    .iter()
464                    .map(|(_, b)| *b)
465                    .fold(f32::INFINITY, f32::min);
466                let max_b = candidates_with_bm25
467                    .iter()
468                    .map(|(_, b)| *b)
469                    .fold(f32::NEG_INFINITY, f32::max);
470                candidates_with_bm25
471                    .iter()
472                    .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
473                    .collect()
474            }
475        };
476
477        for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
478            all_results.push(SearchResult {
479                row_id,
480                distance: fused[i],
481                file_path,
482            });
483        }
484
485        // For RRF: lower (more negative) = better; for Linear: lower = better. Same convention.
486        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
487    } else {
488        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
489    }
490
491    all_results.truncate(config.top_k);
492    Ok(all_results)
493}
494
495/// Why this file fell back to a flat (exact, O(N)) scan instead of HNSW —
496/// carried out of `search_one_file` so the caller can aggregate counts across
497/// all concurrently-searched files for the post-loop `warn!`/`debug!` summary.
498enum FlatScanKind {
499    /// Background index build still in progress (deferred write) — expected, transient.
500    Deferred,
501    /// No AI-Lake index and NOT in the deferred-indexing state — most likely an
502    /// external engine (Spark/Trino OPTIMIZE, DuckDB) rewrote the file, or an
503    /// internal inconsistency. Results are still correct, just degraded.
504    Unexpected,
505}
506
507/// Per-file result of `search_one_file`, folded into `search()`'s accumulators
508/// after all files have been searched concurrently.
509#[derive(Default)]
510struct FileSearchOutcome {
511    /// Populated when hybrid search is off.
512    results: Vec<SearchResult>,
513    /// Populated when hybrid search is on: (row_id, vector distance, file path, text).
514    candidates: Vec<(RowId, f32, String, String)>,
515    flat_scan: Option<FlatScanKind>,
516}
517
518/// Fetches, index-searches (or flat-scans), and filters a single file — the
519/// per-file body of `search()`'s main loop, extracted so it can be run
520/// concurrently across files via `try_join_all`. Pure with respect to the
521/// caller's accumulators: everything it would have mutated in place (result
522/// lists, flat-scan counters) comes back in the returned `FileSearchOutcome`
523/// instead, so concurrent invocations never share mutable state.
524#[allow(clippy::too_many_arguments)]
525async fn search_one_file(
526    file_entry: &DataFileEntry,
527    query: &[f32],
528    candidate_k: usize,
529    metric: VectorMetric,
530    table_meta: &TableMetadata,
531    config: &SearchConfig,
532    vector_column: &str,
533    dim: u32,
534    store: &Arc<dyn Store>,
535    eq_del_filter: &EqualityDeleteFilter,
536    use_hybrid: bool,
537) -> AilakeResult<FileSearchOutcome> {
538    let mut outcome = FileSearchOutcome::default();
539
540    // V3 Deletion Vector: fetch bitmap once per file (range GET from Puffin .dvd).
541    // Independent of the file's own bytes — fetched first so both the range-GET
542    // fast path below and the full-file fallback path can use it.
543    // None for V2 tables or V3 files with no deletes. On fetch error: warn + continue
544    // without mask (surfacing deleted rows is safer than hard-failing the search).
545    let dv_bitmap: Option<roaring::RoaringBitmap> = if let Some(ref dv) = file_entry.deletion_vector
546    {
547        match crate::dv::load_deletion_vector(store, dv).await {
548            Ok(bm) => {
549                debug!(
550                    "ailake: DV loaded ({} deletions) for {}",
551                    bm.len(),
552                    file_entry.path
553                );
554                Some(bm)
555            }
556            Err(e) => {
557                warn!(
558                    "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
559                    file_entry.path
560                );
561                None
562            }
563        }
564    } else {
565        None
566    };
567
568    // Range-GET fast path (Fase 16): when this query needs nothing from the
569    // file besides the index itself, skip the whole-file GET and range-GET
570    // just the HNSW/IVF-PQ blob (typically 10-20% of the file's total size —
571    // see CLAUDE.md §6). Every condition here is knowable from the catalog
572    // manifest / query config alone, with no file bytes fetched yet. Limited
573    // to the primary vector column and `IndexStatus::Ready` — see
574    // `index_loader`'s module doc for why secondary columns can't use it, and
575    // `crate::index_loader::load_primary_index`'s contract: any failure there
576    // is safe to treat as "use the full-file path", never a hard error.
577    let primary_col = table_meta
578        .properties
579        .get("ailake.vector-column")
580        .map(String::as_str)
581        .unwrap_or("");
582    let fast_path_eligible = config.column_filter.is_none()
583        && config.rerank_factor.is_none()
584        && config.score_fn.is_none()
585        && !use_hybrid
586        && eq_del_filter.is_empty()
587        && file_entry.index_status == IndexStatus::Ready
588        && !file_entry.is_foreign()
589        && vector_column == primary_col;
590
591    if fast_path_eligible {
592        match crate::index_loader::load_primary_index(store, &file_entry.path).await {
593            Ok(index) => {
594                let local_results = index.search(query, candidate_k, config.ef_search);
595                for (row_id, approx_dist) in local_results {
596                    if dv_bitmap
597                        .as_ref()
598                        .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
599                    {
600                        continue;
601                    }
602                    outcome.results.push(SearchResult {
603                        row_id,
604                        distance: approx_dist,
605                        file_path: file_entry.path.clone(),
606                    });
607                }
608                return Ok(outcome);
609            }
610            Err(e) => {
611                debug!(
612                    "ailake: range-GET fast path failed for {} ({e}) — falling back to full-file GET",
613                    file_entry.path
614                );
615            }
616        }
617    }
618
619    let file_bytes: Bytes = store.get(&file_entry.path).await?;
620    let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
621
622    // Predicate pushdown: resolve which original row positions in this file
623    // satisfy `config.column_filter`, without disturbing row identity (see
624    // `AilakeFileReader::matching_row_ids`). When the set comes back empty,
625    // no row in the file can pass — skip HNSW search and any Parquet
626    // decode for it entirely (the real perf win: files that don't contain
627    // the filtered value are never scanned at all, not just post-filtered).
628    let matching_row_ids: Option<std::collections::HashSet<u64>> =
629        match config.column_filter.as_ref() {
630            Some(filter) => {
631                let matching = reader.matching_row_ids(filter)?;
632                if matching.is_empty() {
633                    return Ok(outcome);
634                }
635                Some(matching)
636            }
637            None => None,
638        };
639
640    // Parquet read required for: flat scan fallback, exact reranking, score_fn, hybrid,
641    // or when equality delete filter must check column values per-row.
642    let need_parquet = file_entry.index_status == IndexStatus::Indexing
643        || !reader.is_ailake_file()
644        || config.rerank_factor.is_some()
645        || config.score_fn.is_some()
646        || use_hybrid
647        || !eq_del_filter.is_empty();
648
649    if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
650        match file_entry.index_status {
651            IndexStatus::Indexing => {
652                outcome.flat_scan = Some(FlatScanKind::Deferred);
653                debug!(
654                    "ailake: flat scan fallback for {} — index build in progress \
655                     (deferred write, expected to resolve once background job completes)",
656                    file_entry.path
657                );
658            }
659            IndexStatus::Failed => {
660                // An internal indexing failure, not a foreign write — the file still
661                // has a real (foreign-write-style) centroid_b64 from make_data_file_entry
662                // *_indexing, since only index_status/index_error get patched on failure
663                // (see writer.rs::patch_index_failed). Attributing this to an external
664                // engine would send on-call looking for a Spark job that never ran.
665                outcome.flat_scan = Some(FlatScanKind::Unexpected);
666                warn!(
667                    "ailake: flat scan fallback for {} — background index build failed \
668                     permanently ({}); serving via flat scan until the next compaction \
669                     rebuilds the index",
670                    file_entry.path,
671                    file_entry
672                        .index_error
673                        .as_deref()
674                        .unwrap_or("no error recorded")
675                );
676            }
677            IndexStatus::Ready => {
678                // Ready but no loadable index/footer: either a genuine foreign write
679                // (no centroid_b64 — see CompactionPlanner::plan's detection) or a rare
680                // internally-inconsistent state (Ready without ever getting an index).
681                outcome.flat_scan = Some(FlatScanKind::Unexpected);
682                if file_entry.is_foreign() {
683                    warn!(
684                        "ailake: flat scan fallback for {} — file has no AI-Lake index \
685                         and no centroid; likely rewritten by a generic Iceberg engine \
686                         (OPTIMIZE / rewrite_data_files) with no knowledge of AI-Lake. \
687                         Results are still correct (exact O(N) scan), but degraded until \
688                         this file is recompacted by the AI-Lake SDK",
689                        file_entry.path
690                    );
691                } else {
692                    warn!(
693                        "ailake: flat scan fallback for {} — marked Ready but has no \
694                         loadable AI-Lake index despite a recorded centroid; internal \
695                         inconsistency, not an external rewrite. Run compaction to rebuild",
696                        file_entry.path
697                    );
698                }
699            }
700        }
701        let (raw_batch, raw_vectors) = reader.read_parquet()?;
702        // Phase G: inject columns added via schema evolution with initial_default values.
703        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
704        for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
705            // Skip rows marked as deleted by a V3 Deletion Vector.
706            if dv_bitmap
707                .as_ref()
708                .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
709            {
710                continue;
711            }
712            // Phase H: skip rows matched by an equality delete predicate.
713            if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
714                continue;
715            }
716            // Predicate pushdown: row survived the file-level check above,
717            // but only rows in the matching set itself pass the filter.
718            if matching_row_ids
719                .as_ref()
720                .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
721            {
722                continue;
723            }
724            if use_hybrid {
725                let text = extract_text_for_row(
726                    &batch,
727                    row_id.as_u64() as usize,
728                    config.hybrid.as_ref().unwrap(),
729                );
730                outcome
731                    .candidates
732                    .push((row_id, distance, file_entry.path.clone(), text));
733            } else {
734                let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
735                outcome.results.push(SearchResult {
736                    row_id,
737                    distance: final_score,
738                    file_path: file_entry.path.clone(),
739                });
740            }
741        }
742        return Ok(outcome);
743    }
744
745    let index = reader.load_any_index_for_column(vector_column)?;
746    let local_results = index.search(query, candidate_k, config.ef_search);
747
748    let parquet_data = if need_parquet {
749        let (raw_batch, raw_vecs) = reader.read_parquet()?;
750        // Phase G: fill missing columns for old files before score_fn / hybrid BM25.
751        let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
752        Some((filled, raw_vecs))
753    } else {
754        None
755    };
756
757    for (row_id, approx_dist) in local_results {
758        // Skip rows marked as deleted by a V3 Deletion Vector.
759        if dv_bitmap
760            .as_ref()
761            .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
762        {
763            continue;
764        }
765        let idx = row_id.as_u64() as usize;
766        // Phase H: skip rows matched by an equality delete predicate.
767        // parquet_data is always loaded when eq_del_filter is non-empty (see need_parquet).
768        if let Some((ref batch, _)) = parquet_data {
769            if eq_del_filter.should_delete_row(batch, idx) {
770                continue;
771            }
772        }
773        // Predicate pushdown: row survived the file-level check above,
774        // but only rows in the matching set itself pass the filter.
775        if matching_row_ids
776            .as_ref()
777            .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
778        {
779            continue;
780        }
781
782        let distance = if config.rerank_factor.is_some() {
783            match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
784                Some(v) => exact_distance(metric, query, v),
785                None => {
786                    error!(
787                        "ailake: invariant violated — row_id {} out of bounds \
788                         (file={}); Parquet and HNSW node count out of sync; \
789                         run compaction to rebuild",
790                        idx, file_entry.path
791                    );
792                    f32::INFINITY
793                }
794            }
795        } else {
796            approx_dist
797        };
798
799        if use_hybrid {
800            let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
801                extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
802            });
803            outcome
804                .candidates
805                .push((row_id, distance, file_entry.path.clone(), text));
806        } else {
807            let final_score = if let Some((ref batch, _)) = parquet_data {
808                apply_score_fn(&config.score_fn, distance, row_id, batch)
809            } else {
810                distance
811            };
812            outcome.results.push(SearchResult {
813                row_id,
814                distance: final_score,
815                file_path: file_entry.path.clone(),
816            });
817        }
818    }
819
820    Ok(outcome)
821}
822
823/// Extract concatenated text from specified columns for a single row.
824fn extract_text_for_row(
825    batch: &RecordBatch,
826    row_idx: usize,
827    hybrid: &crate::bm25::HybridConfig,
828) -> String {
829    use arrow_array::cast::AsArray;
830    hybrid
831        .text_columns
832        .iter()
833        .filter_map(|col| {
834            batch.column_by_name(col).and_then(|arr| {
835                arr.as_string_opt::<i32>().and_then(|sa| {
836                    if row_idx < sa.len() && sa.is_valid(row_idx) {
837                        Some(sa.value(row_idx).to_string())
838                    } else {
839                        None
840                    }
841                })
842            })
843        })
844        .collect::<Vec<_>>()
845        .join(" ")
846}
847
848/// One query arm in a cross-modal search.
849#[derive(Debug, Clone)]
850pub struct ModalQuery<'a> {
851    /// Vector column to search (must exist in the table).
852    pub column: &'a str,
853    /// Query vector for this modality.
854    pub query: &'a [f32],
855    /// Relative weight applied in the RRF formula: `weight / (k + rank)`.
856    /// `1.0` means equal weight across all modalities.
857    pub weight: f32,
858    /// Dimensionality of this column's vectors. `0` = auto-detect from table metadata
859    /// (`ailake.dim-<column>` for secondary columns, `ailake.vector-dim` for primary).
860    pub dim: u32,
861}
862
863/// Fusion method for combining results from multiple vector columns.
864#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum FusionMethod {
866    /// Reciprocal Rank Fusion: `score(d) = Σ weight_i / (k + rank_i(d))`.
867    /// `k = 60` (standard). Returned `SearchResult.distance` = `-rrf_score`
868    /// so that sort-ascending-by-distance gives the correct RRF ranking.
869    Rrf,
870}
871
872/// Cross-modal search: run independent HNSW searches across N vector columns,
873/// then fuse per-column ranked lists using Reciprocal Rank Fusion.
874///
875/// Each `ModalQuery` specifies a column name, its query vector, RRF weight, and dim.
876/// When `ModalQuery.dim == 0`, the dim is auto-detected from `ailake.dim-<col>` /
877/// `ailake.vector-dim` in table metadata.
878/// Results are de-duplicated by `(file_path, row_id)` and ranked by aggregate
879/// RRF score. `SearchResult.distance` stores `-rrf_score` (lower = better) so
880/// existing sort-ascending callers get the correct ordering.
881pub async fn search_multimodal(
882    table: &TableIdent,
883    queries: &[ModalQuery<'_>],
884    config: SearchConfig,
885    catalog: Arc<dyn CatalogProvider>,
886    store: Arc<dyn Store>,
887    fusion: FusionMethod,
888) -> AilakeResult<Vec<SearchResult>> {
889    use std::collections::HashMap;
890
891    if queries.is_empty() {
892        return Err(AilakeError::InvalidArgument(
893            "search_multimodal requires at least one ModalQuery".into(),
894        ));
895    }
896
897    // Load table metadata once for dim auto-detection and metric resolution.
898    let table_meta = catalog.load_table(table).await?;
899    let primary_col = table_meta
900        .properties
901        .get("ailake.vector-column")
902        .cloned()
903        .unwrap_or_default();
904    let primary_dim: u32 = table_meta
905        .properties
906        .get("ailake.vector-dim")
907        .and_then(|s| s.parse().ok())
908        .unwrap_or(0);
909
910    // Fetch more candidates per column so RRF has enough to fuse.
911    let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
912
913    let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
914    for mq in queries {
915        // Resolve dim: caller-supplied > per-column property > primary column dim.
916        let resolved_dim = if mq.dim > 0 {
917            mq.dim
918        } else if mq.column == primary_col {
919            primary_dim
920        } else {
921            table_meta
922                .properties
923                .get(&format!("ailake.dim-{}", mq.column))
924                .and_then(|s| s.parse().ok())
925                .unwrap_or(mq.query.len() as u32)
926        };
927
928        let col_config = SearchConfig {
929            top_k: per_col_k,
930            ef_search: config.ef_search,
931            pruning_threshold: config.pruning_threshold,
932            rerank_factor: config.rerank_factor,
933            score_fn: None,
934            partition_filter: config.partition_filter.clone(),
935            hybrid: None,
936            column_filter: config.column_filter.clone(),
937        };
938        let results = search(
939            table,
940            mq.query,
941            col_config,
942            mq.column,
943            resolved_dim,
944            catalog.clone(),
945            store.clone(),
946        )
947        .await?;
948        per_col_results.push((mq.weight, results));
949    }
950
951    // RRF fusion: accumulate score per (file_path, row_id).
952    const K: f32 = 60.0;
953    let mut scores: HashMap<(String, u64), f32> = HashMap::new();
954
955    for (weight, results) in &per_col_results {
956        for (rank, r) in results.iter().enumerate() {
957            let key = (r.file_path.clone(), r.row_id.as_u64());
958            let rrf = weight / (K + rank as f32 + 1.0);
959            *scores.entry(key).or_insert(0.0) += rrf;
960        }
961    }
962
963    // Build SearchResult list sorted by descending RRF score.
964    // Store `-rrf_score` as `.distance` so callers sorting ascending get correct order.
965    let all_files = catalog.list_files(table, None).await?;
966    let _ = all_files; // centroid not needed for fusion — just need file_path+row_id
967
968    // Collect unique candidates: prefer the row's appearance in the first column's results.
969    let mut seen: HashMap<(String, u64), f32> = HashMap::new();
970    for (_, results) in &per_col_results {
971        for r in results {
972            let key = (r.file_path.clone(), r.row_id.as_u64());
973            let rrf_score = *scores.get(&key).unwrap_or(&0.0);
974            seen.entry(key).or_insert(rrf_score);
975        }
976    }
977
978    let mut fused: Vec<SearchResult> = seen
979        .into_iter()
980        .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
981            row_id: RowId::new(row_id_u64),
982            distance: -rrf_score,
983            file_path,
984        })
985        .collect();
986
987    fused.sort_by(|a, b| {
988        a.distance
989            .partial_cmp(&b.distance)
990            .unwrap_or(std::cmp::Ordering::Equal)
991    });
992    fused.truncate(config.top_k);
993
994    let _ = fusion; // only RRF implemented; enum is extensible
995
996    Ok(fused)
997}
998
999/// Apply `score_fn` to a single result row, or return `distance` unchanged.
1000///
1001/// Slices the batch to a 1-row RecordBatch at `row_id` and calls the fn.
1002/// If `score_fn` is `None` or the row index is out of bounds, returns `distance`.
1003#[inline]
1004fn apply_score_fn(
1005    score_fn: &Option<ScoreFn>,
1006    distance: f32,
1007    row_id: RowId,
1008    batch: &RecordBatch,
1009) -> f32 {
1010    match score_fn {
1011        None => distance,
1012        Some(f) => {
1013            let idx = row_id.as_u64() as usize;
1014            if idx < batch.num_rows() {
1015                f.call(distance, &batch.slice(idx, 1))
1016            } else {
1017                distance
1018            }
1019        }
1020    }
1021}
1022
1023/// Brute-force top-k search over raw vectors. Used for Indexing shards.
1024fn flat_search(
1025    raw: &[Vec<f32>],
1026    query: &[f32],
1027    top_k: usize,
1028    metric: VectorMetric,
1029) -> Vec<(RowId, f32)> {
1030    let mut results: Vec<(RowId, f32)> = raw
1031        .iter()
1032        .enumerate()
1033        .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
1034        .collect();
1035    results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1036    results.truncate(top_k);
1037    results
1038}
1039
1040fn parse_metric(s: &str) -> VectorMetric {
1041    match s {
1042        "euclidean" => VectorMetric::Euclidean,
1043        "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
1044        "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
1045        _ => VectorMetric::Cosine,
1046    }
1047}
1048
1049/// Pre-loaded search session: all HNSW indexes loaded into memory once.
1050///
1051/// Useful for benchmarks and servers that issue many queries against the same
1052/// snapshot. Avoids re-loading and re-deserializing indexes on every call.
1053///
1054/// **Deleted rows are NOT filtered here**: unlike [`search`]/[`search_text`],
1055/// this session does not load deletion vectors or equality delete files —
1056/// rows removed via `delete_rows`/`delete_where` still appear in results.
1057/// Use [`search`] when delete visibility matters; this type trades that for
1058/// raw throughput on static snapshots (its benchmark use case).
1059pub struct SearchSession {
1060    shards: Vec<LoadedShard>,
1061    metric: VectorMetric,
1062}
1063
1064struct LoadedShard {
1065    entry: DataFileEntry,
1066    /// None when the shard is still being indexed (IndexStatus::Indexing).
1067    index: Option<AnyIndex>,
1068    /// Raw F32 vectors: always present for Indexing shards (flat scan), optionally
1069    /// present for Ready shards when `load_raw = true` (reranking).
1070    raw_vectors: Option<Vec<Vec<f32>>>,
1071}
1072
1073impl SearchSession {
1074    /// Load all indexes for the latest snapshot into memory.
1075    ///
1076    /// Pass `load_raw = true` when reranking will be used (`rerank_factor` is
1077    /// `Some`); it reads the full parquet columns so exact distances are
1078    /// available without extra I/O during `search_query`.
1079    pub async fn load(
1080        table: &TableIdent,
1081        vector_column: &str,
1082        dim: u32,
1083        catalog: Arc<dyn CatalogProvider>,
1084        store: Arc<dyn Store>,
1085        load_raw: bool,
1086    ) -> AilakeResult<Self> {
1087        let all_files = catalog.list_files(table, None).await?;
1088        let table_meta = catalog.load_table(table).await?;
1089        let metric = parse_metric(
1090            table_meta
1091                .properties
1092                .get("ailake.vector-metric")
1093                .map(String::as_str)
1094                .unwrap_or("cosine"),
1095        );
1096
1097        let mut shards = Vec::with_capacity(all_files.len());
1098        for entry in all_files {
1099            let file_bytes: Bytes = store.get(&entry.path).await?;
1100            let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
1101
1102            if entry.index_status == IndexStatus::Indexing {
1103                // HNSW not yet built — load raw vectors for flat scan.
1104                let (_, raw_vecs) = reader.read_parquet()?;
1105                shards.push(LoadedShard {
1106                    entry,
1107                    index: None,
1108                    raw_vectors: Some(raw_vecs),
1109                });
1110            } else if reader.is_ailake_file() {
1111                let mut index = reader.load_any_index_for_column(vector_column)?;
1112                let raw_vectors = if load_raw {
1113                    index.quantize_to_f16();
1114                    let (_, vecs) = reader.read_parquet()?;
1115                    Some(vecs)
1116                } else {
1117                    None
1118                };
1119                shards.push(LoadedShard {
1120                    entry,
1121                    index: Some(index),
1122                    raw_vectors,
1123                });
1124            }
1125        }
1126
1127        Ok(Self { shards, metric })
1128    }
1129
1130    /// Number of loaded shards.
1131    pub fn shard_count(&self) -> usize {
1132        self.shards.len()
1133    }
1134
1135    /// Search multiple queries in one call.
1136    ///
1137    /// For shards with raw vectors (Indexing or reranking): dispatches to GPU batch
1138    /// matmul when a CUDA device is available, falling back to CPU flat scan.
1139    /// For indexed shards (HNSW / IVF-PQ): rayon parallel-map over queries — graph
1140    /// traversal is inherently sequential and has no GPU batch path.
1141    ///
1142    /// Returns one `Vec<SearchResult>` per input query, in the same order.
1143    pub fn search_batch(
1144        &self,
1145        queries: &[Vec<f32>],
1146        config: &SearchConfig,
1147    ) -> Vec<Vec<SearchResult>> {
1148        if queries.is_empty() {
1149            return vec![];
1150        }
1151
1152        let n_queries = queries.len();
1153        let candidate_k = match config.rerank_factor {
1154            Some(factor) => config.top_k * factor,
1155            None => config.top_k,
1156        };
1157        let use_nvidia = ailake_index::hardware::detect_cuda();
1158        let use_amd = ailake_index::hardware::detect_rocm();
1159
1160        // Accumulate per-query results across all shards.
1161        let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
1162
1163        for shard in &self.shards {
1164            if let Some(raw) = &shard.raw_vectors {
1165                // Flat-scan shard — try GPU batch path (NVIDIA first, then AMD ROCm).
1166                if !raw.is_empty() {
1167                    let dim = raw[0].len();
1168                    let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
1169                    let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
1170                    let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1171
1172                    let gpu_batch = if use_nvidia {
1173                        ailake_index::gpu::try_nvidia_search_batch(
1174                            &q_refs,
1175                            &row_ids,
1176                            &flat,
1177                            dim,
1178                            self.metric,
1179                            candidate_k,
1180                        )
1181                    } else if use_amd {
1182                        ailake_index::gpu::try_rocm_search_batch(
1183                            &q_refs,
1184                            &row_ids,
1185                            &flat,
1186                            dim,
1187                            self.metric,
1188                            candidate_k,
1189                        )
1190                    } else {
1191                        None
1192                    };
1193
1194                    if let Some(batch) = gpu_batch {
1195                        for (qi, results) in batch.into_iter().enumerate() {
1196                            for (row_id, distance) in results {
1197                                all_results[qi].push(SearchResult {
1198                                    row_id,
1199                                    distance,
1200                                    file_path: shard.entry.path.clone(),
1201                                });
1202                            }
1203                        }
1204                        continue;
1205                    }
1206                }
1207
1208                // CPU fallback for flat scan.
1209                for (qi, query) in queries.iter().enumerate() {
1210                    for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1211                        all_results[qi].push(SearchResult {
1212                            row_id,
1213                            distance,
1214                            file_path: shard.entry.path.clone(),
1215                        });
1216                    }
1217                }
1218            } else if let Some(index) = &shard.index {
1219                // Indexed shard — rayon parallel-map over queries.
1220                let shard_results: Vec<Vec<SearchResult>> = queries
1221                    .par_iter()
1222                    .map(|query| {
1223                        index
1224                            .search(query, candidate_k, config.ef_search)
1225                            .into_iter()
1226                            .map(|(row_id, distance)| SearchResult {
1227                                row_id,
1228                                distance,
1229                                file_path: shard.entry.path.clone(),
1230                            })
1231                            .collect()
1232                    })
1233                    .collect();
1234
1235                for (qi, results) in shard_results.into_iter().enumerate() {
1236                    all_results[qi].extend(results);
1237                }
1238            }
1239        }
1240
1241        // Sort + truncate per query.
1242        for results in &mut all_results {
1243            results.sort_by(|a, b| {
1244                a.distance
1245                    .partial_cmp(&b.distance)
1246                    .unwrap_or(std::cmp::Ordering::Equal)
1247            });
1248            results.truncate(config.top_k);
1249        }
1250
1251        all_results
1252    }
1253
1254    /// Search using pre-loaded indexes. No I/O — pure in-memory search.
1255    pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1256        let candidate_k = match config.rerank_factor {
1257            Some(factor) => config.top_k * factor,
1258            None => config.top_k,
1259        };
1260
1261        let mut all_results: Vec<SearchResult> = self
1262            .shards
1263            .par_iter()
1264            .flat_map(|shard| {
1265                // Geometric pruning per shard.
1266                if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1267                    let dist = match self.metric {
1268                        VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1269                            ailake_vec::cosine_distance(query, &centroid.values)
1270                        }
1271                        VectorMetric::Euclidean => {
1272                            ailake_vec::euclidean_distance(query, &centroid.values)
1273                        }
1274                        VectorMetric::DotProduct => {
1275                            -ailake_vec::dot_product(query, &centroid.values)
1276                        }
1277                    };
1278                    if dist - centroid.radius > config.pruning_threshold {
1279                        return vec![];
1280                    }
1281                }
1282
1283                if let Some(index) = &shard.index {
1284                    // Ready shard: HNSW or IVF-PQ search (dispatched by AnyIndex).
1285                    let local_results = index.search(query, candidate_k, config.ef_search);
1286                    if config.rerank_factor.is_some() {
1287                        if let Some(raw) = &shard.raw_vectors {
1288                            local_results
1289                                .into_iter()
1290                                .map(|(row_id, _approx_dist)| {
1291                                    let idx = row_id.as_u64() as usize;
1292                                    let exact_dist = raw
1293                                        .get(idx)
1294                                        .map(|v| exact_distance(self.metric, query, v))
1295                                        .unwrap_or(f32::INFINITY);
1296                                    SearchResult {
1297                                        row_id,
1298                                        distance: exact_dist,
1299                                        file_path: shard.entry.path.clone(),
1300                                    }
1301                                })
1302                                .collect()
1303                        } else {
1304                            local_results
1305                                .into_iter()
1306                                .map(|(row_id, distance)| SearchResult {
1307                                    row_id,
1308                                    distance,
1309                                    file_path: shard.entry.path.clone(),
1310                                })
1311                                .collect()
1312                        }
1313                    } else {
1314                        local_results
1315                            .into_iter()
1316                            .map(|(row_id, distance)| SearchResult {
1317                                row_id,
1318                                distance,
1319                                file_path: shard.entry.path.clone(),
1320                            })
1321                            .collect()
1322                    }
1323                } else if let Some(raw) = &shard.raw_vectors {
1324                    // Indexing shard: exact flat scan.
1325                    flat_search(raw, query, candidate_k, self.metric)
1326                        .into_iter()
1327                        .map(|(row_id, distance)| SearchResult {
1328                            row_id,
1329                            distance,
1330                            file_path: shard.entry.path.clone(),
1331                        })
1332                        .collect()
1333                } else {
1334                    vec![]
1335                }
1336            })
1337            .collect();
1338
1339        all_results.sort_by(|a, b| {
1340            a.distance
1341                .partial_cmp(&b.distance)
1342                .unwrap_or(std::cmp::Ordering::Equal)
1343        });
1344        all_results.truncate(config.top_k);
1345        all_results
1346    }
1347}
1348
1349/// Pure BM25 full-text search across all Parquet files in the table.
1350///
1351/// Scans every surviving file (O(N) complexity), scores each row with BM25 against
1352/// `query_text`, and returns the global top-k by score. IDF stats are loaded from
1353/// `metadata/ailake_bm25_stats.bin` (written by `TableWriter` when `bm25_text_column`
1354/// is configured). If the stats file is absent, IDF defaults to an empty corpus
1355/// (all terms treated as maximally rare — directionally correct but less precise).
1356///
1357/// For pure-lexical search at scale (millions of rows, hundreds of files), consider
1358/// using SQL `LIKE` / `ILIKE` via DuckDB/Trino over the Iceberg-compatible table.
1359/// This function is best suited for small-medium tables or as a lexical complement
1360/// to `search()` for tables where the document count per file is manageable.
1361pub async fn search_text(
1362    table: &TableIdent,
1363    query_text: &str,
1364    text_columns: &[&str],
1365    top_k: usize,
1366    catalog: Arc<dyn CatalogProvider>,
1367    store: Arc<dyn Store>,
1368    partition_filter: Option<&str>,
1369) -> AilakeResult<Vec<SearchResult>> {
1370    use arrow_array::cast::AsArray;
1371
1372    if text_columns.is_empty() {
1373        return Err(AilakeError::InvalidArgument(
1374            "search_text requires at least one text column".into(),
1375        ));
1376    }
1377
1378    let all_files = catalog.list_files(table, None).await?;
1379    let table_meta = catalog.load_table(table).await?;
1380
1381    // Partition pruning
1382    let files: Vec<_> = if let Some(pv) = partition_filter {
1383        all_files
1384            .into_iter()
1385            .filter(|f| f.partition_value.as_deref() == Some(pv))
1386            .collect()
1387    } else {
1388        all_files
1389    };
1390
1391    // Load BM25 stats
1392    let stats_path = table_meta
1393        .properties
1394        .get(crate::bm25::BM25_STATS_PATH_PROP)
1395        .map(String::as_str)
1396        .unwrap_or(crate::bm25::BM25_STATS_FILE);
1397    let stats = match store.get(stats_path).await {
1398        Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1399        Err(_) => {
1400            debug!(
1401                "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1402                stats_path
1403            );
1404            crate::bm25::IdfStats::default()
1405        }
1406    };
1407    let scorer = crate::bm25::BM25Scorer::new(&stats);
1408
1409    // Phase H: equality delete filter for search_text results.
1410    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1411        Ok(edfs) if !edfs.is_empty() => {
1412            match EqualityDeleteFilter::from_files(&store, &edfs).await {
1413                Ok(f) => f,
1414                Err(e) => {
1415                    warn!("ailake: equality delete filter build failed in search_text: {e}");
1416                    EqualityDeleteFilter::empty()
1417                }
1418            }
1419        }
1420        _ => EqualityDeleteFilter::empty(),
1421    };
1422
1423    let mut results: Vec<SearchResult> = Vec::new();
1424
1425    for file_entry in &files {
1426        let file_bytes = store.get(&file_entry.path).await?;
1427        // Use dim=0 — we only read the Parquet columns, not the HNSW.
1428        let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1429
1430        // Fast path: per-file Tantivy index (O(log N) via inverted index).
1431        // Falls back to BM25 O(N) brute-force for files without an FTS section.
1432        if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1433            match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1434                Ok(fts) => {
1435                    let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1436                    if !hits.is_empty() {
1437                        // Load batch only for equality delete checking (not for scoring).
1438                        let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1439                        let (raw_batch, _) = reader2.read_parquet()?;
1440                        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1441                        for hit in hits {
1442                            let row_idx = hit.row_id as usize;
1443                            if row_idx >= batch.num_rows() {
1444                                continue;
1445                            }
1446                            if eq_del_filter.should_delete_row(&batch, row_idx) {
1447                                continue;
1448                            }
1449                            results.push(SearchResult {
1450                                row_id: RowId::new(hit.row_id),
1451                                distance: -hit.score,
1452                                file_path: file_entry.path.clone(),
1453                            });
1454                        }
1455                    }
1456                    continue; // skip O(N) BM25 fallback
1457                }
1458                Err(e) => {
1459                    warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1460                    // fall through to BM25 brute-force
1461                }
1462            }
1463        }
1464
1465        // Fallback: O(N) BM25 brute-force — unchanged from pre-Phase-T behaviour.
1466        let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1467        let (raw_batch, _) = reader_fb.read_parquet()?;
1468        // Phase G: fill missing columns for old files before BM25 text extraction.
1469        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1470
1471        for row_idx in 0..batch.num_rows() {
1472            // Phase H: skip rows matched by equality delete predicate.
1473            if eq_del_filter.should_delete_row(&batch, row_idx) {
1474                continue;
1475            }
1476            let doc_text: String = text_columns
1477                .iter()
1478                .filter_map(|&col| {
1479                    batch.column_by_name(col).and_then(|arr| {
1480                        arr.as_string_opt::<i32>().and_then(|sa| {
1481                            if sa.is_valid(row_idx) {
1482                                Some(sa.value(row_idx).to_string())
1483                            } else {
1484                                None
1485                            }
1486                        })
1487                    })
1488                })
1489                .collect::<Vec<_>>()
1490                .join(" ");
1491
1492            if doc_text.is_empty() {
1493                continue;
1494            }
1495
1496            let bm25 = scorer.score(query_text, &doc_text);
1497            if bm25 > 0.0 {
1498                // Negate so that sort-ascending = best-first (lower distance = higher BM25).
1499                results.push(SearchResult {
1500                    row_id: RowId::new(row_idx as u64),
1501                    distance: -bm25,
1502                    file_path: file_entry.path.clone(),
1503                });
1504            }
1505        }
1506    }
1507
1508    results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1509    results.truncate(top_k);
1510    Ok(results)
1511}
1512
1513/// Fetch full row data for a slice of search results.
1514///
1515/// Groups results by Parquet file, reads each file once, extracts the matching rows
1516/// via `arrow_select::take`, then concatenates everything back in original top-k order
1517/// with a `_distance: Float32` column appended.
1518///
1519/// Use this immediately after `search()` to retrieve the actual text / metadata
1520/// columns (e.g. `chunk_text`, `document_title`) alongside the distance scores.
1521pub async fn fetch_rows(
1522    results: &[SearchResult],
1523    store: Arc<dyn Store>,
1524    vector_column: &str,
1525    dim: u32,
1526    schema_fields: &[SchemaField],
1527) -> AilakeResult<RecordBatch> {
1528    use std::collections::HashMap;
1529
1530    use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1531    use arrow_schema::{DataType, Field, Schema};
1532    use arrow_select::{concat::concat_batches, take::take};
1533
1534    if results.is_empty() {
1535        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1536    }
1537
1538    // Group by file path; preserve original position for re-sorting.
1539    let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1540    for (i, r) in results.iter().enumerate() {
1541        by_file
1542            .entry(r.file_path.as_str())
1543            .or_default()
1544            .push((r.row_id.as_u64(), r.distance, i));
1545    }
1546
1547    use arrow_array::FixedSizeListArray;
1548
1549    // `vector_column` is deliberately excluded from the batch AilakeFileReader::read_parquet()
1550    // returns — it's decoded separately into `vectors: Vec<Vec<f32>>` and re-appended below as
1551    // a FixedSizeList<Float32> field. SchemaFiller has no way to know that; left unfiltered it
1552    // treats the vector column as "missing" (it genuinely isn't in the tabular batch) and
1553    // injects a synthetic column for it — wrong type (falls through iceberg_type_to_arrow's
1554    // Utf8 fallback for the vector column's Iceberg type string) and a duplicate field name
1555    // once the real decoded vector column is appended, breaking pandas' arrow->pandas
1556    // conversion (`Unsupported cast from fixed_size_list<...> to large_utf8`). Found writing
1557    // the regression test for the *other* schema-projection bug this function has — filtering
1558    // it out here is required for schema-filling to be correct at all in fetch_rows.
1559    let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1560        .iter()
1561        .filter(|sf| sf.name != vector_column)
1562        .cloned()
1563        .collect();
1564
1565    // (original_index, distance, single-row RecordBatch, decoded F32 vector)
1566    let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1567
1568    for (file_path, rows) in &by_file {
1569        let bytes = store.get(file_path).await?;
1570        let reader = AilakeFileReader::new(bytes, vector_column, dim);
1571        let (raw_batch, vectors) = reader.read_parquet()?;
1572        // Project against the table's *current* Iceberg schema — old files written
1573        // before a metadata-only evolve_schema/add_column don't physically have the
1574        // new column. Without this, a file that happens to land first in `collected`
1575        // silently drives `base_schema` below and the new column never appears in the
1576        // response at all (not even as null) — confirmed live via Spark's
1577        // AilakeNative.scan() and ailake.search(fetch_data=True), both of which call
1578        // this function. Same fix SchemaFiller::fill already applies on the
1579        // pointer-search path (search()); this was the one full-row-fetch path it
1580        // never reached.
1581        let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1582
1583        for &(row_id, distance, pos) in rows {
1584            let idx = row_id as usize;
1585            if idx >= batch.num_rows() {
1586                tracing::warn!(
1587                    "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1588                    idx,
1589                    batch.num_rows(),
1590                    file_path
1591                );
1592                continue;
1593            }
1594
1595            let indices = UInt32Array::from(vec![idx as u32]);
1596            let row_cols: Vec<ArrayRef> = batch
1597                .columns()
1598                .iter()
1599                .map(|col| {
1600                    take(col.as_ref(), &indices, None)
1601                        .map_err(|e| AilakeError::Arrow(e.to_string()))
1602                })
1603                .collect::<AilakeResult<Vec<_>>>()?;
1604
1605            let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1606                .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1607
1608            // Capture decoded F32 vector for this row (empty vec if not available).
1609            let vec = vectors
1610                .get(idx)
1611                .cloned()
1612                .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1613
1614            collected.push((pos, distance, row_batch, vec));
1615        }
1616    }
1617
1618    if collected.is_empty() {
1619        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1620    }
1621
1622    // Restore original top-k order from the search results slice.
1623    collected.sort_by_key(|(pos, _, _, _)| *pos);
1624
1625    let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1626    let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1627    let base_schema = collected[0].2.schema();
1628
1629    let combined =
1630        concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1631
1632    // Build FixedSizeList<Float32> column with decoded vectors (F32, not raw F16 bytes).
1633    let flat_vecs: Vec<f32> = collected
1634        .iter()
1635        .flat_map(|(_, _, _, v)| v.iter().copied())
1636        .collect();
1637    let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1638    let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1639    let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1640    let vec_field = Arc::new(Field::new(
1641        vector_column,
1642        DataType::FixedSizeList(item_field, dim as i32),
1643        false,
1644    ));
1645
1646    // Schema: tabular cols, then decoded vector col, then _distance.
1647    let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1648    fields.push(vec_field);
1649    fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1650    let new_schema = Arc::new(Schema::new(fields));
1651
1652    let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1653    columns.push(Arc::new(vec_col));
1654    columns.push(Arc::new(Float32Array::from(distances)));
1655
1656    RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1657}
1658
1659/// Load per-file BM25 Bloom filters from the Puffin stats file for the current snapshot.
1660///
1661/// Returns a map of `file_path → BloomFilter`. Empty map = no stats file available
1662/// (V2 table, first write, or fetch failure). The scanner applies Bloom pruning only
1663/// when the map is non-empty.
1664async fn load_bloom_map(
1665    table_meta: &ailake_catalog::TableMetadata,
1666    store: &dyn Store,
1667) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1668    let stats_path = match &table_meta.current_statistics_path {
1669        Some(p) => p.clone(),
1670        None => return std::collections::HashMap::new(),
1671    };
1672    let bytes = match store.get(&stats_path).await {
1673        Ok(b) => b,
1674        Err(e) => {
1675            debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1676            return std::collections::HashMap::new();
1677        }
1678    };
1679    let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1680    let bloom_entries = match reader.read_bm25_blooms() {
1681        Ok(e) => e,
1682        Err(e) => {
1683            warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1684            return std::collections::HashMap::new();
1685        }
1686    };
1687    bloom_entries
1688        .into_iter()
1689        .filter_map(|entry| {
1690            let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1691            Some((entry.path, bf))
1692        })
1693        .collect()
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698    use super::*;
1699    use crate::writer::MultiVectorBatch;
1700    use ailake_catalog::{HadoopCatalog, TableIdent};
1701    use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1702    use ailake_store::LocalStore;
1703    use arrow_array::{Int32Array, RecordBatch};
1704    use arrow_schema::{DataType, Field, Schema};
1705    use std::sync::Arc;
1706    use tempfile::TempDir;
1707
1708    fn make_policy(dim: u32) -> VectorStoragePolicy {
1709        VectorStoragePolicy {
1710            column_name: "embedding".to_string(),
1711            dim,
1712            metric: VectorMetric::Cosine,
1713            precision: VectorPrecision::F16,
1714            pq: None,
1715            keep_raw_for_reranking: true,
1716            pre_normalize: false,
1717            hnsw_m: None,
1718            hnsw_ef_construction: None,
1719            ivf_residual: false,
1720            embedding_model: None,
1721            modality: None,
1722            partition_by: None,
1723            partition_value: None,
1724            partition_column_type: None,
1725            partition_fields: vec![],
1726        }
1727    }
1728
1729    async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1730        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1731        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1732        let table = TableIdent::new("default", "table");
1733
1734        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1735        let ids: Vec<i32> = (0..rows as i32).collect();
1736        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1737
1738        // Each row i has embedding with 1.0 at dimension i and 0 elsewhere (unit basis vectors)
1739        let embeddings: Vec<Vec<f32>> = (0..rows)
1740            .map(|i| {
1741                let mut v = vec![0.0f32; dim];
1742                v[i % dim] = 1.0;
1743                v
1744            })
1745            .collect();
1746
1747        let mut writer =
1748            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1749                .await
1750                .unwrap();
1751        writer.write_batch(&batch, &embeddings).await.unwrap();
1752        writer.commit().await.unwrap();
1753    }
1754
1755    /// Same shape as `write_demo_table`, plus a `category` column ("even"/"odd"
1756    /// by row id) — lets predicate-pushdown tests assert against a real column
1757    /// while still using the same unit-basis-vector embeddings (row `i`'s
1758    /// nearest neighbor for query `i` is unambiguous).
1759    async fn write_demo_table_with_category(dir: &TempDir, dim: usize, rows: usize) {
1760        use arrow_array::StringArray;
1761        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1762        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1763        let table = TableIdent::new("default", "table");
1764
1765        let schema = Arc::new(Schema::new(vec![
1766            Field::new("id", DataType::Int32, false),
1767            Field::new("category", DataType::Utf8, false),
1768        ]));
1769        let ids: Vec<i32> = (0..rows as i32).collect();
1770        let categories: Vec<&str> = (0..rows)
1771            .map(|i| if i % 2 == 0 { "even" } else { "odd" })
1772            .collect();
1773        let batch = RecordBatch::try_new(
1774            schema,
1775            vec![
1776                Arc::new(Int32Array::from(ids)),
1777                Arc::new(StringArray::from(categories)),
1778            ],
1779        )
1780        .unwrap();
1781
1782        let embeddings: Vec<Vec<f32>> = (0..rows)
1783            .map(|i| {
1784                let mut v = vec![0.0f32; dim];
1785                v[i % dim] = 1.0;
1786                v
1787            })
1788            .collect();
1789
1790        let mut writer =
1791            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1792                .await
1793                .unwrap();
1794        writer.write_batch(&batch, &embeddings).await.unwrap();
1795        writer.commit().await.unwrap();
1796    }
1797
1798    #[tokio::test]
1799    async fn column_filter_preserves_row_identity_on_indexed_path() {
1800        // The critical correctness property: row_ids returned under a
1801        // column_filter must still be the TRUE file-relative row ids (usable
1802        // to fetch the right row), not positions into some internally
1803        // compacted/filtered batch. Every "even" row's embedding is a unit
1804        // basis vector at its own dimension, so searching with that exact
1805        // query and asserting the returned row_id matches would fail loudly
1806        // if row identity got shifted by the pushdown.
1807        let dir = TempDir::new().unwrap();
1808        let dim = 8usize;
1809        write_demo_table_with_category(&dir, dim, 8).await;
1810
1811        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1812        let catalog: Arc<dyn CatalogProvider> =
1813            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1814        let table = TableIdent::new("default", "table");
1815
1816        // Query matches row 4 exactly ("even" — 4 % 2 == 0).
1817        let mut query = vec![0.0f32; dim];
1818        query[4] = 1.0;
1819
1820        let config = SearchConfig {
1821            top_k: 1,
1822            ef_search: 50,
1823            pruning_threshold: f32::INFINITY,
1824            rerank_factor: None,
1825            score_fn: None,
1826            partition_filter: None,
1827            hybrid: None,
1828            column_filter: Some(ailake_core::ColumnFilter::eq(
1829                "category",
1830                ailake_core::FilterValue::Str("even".to_string()),
1831            )),
1832        };
1833
1834        let results = search(
1835            &table,
1836            &query,
1837            config,
1838            "embedding",
1839            dim as u32,
1840            catalog,
1841            store,
1842        )
1843        .await
1844        .unwrap();
1845
1846        assert_eq!(results.len(), 1);
1847        assert_eq!(
1848            results[0].row_id.as_u64(),
1849            4,
1850            "row_id must be the true file-relative position, not a filtered-batch index"
1851        );
1852    }
1853
1854    #[tokio::test]
1855    async fn column_filter_excludes_non_matching_rows() {
1856        let dir = TempDir::new().unwrap();
1857        let dim = 8usize;
1858        write_demo_table_with_category(&dir, dim, 8).await;
1859
1860        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1861        let catalog: Arc<dyn CatalogProvider> =
1862            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1863        let table = TableIdent::new("default", "table");
1864
1865        // Query matches row 3 exactly ("odd"); filtering for "even" must exclude it.
1866        let mut query = vec![0.0f32; dim];
1867        query[3] = 1.0;
1868
1869        let config = SearchConfig {
1870            top_k: 8,
1871            ef_search: 50,
1872            pruning_threshold: f32::INFINITY,
1873            rerank_factor: None,
1874            score_fn: None,
1875            partition_filter: None,
1876            hybrid: None,
1877            column_filter: Some(ailake_core::ColumnFilter::eq(
1878                "category",
1879                ailake_core::FilterValue::Str("even".to_string()),
1880            )),
1881        };
1882
1883        let results = search(
1884            &table,
1885            &query,
1886            config,
1887            "embedding",
1888            dim as u32,
1889            catalog,
1890            store,
1891        )
1892        .await
1893        .unwrap();
1894
1895        assert_eq!(results.len(), 4, "only the 4 even rows should survive");
1896        for r in &results {
1897            assert_eq!(
1898                r.row_id.as_u64() % 2,
1899                0,
1900                "row {} is not even",
1901                r.row_id.as_u64()
1902            );
1903        }
1904    }
1905
1906    #[tokio::test]
1907    async fn column_filter_no_match_returns_empty() {
1908        let dir = TempDir::new().unwrap();
1909        let dim = 8usize;
1910        write_demo_table_with_category(&dir, dim, 8).await;
1911
1912        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1913        let catalog: Arc<dyn CatalogProvider> =
1914            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1915        let table = TableIdent::new("default", "table");
1916
1917        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1918        let config = SearchConfig {
1919            top_k: 8,
1920            ef_search: 50,
1921            pruning_threshold: f32::INFINITY,
1922            rerank_factor: None,
1923            score_fn: None,
1924            partition_filter: None,
1925            hybrid: None,
1926            column_filter: Some(ailake_core::ColumnFilter::eq(
1927                "category",
1928                ailake_core::FilterValue::Str("nonexistent".to_string()),
1929            )),
1930        };
1931
1932        let results = search(
1933            &table,
1934            &query,
1935            config,
1936            "embedding",
1937            dim as u32,
1938            catalog,
1939            store,
1940        )
1941        .await
1942        .unwrap();
1943        assert!(results.is_empty());
1944    }
1945
1946    /// Wraps a `Store`, recording every `get`/`get_range` call's byte range
1947    /// per path — used to prove the Fase 16 range-GET fast path never
1948    /// touches the row-group tabular/vector data section of a file, not just
1949    /// to assert it doesn't crash. A whole-file `get` is recorded as
1950    /// `0..file_size` (its full extent), since that's what it touches even
1951    /// though the `Store` trait doesn't expose a byte offset for it.
1952    struct CountingStore {
1953        inner: Arc<dyn Store>,
1954        ranges_by_path: std::sync::Mutex<std::collections::HashMap<String, Vec<(u64, u64)>>>,
1955    }
1956
1957    impl CountingStore {
1958        fn new(inner: Arc<dyn Store>) -> Self {
1959            Self {
1960                inner,
1961                ranges_by_path: std::sync::Mutex::new(std::collections::HashMap::new()),
1962            }
1963        }
1964
1965        fn record(&self, path: &str, start: u64, end: u64) {
1966            self.ranges_by_path
1967                .lock()
1968                .unwrap()
1969                .entry(path.to_string())
1970                .or_default()
1971                .push((start, end));
1972        }
1973
1974        /// Lowest byte offset fetched for `path` across every recorded call —
1975        /// the precise, ratio-independent proof that the tabular/vector data
1976        /// section (everything before the AILK section) was never touched.
1977        fn min_offset_for(&self, path: &str) -> Option<u64> {
1978            self.ranges_by_path
1979                .lock()
1980                .unwrap()
1981                .get(path)
1982                .and_then(|ranges| ranges.iter().map(|(s, _)| *s).min())
1983        }
1984    }
1985
1986    #[async_trait::async_trait]
1987    impl Store for CountingStore {
1988        async fn get(&self, path: &str) -> AilakeResult<Bytes> {
1989            let b = self.inner.get(path).await?;
1990            self.record(path, 0, b.len() as u64);
1991            Ok(b)
1992        }
1993        async fn get_range(&self, path: &str, range: std::ops::Range<u64>) -> AilakeResult<Bytes> {
1994            let b = self.inner.get_range(path, range.clone()).await?;
1995            self.record(path, range.start, range.end);
1996            Ok(b)
1997        }
1998        async fn put(&self, path: &str, data: Bytes) -> AilakeResult<()> {
1999            self.inner.put(path, data).await
2000        }
2001        async fn list(&self, prefix: &str) -> AilakeResult<Vec<String>> {
2002            self.inner.list(prefix).await
2003        }
2004        async fn file_size(&self, path: &str) -> AilakeResult<u64> {
2005            self.inner.file_size(path).await
2006        }
2007        async fn exists(&self, path: &str) -> AilakeResult<bool> {
2008            self.inner.exists(path).await
2009        }
2010        async fn delete(&self, path: &str) -> AilakeResult<()> {
2011            self.inner.delete(path).await
2012        }
2013    }
2014
2015    #[tokio::test]
2016    async fn range_get_fast_path_never_touches_tabular_data_section() {
2017        let dir = TempDir::new().unwrap();
2018        let dim = 64usize;
2019        write_demo_table(&dir, dim, 400).await;
2020
2021        let local: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2022        let data_files = local.list("data").await.unwrap();
2023        assert_eq!(
2024            data_files.len(),
2025            1,
2026            "expect a single part file for this test"
2027        );
2028        let data_path = data_files[0].clone();
2029
2030        // Ground truth: the exact tabular/data-section boundary, from a plain
2031        // full-file read+parse — independent of the fast path under test.
2032        let full_bytes = local.get(&data_path).await.unwrap();
2033        let ailk_offset = ailake_file::AilakeFileReader::new(full_bytes, "embedding", dim as u32)
2034            .ailk_offset()
2035            .unwrap();
2036
2037        let counting = Arc::new(CountingStore::new(local.clone()));
2038        let store: Arc<dyn Store> = counting.clone();
2039        let catalog: Arc<dyn CatalogProvider> =
2040            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2041        let table = TableIdent::new("default", "table");
2042
2043        let mut query = vec![0.0f32; dim];
2044        query[3] = 1.0;
2045        let config = SearchConfig {
2046            top_k: 5,
2047            ef_search: 50,
2048            pruning_threshold: f32::INFINITY,
2049            rerank_factor: None,
2050            score_fn: None,
2051            partition_filter: None,
2052            hybrid: None,
2053            column_filter: None,
2054        };
2055
2056        let results = search(
2057            &table,
2058            &query,
2059            config,
2060            "embedding",
2061            dim as u32,
2062            catalog,
2063            store,
2064        )
2065        .await
2066        .unwrap();
2067        assert!(!results.is_empty());
2068
2069        // The precise, ratio-independent guarantee this feature exists for:
2070        // regardless of how large the HNSW blob happens to be relative to the
2071        // tabular data (small synthetic tables can skew that ratio either
2072        // way — see `range_get_fast_path_matches_full_file_path_results` for
2073        // the separate correctness-parity proof), the fast path must never
2074        // fetch a single byte positioned before the AILK section starts.
2075        let min_offset = counting
2076            .min_offset_for(&data_path)
2077            .expect("should have fetched something from the data file");
2078        assert!(
2079            min_offset >= ailk_offset,
2080            "fast path fetched bytes from the tabular/vector data section: \
2081             min_offset={min_offset} < ailk_offset={ailk_offset}"
2082        );
2083    }
2084
2085    #[tokio::test]
2086    async fn range_get_fast_path_matches_full_file_path_results() {
2087        // Forces the full-file fallback via a column_filter that matches
2088        // every row (id >= 0) — same semantic query and identical distances,
2089        // just disqualified from the fast path (config.column_filter.is_some()).
2090        // Comparing the two proves the fast path isn't silently returning
2091        // different (or wrong-row-identity) results.
2092        let dir = TempDir::new().unwrap();
2093        let dim = 16usize;
2094        write_demo_table(&dir, dim, 20).await;
2095
2096        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2097        let catalog: Arc<dyn CatalogProvider> =
2098            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2099        let table = TableIdent::new("default", "table");
2100
2101        let mut query = vec![0.0f32; dim];
2102        query[3] = 1.0;
2103
2104        let fast_config = SearchConfig {
2105            top_k: 5,
2106            ef_search: 50,
2107            pruning_threshold: f32::INFINITY,
2108            rerank_factor: None,
2109            score_fn: None,
2110            partition_filter: None,
2111            hybrid: None,
2112            column_filter: None,
2113        };
2114        let fast_results = search(
2115            &table,
2116            &query,
2117            fast_config,
2118            "embedding",
2119            dim as u32,
2120            catalog.clone(),
2121            store.clone(),
2122        )
2123        .await
2124        .unwrap();
2125
2126        let slow_config = SearchConfig {
2127            top_k: 5,
2128            ef_search: 50,
2129            pruning_threshold: f32::INFINITY,
2130            rerank_factor: None,
2131            score_fn: None,
2132            partition_filter: None,
2133            hybrid: None,
2134            column_filter: Some(ailake_core::ColumnFilter::new(
2135                "id",
2136                ailake_core::FilterOp::Gte,
2137                ailake_core::FilterValue::I64(0),
2138            )),
2139        };
2140        let slow_results = search(
2141            &table,
2142            &query,
2143            slow_config,
2144            "embedding",
2145            dim as u32,
2146            catalog,
2147            store,
2148        )
2149        .await
2150        .unwrap();
2151
2152        assert_eq!(fast_results.len(), slow_results.len());
2153        assert!(!fast_results.is_empty());
2154        for (f, s) in fast_results.iter().zip(slow_results.iter()) {
2155            assert_eq!(f.row_id, s.row_id, "fast/slow path row_id mismatch");
2156            assert!(
2157                (f.distance - s.distance).abs() < 1e-4,
2158                "fast/slow path distance mismatch: {} vs {}",
2159                f.distance,
2160                s.distance
2161            );
2162        }
2163    }
2164
2165    #[tokio::test]
2166    async fn rerank_returns_correct_top_k_count() {
2167        let dir = TempDir::new().unwrap();
2168        let dim = 8usize;
2169        write_demo_table(&dir, dim, 8).await;
2170
2171        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2172        let catalog: Arc<dyn CatalogProvider> =
2173            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2174        let table = TableIdent::new("default", "table");
2175
2176        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2177        let config = SearchConfig {
2178            top_k: 3,
2179            ef_search: 50,
2180            pruning_threshold: f32::INFINITY,
2181            rerank_factor: Some(2),
2182            score_fn: None,
2183            partition_filter: None,
2184            hybrid: None,
2185            column_filter: None,
2186        };
2187
2188        let results = search(
2189            &table,
2190            &query,
2191            config,
2192            "embedding",
2193            dim as u32,
2194            catalog,
2195            store,
2196        )
2197        .await
2198        .unwrap();
2199
2200        assert_eq!(results.len(), 3);
2201    }
2202
2203    #[tokio::test]
2204    async fn rerank_nearest_is_exact_match() {
2205        let dir = TempDir::new().unwrap();
2206        let dim = 8usize;
2207        write_demo_table(&dir, dim, 8).await;
2208
2209        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2210        let catalog: Arc<dyn CatalogProvider> =
2211            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2212        let table = TableIdent::new("default", "table");
2213
2214        // Row 0 has [1,0,0,...] — cosine distance to same query is 0
2215        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2216        let config = SearchConfig {
2217            top_k: 1,
2218            ef_search: 50,
2219            pruning_threshold: f32::INFINITY,
2220            rerank_factor: Some(4),
2221            score_fn: None,
2222            partition_filter: None,
2223            hybrid: None,
2224            column_filter: None,
2225        };
2226
2227        let results = search(
2228            &table,
2229            &query,
2230            config,
2231            "embedding",
2232            dim as u32,
2233            catalog,
2234            store,
2235        )
2236        .await
2237        .unwrap();
2238
2239        assert_eq!(results.len(), 1);
2240        // Exact cosine distance between identical unit vectors is ~0 (F16 rounding allowed)
2241        assert!(
2242            results[0].distance < 1e-3,
2243            "distance was {}",
2244            results[0].distance
2245        );
2246        assert_eq!(results[0].row_id, RowId::new(0));
2247    }
2248
2249    #[tokio::test]
2250    async fn no_rerank_matches_default_behavior() {
2251        let dir = TempDir::new().unwrap();
2252        let dim = 4usize;
2253        write_demo_table(&dir, dim, 4).await;
2254
2255        let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2256        let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2257        let cat_a: Arc<dyn CatalogProvider> =
2258            Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
2259        let cat_b: Arc<dyn CatalogProvider> =
2260            Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
2261        let table = TableIdent::new("default", "table");
2262
2263        let query = vec![1.0f32, 0.0, 0.0, 0.0];
2264        let cfg_plain = SearchConfig {
2265            top_k: 2,
2266            ef_search: 50,
2267            pruning_threshold: f32::INFINITY,
2268            rerank_factor: None,
2269            score_fn: None,
2270            partition_filter: None,
2271            hybrid: None,
2272            column_filter: None,
2273        };
2274        let cfg_rerank = SearchConfig {
2275            top_k: 2,
2276            ef_search: 50,
2277            pruning_threshold: f32::INFINITY,
2278            rerank_factor: Some(2),
2279            score_fn: None,
2280            partition_filter: None,
2281            hybrid: None,
2282            column_filter: None,
2283        };
2284
2285        let plain = search(
2286            &table,
2287            &query,
2288            cfg_plain,
2289            "embedding",
2290            dim as u32,
2291            cat_a,
2292            store_a,
2293        )
2294        .await
2295        .unwrap();
2296        let reranked = search(
2297            &table,
2298            &query,
2299            cfg_rerank,
2300            "embedding",
2301            dim as u32,
2302            cat_b,
2303            store_b,
2304        )
2305        .await
2306        .unwrap();
2307
2308        // Both should return same top-1 result (row 0, distance ~0)
2309        assert_eq!(plain[0].row_id, reranked[0].row_id);
2310    }
2311
2312    #[tokio::test]
2313    async fn multimodal_rrf_returns_top_k() {
2314        let dir = TempDir::new().unwrap();
2315        let dim = 4usize;
2316        write_demo_table(&dir, dim, 4).await;
2317
2318        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2319        let catalog: Arc<dyn CatalogProvider> =
2320            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2321        let table = TableIdent::new("default", "table");
2322
2323        // Two modal queries using the same column (single-column table).
2324        // Different queries to exercise RRF merging.
2325        let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
2326        let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
2327
2328        let queries = vec![
2329            ModalQuery {
2330                column: "embedding",
2331                query: &q1,
2332                weight: 0.7,
2333                dim: dim as u32,
2334            },
2335            ModalQuery {
2336                column: "embedding",
2337                query: &q2,
2338                weight: 0.3,
2339                dim: dim as u32,
2340            },
2341        ];
2342
2343        let config = SearchConfig {
2344            top_k: 2,
2345            ef_search: 50,
2346            pruning_threshold: f32::INFINITY,
2347            rerank_factor: None,
2348            score_fn: None,
2349            partition_filter: None,
2350            hybrid: None,
2351            column_filter: None,
2352        };
2353
2354        let results =
2355            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2356                .await
2357                .unwrap();
2358
2359        assert_eq!(results.len(), 2);
2360        // RRF score stored as -distance; all should be negative
2361        assert!(results[0].distance <= 0.0);
2362        // Top result should be one of rows 0 or 1 (nearest to q1 or q2)
2363        assert!(results[0].row_id.as_u64() < 4);
2364    }
2365
2366    /// True cross-modal test: two columns with DIFFERENT dims (4 + 2).
2367    /// Verifies that search_multimodal correctly routes to each column's HNSW
2368    /// and that the dim validation in search() handles secondary columns.
2369    #[tokio::test]
2370    async fn multimodal_rrf_cross_modal_different_dims() {
2371        let dir = TempDir::new().unwrap();
2372        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2373        let catalog: Arc<dyn CatalogProvider> =
2374            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2375        let table = TableIdent::new("default", "table");
2376
2377        // Write a 2-column table: "embedding" dim=4, "img_embedding" dim=2
2378        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2379        let rows = 4usize;
2380        let ids: Vec<i32> = (0..rows as i32).collect();
2381        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2382
2383        let text_embs: Vec<Vec<f32>> = (0..rows)
2384            .map(|i| {
2385                let mut v = vec![0.0f32; 4];
2386                v[i % 4] = 1.0;
2387                v
2388            })
2389            .collect();
2390        let img_embs: Vec<Vec<f32>> = (0..rows)
2391            .map(|i| {
2392                let mut v = vec![0.0f32; 2];
2393                v[i % 2] = 1.0;
2394                v
2395            })
2396            .collect();
2397
2398        let text_policy = make_policy(4);
2399        let img_policy = VectorStoragePolicy {
2400            column_name: "img_embedding".to_string(),
2401            dim: 2,
2402            metric: VectorMetric::Cosine,
2403            precision: VectorPrecision::F16,
2404            pq: None,
2405            keep_raw_for_reranking: true,
2406            pre_normalize: false,
2407            hnsw_m: None,
2408            hnsw_ef_construction: None,
2409            ivf_residual: false,
2410            embedding_model: None,
2411            modality: None,
2412            partition_by: None,
2413            partition_value: None,
2414            partition_column_type: None,
2415            partition_fields: vec![],
2416        };
2417
2418        let mut writer = crate::TableWriter::create_or_open(
2419            catalog.clone(),
2420            store.clone(),
2421            text_policy,
2422            table.clone(),
2423            2,
2424        )
2425        .await
2426        .unwrap();
2427
2428        let batches = [
2429            MultiVectorBatch {
2430                policy: make_policy(4),
2431                embeddings: &text_embs,
2432            },
2433            MultiVectorBatch {
2434                policy: img_policy,
2435                embeddings: &img_embs,
2436            },
2437        ];
2438        writer.write_batch_multi(&batch, &batches).await.unwrap();
2439        writer.commit().await.unwrap();
2440
2441        // Cross-modal search: text query (dim=4) + image query (dim=2).
2442        let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
2443        let q_img = vec![1.0f32, 0.0];
2444
2445        let queries = vec![
2446            ModalQuery {
2447                column: "embedding",
2448                query: &q_text,
2449                weight: 0.6,
2450                dim: 4,
2451            },
2452            ModalQuery {
2453                column: "img_embedding",
2454                query: &q_img,
2455                weight: 0.4,
2456                dim: 2,
2457            },
2458        ];
2459        let config = SearchConfig {
2460            top_k: 2,
2461            ef_search: 50,
2462            pruning_threshold: f32::INFINITY,
2463            rerank_factor: None,
2464            score_fn: None,
2465            partition_filter: None,
2466            hybrid: None,
2467            column_filter: None,
2468        };
2469
2470        let results =
2471            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2472                .await
2473                .unwrap();
2474
2475        assert!(!results.is_empty(), "should return results");
2476        assert!(results[0].distance <= 0.0, "distance is -rrf_score");
2477        // Row 0 is nearest to both q_text=[1,0,0,0] and q_img=[1,0]
2478        assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
2479    }
2480
2481    /// Regression: `fetch_rows` used to build its output schema from whichever file's
2482    /// physical Parquet schema happened to be read first — a file written before a
2483    /// metadata-only `evolve_schema`/`add_column` never physically has the new column,
2484    /// so it was silently absent from the response instead of projected as null.
2485    /// Confirmed live via Spark's `AilakeNative.scan()` and
2486    /// `ailake.search(fetch_data=True)`, both backed by this function.
2487    #[tokio::test]
2488    async fn fetch_rows_projects_evolved_column_as_null() {
2489        use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
2490
2491        let dir = TempDir::new().unwrap();
2492        let dim = 8usize;
2493        write_demo_table(&dir, dim, 8).await;
2494
2495        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2496        let catalog: Arc<dyn CatalogProvider> =
2497            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2498        let table = TableIdent::new("default", "table");
2499
2500        // Metadata-only schema evolution — no data files rewritten, so every existing
2501        // file on disk still physically lacks the "note" column.
2502        catalog
2503            .evolve_schema(
2504                &table,
2505                SchemaEvolution::new().add_column(AddColumnRequest {
2506                    name: "note".to_string(),
2507                    iceberg_type: "string".to_string(),
2508                    required: false,
2509                    initial_default: None,
2510                    write_default: None,
2511                    doc: None,
2512                }),
2513            )
2514            .await
2515            .unwrap();
2516
2517        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2518        let config = SearchConfig {
2519            top_k: 3,
2520            ef_search: 50,
2521            pruning_threshold: f32::INFINITY,
2522            rerank_factor: None,
2523            score_fn: None,
2524            partition_filter: None,
2525            hybrid: None,
2526            column_filter: None,
2527        };
2528        let results = search(
2529            &table,
2530            &query,
2531            config,
2532            "embedding",
2533            dim as u32,
2534            Arc::clone(&catalog),
2535            Arc::clone(&store),
2536        )
2537        .await
2538        .unwrap();
2539        assert_eq!(results.len(), 3);
2540
2541        let table_meta = catalog.load_table(&table).await.unwrap();
2542        let batch = fetch_rows(
2543            &results,
2544            store,
2545            "embedding",
2546            dim as u32,
2547            &table_meta.schema_fields,
2548        )
2549        .await
2550        .unwrap();
2551
2552        let note_col = batch
2553            .column_by_name("note")
2554            .expect("evolved 'note' column must be present, not silently dropped");
2555        assert_eq!(note_col.len(), 3);
2556        assert_eq!(
2557            note_col.null_count(),
2558            3,
2559            "old files predate 'note' — every value must be null, not an error or a missing column"
2560        );
2561    }
2562
2563    /// Regression: the schema-projection fix above (`fetch_rows_projects_evolved_column_as_null`)
2564    /// initially introduced its own bug — `SchemaFiller::fill` was called with the *unfiltered*
2565    /// current-schema field list, which includes the vector column itself. Since
2566    /// `AilakeFileReader::read_parquet()` deliberately returns the vector column out-of-band
2567    /// (as `vectors: Vec<Vec<f32>>`, not as part of the tabular `RecordBatch`), the filler saw
2568    /// it as "missing" and injected a synthetic column for it — wrong-typed (`iceberg_type_to_arrow`'s
2569    /// `Utf8` fallback) and a duplicate of the real decoded vector column appended a few lines
2570    /// later, breaking pandas' arrow→pandas conversion for *every* `fetch_data=True`/`scan()`
2571    /// call, not just evolved-schema ones. Caught immediately by testing against real
2572    /// pandas conversion (not just the Rust arrow API) — this test guards it at the Rust level too.
2573    #[tokio::test]
2574    async fn fetch_rows_does_not_duplicate_vector_column() {
2575        let dir = TempDir::new().unwrap();
2576        let dim = 8usize;
2577        write_demo_table(&dir, dim, 8).await;
2578
2579        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2580        let catalog: Arc<dyn CatalogProvider> =
2581            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2582        let table = TableIdent::new("default", "table");
2583
2584        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2585        let config = SearchConfig {
2586            top_k: 3,
2587            ef_search: 50,
2588            pruning_threshold: f32::INFINITY,
2589            rerank_factor: None,
2590            score_fn: None,
2591            partition_filter: None,
2592            hybrid: None,
2593            column_filter: None,
2594        };
2595        let results = search(
2596            &table,
2597            &query,
2598            config,
2599            "embedding",
2600            dim as u32,
2601            Arc::clone(&catalog),
2602            Arc::clone(&store),
2603        )
2604        .await
2605        .unwrap();
2606
2607        let table_meta = catalog.load_table(&table).await.unwrap();
2608        let batch = fetch_rows(
2609            &results,
2610            store,
2611            "embedding",
2612            dim as u32,
2613            &table_meta.schema_fields,
2614        )
2615        .await
2616        .unwrap();
2617
2618        let batch_schema = batch.schema();
2619        let embedding_fields: Vec<_> = batch_schema
2620            .fields()
2621            .iter()
2622            .filter(|f| f.name() == "embedding")
2623            .collect();
2624        assert_eq!(
2625            embedding_fields.len(),
2626            1,
2627            "exactly one 'embedding' field expected, got: {:?}",
2628            batch_schema
2629        );
2630        assert!(
2631            matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2632            "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2633            embedding_fields[0].data_type()
2634        );
2635    }
2636}