Skip to main content

ailake_query/
scanner.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::sync::Arc;
3
4use rayon::prelude::*;
5use tracing::{debug, error, warn};
6
7use ailake_catalog::{CatalogProvider, DataFileEntry, IndexStatus, SchemaField, TableIdent};
8use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, RowId, VectorMetric};
9use ailake_file::AilakeFileReader;
10use ailake_index::AnyIndex;
11use ailake_store::Store;
12use ailake_vec::exact_distance;
13use arrow_array::{Array, RecordBatch};
14use bytes::Bytes;
15
16use crate::equality_delete::EqualityDeleteFilter;
17use crate::pruner::{BloomPruner, VectorPruner};
18use crate::schema_filler::SchemaFiller;
19
20/// Injectable per-result scoring function for hybrid ranking.
21///
22/// Called after HNSW retrieval with the HNSW distance and a single-row
23/// `RecordBatch` containing all Parquet columns for that result. Returns a
24/// replacement score (lower = better rank, same convention as distance).
25///
26/// Typical use: combine HNSW distance with recency and importance signals
27/// from the `episodic_columns` for agent memory tables:
28///
29/// ```rust,no_run
30/// use ailake_core::{hybrid_score, episodic_columns};
31/// use ailake_query::scanner::ScoreFn;
32/// use arrow_array::{RecordBatch, cast::AsArray};
33/// use arrow_array::types::Float32Type;
34///
35/// let score_fn = ScoreFn::new(|distance, row| {
36///     let recency = row
37///         .column_by_name(episodic_columns::RECENCY_WEIGHT)
38///         .and_then(|c| c.as_primitive_opt::<Float32Type>())
39///         .and_then(|a| a.iter().next().flatten())
40///         .unwrap_or(1.0);
41///     let importance = row
42///         .column_by_name(episodic_columns::IMPORTANCE_SCORE)
43///         .and_then(|c| c.as_primitive_opt::<Float32Type>())
44///         .and_then(|a| a.iter().next().flatten())
45///         .unwrap_or(1.0);
46///     hybrid_score(distance, recency, importance)
47/// });
48/// ```
49#[allow(clippy::type_complexity)]
50pub struct ScoreFn(pub std::sync::Arc<dyn Fn(f32, &RecordBatch) -> f32 + Send + Sync>);
51
52impl ScoreFn {
53    pub fn new(f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static) -> Self {
54        Self(std::sync::Arc::new(f))
55    }
56
57    #[inline]
58    pub fn call(&self, distance: f32, row: &RecordBatch) -> f32 {
59        (self.0)(distance, row)
60    }
61}
62
63impl Clone for ScoreFn {
64    fn clone(&self) -> Self {
65        Self(std::sync::Arc::clone(&self.0))
66    }
67}
68
69impl std::fmt::Debug for ScoreFn {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str("ScoreFn(<fn>)")
72    }
73}
74
75#[derive(Debug, Clone)]
76pub struct SearchConfig {
77    pub top_k: usize,
78    pub ef_search: usize,
79    /// Maximum distance from query to file centroid edge for a file to be searched.
80    /// Files where `distance(query, centroid) - radius > pruning_threshold` are skipped.
81    /// Set to `f32::INFINITY` to disable pruning (scan all files).
82    pub pruning_threshold: f32,
83    /// When `Some(factor)`, fetch `top_k * factor` candidates from the HNSW index and
84    /// rerank them using exact F32 distances before truncating to `top_k`.
85    /// Corrects the approximation error introduced by PQ-compressed HNSW distances.
86    /// `None` (default) disables reranking.
87    pub rerank_factor: Option<usize>,
88    /// Hybrid BM25+vector search configuration.
89    ///
90    /// When set, the pipeline loads global IDF stats from the table's BM25 stats file,
91    /// fetches a larger candidate pool from HNSW (`candidate_pool` or `10 * top_k`),
92    /// scores each candidate with BM25 against `query_text`, then fuses vector distance
93    /// and BM25 score via RRF (default) or linear combination.
94    ///
95    /// The BM25 stats file (`metadata/ailake_bm25_stats.bin`) is populated automatically
96    /// by `TableWriter` when `bm25_text_column` is configured. If absent, pure vector
97    /// distances are used (BM25 scores default to 0).
98    pub hybrid: Option<crate::bm25::HybridConfig>,
99    /// Optional scoring function for hybrid ranking.
100    ///
101    /// When set, the search pipeline reads the Parquet row for each HNSW
102    /// candidate and calls `score_fn(distance, &single_row_batch)`. The
103    /// returned value replaces `distance` in `SearchResult` and determines
104    /// final ranking (lower = better).
105    ///
106    /// If `rerank_factor` is also set, `score_fn` receives the exact
107    /// (non-approximated) distance from the reranking step.
108    ///
109    /// Use `ScoreFn::new(|d, row| ...)` to construct. See `ScoreFn` docs
110    /// for an example using `hybrid_score` with episodic memory columns.
111    pub score_fn: Option<ScoreFn>,
112    /// Partition filter: only search files whose `DataFileEntry::partition_value`
113    /// matches this string. `None` searches all files (no partition pruning).
114    /// Set to `agent_id` in Agent.recall() for per-agent isolated search.
115    pub partition_filter: Option<String>,
116}
117
118impl Default for SearchConfig {
119    fn default() -> Self {
120        Self {
121            top_k: 10,
122            ef_search: 50,
123            pruning_threshold: f32::INFINITY,
124            rerank_factor: None,
125            score_fn: None,
126            partition_filter: None,
127            hybrid: None,
128        }
129    }
130}
131
132impl SearchConfig {
133    pub fn with_pruning(mut self, threshold: f32) -> Self {
134        self.pruning_threshold = threshold;
135        self
136    }
137
138    pub fn with_reranking(mut self, factor: usize) -> Self {
139        self.rerank_factor = Some(factor);
140        self
141    }
142
143    pub fn with_score_fn(
144        mut self,
145        f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static,
146    ) -> Self {
147        self.score_fn = Some(ScoreFn::new(f));
148        self
149    }
150
151    pub fn with_hybrid(mut self, cfg: crate::bm25::HybridConfig) -> Self {
152        self.hybrid = Some(cfg);
153        self
154    }
155}
156
157#[derive(Debug)]
158pub struct SearchResult {
159    pub row_id: RowId,
160    pub distance: f32,
161    pub file_path: String,
162}
163
164/// Search across all files in the latest snapshot, with geometric pruning.
165///
166/// Flow:
167/// 1. Load file list from catalog (includes centroid metadata)
168/// 2. Prune files whose centroid + radius cannot contain a result within `pruning_threshold`
169/// 3. For surviving files: load bytes, deserialize HNSW, run top-k search
170/// 4. Global merge of all per-file top-k lists, return global top-k
171pub async fn search(
172    table: &TableIdent,
173    query: &[f32],
174    config: SearchConfig,
175    vector_column: &str,
176    dim: u32,
177    catalog: Arc<dyn CatalogProvider>,
178    store: Arc<dyn Store>,
179) -> AilakeResult<Vec<SearchResult>> {
180    // Get file metadata (includes centroid info) without reading any data files
181    let all_files = catalog.list_files(table, None).await?;
182
183    // Determine vector metric from table metadata for correct distance computation
184    let table_meta = catalog.load_table(table).await?;
185
186    // Validate query dim against the column's stored dim.
187    // Primary column: use `ailake.vector-dim`. Secondary columns: use `ailake.dim-<col>`.
188    // Skip validation when the column has no stored dim (e.g. old tables written before
189    // multi-column support).
190    let primary_col = table_meta
191        .properties
192        .get("ailake.vector-column")
193        .map(String::as_str)
194        .unwrap_or("");
195    let stored_dim_key = if vector_column == primary_col {
196        "ailake.vector-dim".to_string()
197    } else {
198        format!("ailake.dim-{vector_column}")
199    };
200    if let Some(table_dim_str) = table_meta.properties.get(&stored_dim_key) {
201        if let Ok(table_dim) = table_dim_str.parse::<u32>() {
202            let query_dim = query.len() as u32;
203            if query_dim != table_dim {
204                let table_model = table_meta
205                    .properties
206                    .get(EmbeddingModelInfo::property_key())
207                    .cloned()
208                    .unwrap_or_else(|| format!("dim={}", table_dim));
209                return Err(AilakeError::ModelMismatch {
210                    table_model,
211                    table_dim,
212                    batch_model: format!("query dim={}", query_dim),
213                    batch_dim: query_dim,
214                });
215            }
216        }
217    }
218
219    // Metric: prefer per-column `ailake.metric-<col>`, fall back to primary metric.
220    let metric_key = if vector_column == primary_col {
221        "ailake.vector-metric".to_string()
222    } else {
223        format!("ailake.metric-{vector_column}")
224    };
225    let metric = parse_metric(
226        table_meta
227            .properties
228            .get(&metric_key)
229            .or_else(|| table_meta.properties.get("ailake.vector-metric"))
230            .map(String::as_str)
231            .unwrap_or("cosine"),
232    );
233
234    // Partition pruning: skip files not belonging to the requested partition value.
235    let all_files = if let Some(ref pv) = config.partition_filter {
236        let before = all_files.len();
237        let filtered: Vec<_> = all_files
238            .into_iter()
239            .filter(|f| f.partition_value.as_deref() == Some(pv.as_str()))
240            .collect();
241        debug!(
242            "ailake: partition pruning '{}' — {}/{} files survive",
243            pv,
244            filtered.len(),
245            before
246        );
247        filtered
248    } else {
249        all_files
250    };
251
252    // Geometric pruning: skip files whose centroid is too far from the query
253    let total_files = all_files.len();
254    let surviving_files = VectorPruner::prune(all_files, query, metric, config.pruning_threshold);
255    debug!(
256        "ailake: geometric pruning — {}/{} files survive (threshold={})",
257        surviving_files.len(),
258        total_files,
259        config.pruning_threshold
260    );
261
262    // Phase F — Bloom pruning: for hybrid queries, load per-file Bloom filters from
263    // the Puffin stats file and skip files where no query term can be present.
264    let surviving_files = if let Some(ref h) = config.hybrid {
265        let bloom_map = load_bloom_map(&table_meta, store.as_ref()).await;
266        if !bloom_map.is_empty() {
267            BloomPruner::prune(surviving_files, &h.query_text, &bloom_map)
268        } else {
269            surviving_files
270        }
271    } else {
272        surviving_files
273    };
274
275    // Phase H: load equality delete filter for this snapshot.
276    // Reads delete manifests from the catalog and downloads each equality delete Avro file.
277    // Empty filter is a no-op. On error: warn and continue with empty filter (data visible).
278    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
279        Ok(edfs) if !edfs.is_empty() => {
280            match EqualityDeleteFilter::from_files(&store, &edfs).await {
281                Ok(f) => f,
282                Err(e) => {
283                    warn!("ailake: equality delete filter build failed: {e} — rows may appear");
284                    EqualityDeleteFilter::empty()
285                }
286            }
287        }
288        _ => EqualityDeleteFilter::empty(),
289    };
290
291    // Compute candidate pool: hybrid needs a larger pool for BM25 re-ranking.
292    let candidate_k = match (&config.hybrid, config.rerank_factor) {
293        (Some(h), rf) => {
294            let pool = h.candidate_pool.unwrap_or(config.top_k * 10);
295            pool.max(rf.map_or(config.top_k, |f| f * config.top_k))
296        }
297        (None, Some(factor)) => config.top_k * factor,
298        (None, None) => config.top_k,
299    };
300
301    let use_hybrid = config.hybrid.is_some();
302
303    // Load BM25 stats from the table's stats file when hybrid search is active.
304    let bm25_stats: Option<crate::bm25::IdfStats> = if let Some(ref h) = config.hybrid {
305        if h.text_columns.is_empty() {
306            None
307        } else {
308            let stats_path = table_meta
309                .properties
310                .get(crate::bm25::BM25_STATS_PATH_PROP)
311                .map(String::as_str)
312                .unwrap_or(crate::bm25::BM25_STATS_FILE);
313            match store.get(stats_path).await {
314                Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).ok(),
315                Err(_) => {
316                    debug!(
317                        "ailake: BM25 stats not found at '{}' — falling back to empty corpus IDF",
318                        stats_path
319                    );
320                    None
321                }
322            }
323        }
324    } else {
325        None
326    };
327
328    // raw_candidates: (row_id, vec_dist, file_path, bm25_text) for hybrid re-ranking.
329    // Only populated when use_hybrid = true; otherwise all_results is populated directly.
330    let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
331    let mut all_results: Vec<SearchResult> = Vec::new();
332
333    // Observability for the flat-scan fallback below: `deferred` counts files still
334    // being indexed by our own deferred-write path (expected, transient). `unexpected`
335    // counts files with no AI-Lake index that are NOT in that state — most likely
336    // rewritten by a generic Iceberg engine (Spark/Trino OPTIMIZE, DuckDB) with no
337    // knowledge of AI-Lake. Those files still return correct results (flat scan is
338    // exact), just O(N) instead of O(log N), and silently forever unless recompacted.
339    let mut flat_scan_deferred = 0usize;
340    let mut flat_scan_unexpected = 0usize;
341
342    for file_entry in &surviving_files {
343        let file_bytes: Bytes = store.get(&file_entry.path).await?;
344        let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
345
346        // V3 Deletion Vector: fetch bitmap once per file (range GET from Puffin .dvd).
347        // None for V2 tables or V3 files with no deletes. On fetch error: warn + continue
348        // without mask (surfacing deleted rows is safer than hard-failing the search).
349        let dv_bitmap: Option<roaring::RoaringBitmap> =
350            if let Some(ref dv) = file_entry.deletion_vector {
351                match crate::dv::load_deletion_vector(&store, dv).await {
352                    Ok(bm) => {
353                        debug!(
354                            "ailake: DV loaded ({} deletions) for {}",
355                            bm.len(),
356                            file_entry.path
357                        );
358                        Some(bm)
359                    }
360                    Err(e) => {
361                        warn!(
362                            "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
363                            file_entry.path
364                        );
365                        None
366                    }
367                }
368            } else {
369                None
370            };
371
372        // Parquet read required for: flat scan fallback, exact reranking, score_fn, hybrid,
373        // or when equality delete filter must check column values per-row.
374        let need_parquet = file_entry.index_status == IndexStatus::Indexing
375            || !reader.is_ailake_file()
376            || config.rerank_factor.is_some()
377            || config.score_fn.is_some()
378            || use_hybrid
379            || !eq_del_filter.is_empty();
380
381        if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
382            match file_entry.index_status {
383                IndexStatus::Indexing => {
384                    flat_scan_deferred += 1;
385                    debug!(
386                        "ailake: flat scan fallback for {} — index build in progress \
387                         (deferred write, expected to resolve once background job completes)",
388                        file_entry.path
389                    );
390                }
391                IndexStatus::Failed => {
392                    // An internal indexing failure, not a foreign write — the file still
393                    // has a real (foreign-write-style) centroid_b64 from make_data_file_entry
394                    // *_indexing, since only index_status/index_error get patched on failure
395                    // (see writer.rs::patch_index_failed). Attributing this to an external
396                    // engine would send on-call looking for a Spark job that never ran.
397                    flat_scan_unexpected += 1;
398                    warn!(
399                        "ailake: flat scan fallback for {} — background index build failed \
400                         permanently ({}); serving via flat scan until the next compaction \
401                         rebuilds the index",
402                        file_entry.path,
403                        file_entry
404                            .index_error
405                            .as_deref()
406                            .unwrap_or("no error recorded")
407                    );
408                }
409                IndexStatus::Ready => {
410                    // Ready but no loadable index/footer: either a genuine foreign write
411                    // (no centroid_b64 — see CompactionPlanner::plan's detection) or a rare
412                    // internally-inconsistent state (Ready without ever getting an index).
413                    flat_scan_unexpected += 1;
414                    if file_entry.is_foreign() {
415                        warn!(
416                            "ailake: flat scan fallback for {} — file has no AI-Lake index \
417                             and no centroid; likely rewritten by a generic Iceberg engine \
418                             (OPTIMIZE / rewrite_data_files) with no knowledge of AI-Lake. \
419                             Results are still correct (exact O(N) scan), but degraded until \
420                             this file is recompacted by the AI-Lake SDK",
421                            file_entry.path
422                        );
423                    } else {
424                        warn!(
425                            "ailake: flat scan fallback for {} — marked Ready but has no \
426                             loadable AI-Lake index despite a recorded centroid; internal \
427                             inconsistency, not an external rewrite. Run compaction to rebuild",
428                            file_entry.path
429                        );
430                    }
431                }
432            }
433            let (raw_batch, raw_vectors) = reader.read_parquet()?;
434            // Phase G: inject columns added via schema evolution with initial_default values.
435            let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
436            for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
437                // Skip rows marked as deleted by a V3 Deletion Vector.
438                if dv_bitmap
439                    .as_ref()
440                    .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
441                {
442                    continue;
443                }
444                // Phase H: skip rows matched by an equality delete predicate.
445                if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
446                    continue;
447                }
448                if use_hybrid {
449                    let text = extract_text_for_row(
450                        &batch,
451                        row_id.as_u64() as usize,
452                        config.hybrid.as_ref().unwrap(),
453                    );
454                    raw_candidates.push((row_id, distance, file_entry.path.clone(), text));
455                } else {
456                    let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
457                    all_results.push(SearchResult {
458                        row_id,
459                        distance: final_score,
460                        file_path: file_entry.path.clone(),
461                    });
462                }
463            }
464            continue;
465        }
466
467        let index = reader.load_any_index_for_column(vector_column)?;
468        let local_results = index.search(query, candidate_k, config.ef_search);
469
470        let parquet_data = if need_parquet {
471            let (raw_batch, raw_vecs) = reader.read_parquet()?;
472            // Phase G: fill missing columns for old files before score_fn / hybrid BM25.
473            let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
474            Some((filled, raw_vecs))
475        } else {
476            None
477        };
478
479        for (row_id, approx_dist) in local_results {
480            // Skip rows marked as deleted by a V3 Deletion Vector.
481            if dv_bitmap
482                .as_ref()
483                .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
484            {
485                continue;
486            }
487            let idx = row_id.as_u64() as usize;
488            // Phase H: skip rows matched by an equality delete predicate.
489            // parquet_data is always loaded when eq_del_filter is non-empty (see need_parquet).
490            if let Some((ref batch, _)) = parquet_data {
491                if eq_del_filter.should_delete_row(batch, idx) {
492                    continue;
493                }
494            }
495
496            let distance = if config.rerank_factor.is_some() {
497                match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
498                    Some(v) => exact_distance(metric, query, v),
499                    None => {
500                        error!(
501                            "ailake: invariant violated — row_id {} out of bounds \
502                             (file={}); Parquet and HNSW node count out of sync; \
503                             run compaction to rebuild",
504                            idx, file_entry.path
505                        );
506                        f32::INFINITY
507                    }
508                }
509            } else {
510                approx_dist
511            };
512
513            if use_hybrid {
514                let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
515                    extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
516                });
517                raw_candidates.push((row_id, distance, file_entry.path.clone(), text));
518            } else {
519                let final_score = if let Some((ref batch, _)) = parquet_data {
520                    apply_score_fn(&config.score_fn, distance, row_id, batch)
521                } else {
522                    distance
523                };
524                all_results.push(SearchResult {
525                    row_id,
526                    distance: final_score,
527                    file_path: file_entry.path.clone(),
528                });
529            }
530        }
531    }
532
533    if flat_scan_unexpected > 0 {
534        warn!(
535            "ailake: search degraded — {}/{} files scanned without an AI-Lake index \
536             (unexpected — likely external rewrites; {} more in expected deferred-indexing \
537             state). Run compaction to restore O(log N) search on affected files",
538            flat_scan_unexpected,
539            surviving_files.len(),
540            flat_scan_deferred
541        );
542    } else if flat_scan_deferred > 0 {
543        debug!(
544            "ailake: search — {}/{} files scanned via flat fallback (deferred indexing)",
545            flat_scan_deferred,
546            surviving_files.len()
547        );
548    }
549
550    // Hybrid BM25 fusion: applied after all HNSW candidates are collected.
551    if let Some(ref h) = config.hybrid {
552        let empty_stats = crate::bm25::IdfStats::default();
553        let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
554        let scorer = crate::bm25::BM25Scorer::new(stats);
555
556        // Compute BM25 scores before sorting so they stay positionally aligned.
557        let bm25_scores_pre: Vec<f32> = raw_candidates
558            .iter()
559            .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
560            .collect();
561
562        // Zip BM25 scores into candidates so they sort together — avoids index mismatch
563        // when raw_candidates is reordered by distance below.
564        let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
565            raw_candidates.into_iter().zip(bm25_scores_pre).collect();
566        candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
567        let n = candidates_with_bm25.len();
568
569        let vec_ranks: Vec<usize> = (0..n).collect();
570
571        // Rank by BM25 score descending using post-sort aligned scores.
572        let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
573            .iter()
574            .map(|(_, b)| *b)
575            .enumerate()
576            .collect();
577        bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
578        let mut bm25_rank_of = vec![0usize; n];
579        for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
580            bm25_rank_of[*idx] = rank;
581        }
582
583        use crate::bm25::{linear_score, rrf_score, HybridFusion};
584
585        let fused: Vec<f32> = match h.fusion {
586            HybridFusion::Rrf => vec_ranks
587                .iter()
588                .enumerate()
589                .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
590                .collect(),
591            HybridFusion::Linear => {
592                let min_d = candidates_with_bm25
593                    .iter()
594                    .map(|(r, _)| r.1)
595                    .fold(f32::INFINITY, f32::min);
596                let max_d = candidates_with_bm25
597                    .iter()
598                    .map(|(r, _)| r.1)
599                    .fold(f32::NEG_INFINITY, f32::max);
600                let min_b = candidates_with_bm25
601                    .iter()
602                    .map(|(_, b)| *b)
603                    .fold(f32::INFINITY, f32::min);
604                let max_b = candidates_with_bm25
605                    .iter()
606                    .map(|(_, b)| *b)
607                    .fold(f32::NEG_INFINITY, f32::max);
608                candidates_with_bm25
609                    .iter()
610                    .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
611                    .collect()
612            }
613        };
614
615        for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
616            all_results.push(SearchResult {
617                row_id,
618                distance: fused[i],
619                file_path,
620            });
621        }
622
623        // For RRF: lower (more negative) = better; for Linear: lower = better. Same convention.
624        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
625    } else {
626        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
627    }
628
629    all_results.truncate(config.top_k);
630    Ok(all_results)
631}
632
633/// Extract concatenated text from specified columns for a single row.
634fn extract_text_for_row(
635    batch: &RecordBatch,
636    row_idx: usize,
637    hybrid: &crate::bm25::HybridConfig,
638) -> String {
639    use arrow_array::cast::AsArray;
640    hybrid
641        .text_columns
642        .iter()
643        .filter_map(|col| {
644            batch.column_by_name(col).and_then(|arr| {
645                arr.as_string_opt::<i32>().and_then(|sa| {
646                    if row_idx < sa.len() && sa.is_valid(row_idx) {
647                        Some(sa.value(row_idx).to_string())
648                    } else {
649                        None
650                    }
651                })
652            })
653        })
654        .collect::<Vec<_>>()
655        .join(" ")
656}
657
658/// One query arm in a cross-modal search.
659#[derive(Debug, Clone)]
660pub struct ModalQuery<'a> {
661    /// Vector column to search (must exist in the table).
662    pub column: &'a str,
663    /// Query vector for this modality.
664    pub query: &'a [f32],
665    /// Relative weight applied in the RRF formula: `weight / (k + rank)`.
666    /// `1.0` means equal weight across all modalities.
667    pub weight: f32,
668    /// Dimensionality of this column's vectors. `0` = auto-detect from table metadata
669    /// (`ailake.dim-<column>` for secondary columns, `ailake.vector-dim` for primary).
670    pub dim: u32,
671}
672
673/// Fusion method for combining results from multiple vector columns.
674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675pub enum FusionMethod {
676    /// Reciprocal Rank Fusion: `score(d) = Σ weight_i / (k + rank_i(d))`.
677    /// `k = 60` (standard). Returned `SearchResult.distance` = `-rrf_score`
678    /// so that sort-ascending-by-distance gives the correct RRF ranking.
679    Rrf,
680}
681
682/// Cross-modal search: run independent HNSW searches across N vector columns,
683/// then fuse per-column ranked lists using Reciprocal Rank Fusion.
684///
685/// Each `ModalQuery` specifies a column name, its query vector, RRF weight, and dim.
686/// When `ModalQuery.dim == 0`, the dim is auto-detected from `ailake.dim-<col>` /
687/// `ailake.vector-dim` in table metadata.
688/// Results are de-duplicated by `(file_path, row_id)` and ranked by aggregate
689/// RRF score. `SearchResult.distance` stores `-rrf_score` (lower = better) so
690/// existing sort-ascending callers get the correct ordering.
691pub async fn search_multimodal(
692    table: &TableIdent,
693    queries: &[ModalQuery<'_>],
694    config: SearchConfig,
695    catalog: Arc<dyn CatalogProvider>,
696    store: Arc<dyn Store>,
697    fusion: FusionMethod,
698) -> AilakeResult<Vec<SearchResult>> {
699    use std::collections::HashMap;
700
701    if queries.is_empty() {
702        return Err(AilakeError::InvalidArgument(
703            "search_multimodal requires at least one ModalQuery".into(),
704        ));
705    }
706
707    // Load table metadata once for dim auto-detection and metric resolution.
708    let table_meta = catalog.load_table(table).await?;
709    let primary_col = table_meta
710        .properties
711        .get("ailake.vector-column")
712        .cloned()
713        .unwrap_or_default();
714    let primary_dim: u32 = table_meta
715        .properties
716        .get("ailake.vector-dim")
717        .and_then(|s| s.parse().ok())
718        .unwrap_or(0);
719
720    // Fetch more candidates per column so RRF has enough to fuse.
721    let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
722
723    let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
724    for mq in queries {
725        // Resolve dim: caller-supplied > per-column property > primary column dim.
726        let resolved_dim = if mq.dim > 0 {
727            mq.dim
728        } else if mq.column == primary_col {
729            primary_dim
730        } else {
731            table_meta
732                .properties
733                .get(&format!("ailake.dim-{}", mq.column))
734                .and_then(|s| s.parse().ok())
735                .unwrap_or(mq.query.len() as u32)
736        };
737
738        let col_config = SearchConfig {
739            top_k: per_col_k,
740            ef_search: config.ef_search,
741            pruning_threshold: config.pruning_threshold,
742            rerank_factor: config.rerank_factor,
743            score_fn: None,
744            partition_filter: config.partition_filter.clone(),
745            hybrid: None,
746        };
747        let results = search(
748            table,
749            mq.query,
750            col_config,
751            mq.column,
752            resolved_dim,
753            catalog.clone(),
754            store.clone(),
755        )
756        .await?;
757        per_col_results.push((mq.weight, results));
758    }
759
760    // RRF fusion: accumulate score per (file_path, row_id).
761    const K: f32 = 60.0;
762    let mut scores: HashMap<(String, u64), f32> = HashMap::new();
763
764    for (weight, results) in &per_col_results {
765        for (rank, r) in results.iter().enumerate() {
766            let key = (r.file_path.clone(), r.row_id.as_u64());
767            let rrf = weight / (K + rank as f32 + 1.0);
768            *scores.entry(key).or_insert(0.0) += rrf;
769        }
770    }
771
772    // Build SearchResult list sorted by descending RRF score.
773    // Store `-rrf_score` as `.distance` so callers sorting ascending get correct order.
774    let all_files = catalog.list_files(table, None).await?;
775    let _ = all_files; // centroid not needed for fusion — just need file_path+row_id
776
777    // Collect unique candidates: prefer the row's appearance in the first column's results.
778    let mut seen: HashMap<(String, u64), f32> = HashMap::new();
779    for (_, results) in &per_col_results {
780        for r in results {
781            let key = (r.file_path.clone(), r.row_id.as_u64());
782            let rrf_score = *scores.get(&key).unwrap_or(&0.0);
783            seen.entry(key).or_insert(rrf_score);
784        }
785    }
786
787    let mut fused: Vec<SearchResult> = seen
788        .into_iter()
789        .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
790            row_id: RowId::new(row_id_u64),
791            distance: -rrf_score,
792            file_path,
793        })
794        .collect();
795
796    fused.sort_by(|a, b| {
797        a.distance
798            .partial_cmp(&b.distance)
799            .unwrap_or(std::cmp::Ordering::Equal)
800    });
801    fused.truncate(config.top_k);
802
803    let _ = fusion; // only RRF implemented; enum is extensible
804
805    Ok(fused)
806}
807
808/// Apply `score_fn` to a single result row, or return `distance` unchanged.
809///
810/// Slices the batch to a 1-row RecordBatch at `row_id` and calls the fn.
811/// If `score_fn` is `None` or the row index is out of bounds, returns `distance`.
812#[inline]
813fn apply_score_fn(
814    score_fn: &Option<ScoreFn>,
815    distance: f32,
816    row_id: RowId,
817    batch: &RecordBatch,
818) -> f32 {
819    match score_fn {
820        None => distance,
821        Some(f) => {
822            let idx = row_id.as_u64() as usize;
823            if idx < batch.num_rows() {
824                f.call(distance, &batch.slice(idx, 1))
825            } else {
826                distance
827            }
828        }
829    }
830}
831
832/// Brute-force top-k search over raw vectors. Used for Indexing shards.
833fn flat_search(
834    raw: &[Vec<f32>],
835    query: &[f32],
836    top_k: usize,
837    metric: VectorMetric,
838) -> Vec<(RowId, f32)> {
839    let mut results: Vec<(RowId, f32)> = raw
840        .iter()
841        .enumerate()
842        .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
843        .collect();
844    results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
845    results.truncate(top_k);
846    results
847}
848
849fn parse_metric(s: &str) -> VectorMetric {
850    match s {
851        "euclidean" => VectorMetric::Euclidean,
852        "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
853        "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
854        _ => VectorMetric::Cosine,
855    }
856}
857
858/// Pre-loaded search session: all HNSW indexes loaded into memory once.
859///
860/// Useful for benchmarks and servers that issue many queries against the same
861/// snapshot. Avoids re-loading and re-deserializing indexes on every call.
862///
863/// **Deleted rows are NOT filtered here**: unlike [`search`]/[`search_text`],
864/// this session does not load deletion vectors or equality delete files —
865/// rows removed via `delete_rows`/`delete_where` still appear in results.
866/// Use [`search`] when delete visibility matters; this type trades that for
867/// raw throughput on static snapshots (its benchmark use case).
868pub struct SearchSession {
869    shards: Vec<LoadedShard>,
870    metric: VectorMetric,
871}
872
873struct LoadedShard {
874    entry: DataFileEntry,
875    /// None when the shard is still being indexed (IndexStatus::Indexing).
876    index: Option<AnyIndex>,
877    /// Raw F32 vectors: always present for Indexing shards (flat scan), optionally
878    /// present for Ready shards when `load_raw = true` (reranking).
879    raw_vectors: Option<Vec<Vec<f32>>>,
880}
881
882impl SearchSession {
883    /// Load all indexes for the latest snapshot into memory.
884    ///
885    /// Pass `load_raw = true` when reranking will be used (`rerank_factor` is
886    /// `Some`); it reads the full parquet columns so exact distances are
887    /// available without extra I/O during `search_query`.
888    pub async fn load(
889        table: &TableIdent,
890        vector_column: &str,
891        dim: u32,
892        catalog: Arc<dyn CatalogProvider>,
893        store: Arc<dyn Store>,
894        load_raw: bool,
895    ) -> AilakeResult<Self> {
896        let all_files = catalog.list_files(table, None).await?;
897        let table_meta = catalog.load_table(table).await?;
898        let metric = parse_metric(
899            table_meta
900                .properties
901                .get("ailake.vector-metric")
902                .map(String::as_str)
903                .unwrap_or("cosine"),
904        );
905
906        let mut shards = Vec::with_capacity(all_files.len());
907        for entry in all_files {
908            let file_bytes: Bytes = store.get(&entry.path).await?;
909            let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
910
911            if entry.index_status == IndexStatus::Indexing {
912                // HNSW not yet built — load raw vectors for flat scan.
913                let (_, raw_vecs) = reader.read_parquet()?;
914                shards.push(LoadedShard {
915                    entry,
916                    index: None,
917                    raw_vectors: Some(raw_vecs),
918                });
919            } else if reader.is_ailake_file() {
920                let mut index = reader.load_any_index_for_column(vector_column)?;
921                let raw_vectors = if load_raw {
922                    index.quantize_to_f16();
923                    let (_, vecs) = reader.read_parquet()?;
924                    Some(vecs)
925                } else {
926                    None
927                };
928                shards.push(LoadedShard {
929                    entry,
930                    index: Some(index),
931                    raw_vectors,
932                });
933            }
934        }
935
936        Ok(Self { shards, metric })
937    }
938
939    /// Number of loaded shards.
940    pub fn shard_count(&self) -> usize {
941        self.shards.len()
942    }
943
944    /// Search multiple queries in one call.
945    ///
946    /// For shards with raw vectors (Indexing or reranking): dispatches to GPU batch
947    /// matmul when a CUDA device is available, falling back to CPU flat scan.
948    /// For indexed shards (HNSW / IVF-PQ): rayon parallel-map over queries — graph
949    /// traversal is inherently sequential and has no GPU batch path.
950    ///
951    /// Returns one `Vec<SearchResult>` per input query, in the same order.
952    pub fn search_batch(
953        &self,
954        queries: &[Vec<f32>],
955        config: &SearchConfig,
956    ) -> Vec<Vec<SearchResult>> {
957        if queries.is_empty() {
958            return vec![];
959        }
960
961        let n_queries = queries.len();
962        let candidate_k = match config.rerank_factor {
963            Some(factor) => config.top_k * factor,
964            None => config.top_k,
965        };
966        let use_nvidia = ailake_index::hardware::detect_cuda();
967        let use_amd = ailake_index::hardware::detect_rocm();
968
969        // Accumulate per-query results across all shards.
970        let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
971
972        for shard in &self.shards {
973            if let Some(raw) = &shard.raw_vectors {
974                // Flat-scan shard — try GPU batch path (NVIDIA first, then AMD ROCm).
975                if !raw.is_empty() {
976                    let dim = raw[0].len();
977                    let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
978                    let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
979                    let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
980
981                    let gpu_batch = if use_nvidia {
982                        ailake_index::gpu::try_nvidia_search_batch(
983                            &q_refs,
984                            &row_ids,
985                            &flat,
986                            dim,
987                            self.metric,
988                            candidate_k,
989                        )
990                    } else if use_amd {
991                        ailake_index::gpu::try_rocm_search_batch(
992                            &q_refs,
993                            &row_ids,
994                            &flat,
995                            dim,
996                            self.metric,
997                            candidate_k,
998                        )
999                    } else {
1000                        None
1001                    };
1002
1003                    if let Some(batch) = gpu_batch {
1004                        for (qi, results) in batch.into_iter().enumerate() {
1005                            for (row_id, distance) in results {
1006                                all_results[qi].push(SearchResult {
1007                                    row_id,
1008                                    distance,
1009                                    file_path: shard.entry.path.clone(),
1010                                });
1011                            }
1012                        }
1013                        continue;
1014                    }
1015                }
1016
1017                // CPU fallback for flat scan.
1018                for (qi, query) in queries.iter().enumerate() {
1019                    for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1020                        all_results[qi].push(SearchResult {
1021                            row_id,
1022                            distance,
1023                            file_path: shard.entry.path.clone(),
1024                        });
1025                    }
1026                }
1027            } else if let Some(index) = &shard.index {
1028                // Indexed shard — rayon parallel-map over queries.
1029                let shard_results: Vec<Vec<SearchResult>> = queries
1030                    .par_iter()
1031                    .map(|query| {
1032                        index
1033                            .search(query, candidate_k, config.ef_search)
1034                            .into_iter()
1035                            .map(|(row_id, distance)| SearchResult {
1036                                row_id,
1037                                distance,
1038                                file_path: shard.entry.path.clone(),
1039                            })
1040                            .collect()
1041                    })
1042                    .collect();
1043
1044                for (qi, results) in shard_results.into_iter().enumerate() {
1045                    all_results[qi].extend(results);
1046                }
1047            }
1048        }
1049
1050        // Sort + truncate per query.
1051        for results in &mut all_results {
1052            results.sort_by(|a, b| {
1053                a.distance
1054                    .partial_cmp(&b.distance)
1055                    .unwrap_or(std::cmp::Ordering::Equal)
1056            });
1057            results.truncate(config.top_k);
1058        }
1059
1060        all_results
1061    }
1062
1063    /// Search using pre-loaded indexes. No I/O — pure in-memory search.
1064    pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1065        let candidate_k = match config.rerank_factor {
1066            Some(factor) => config.top_k * factor,
1067            None => config.top_k,
1068        };
1069
1070        let mut all_results: Vec<SearchResult> = self
1071            .shards
1072            .par_iter()
1073            .flat_map(|shard| {
1074                // Geometric pruning per shard.
1075                if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1076                    let dist = match self.metric {
1077                        VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1078                            ailake_vec::cosine_distance(query, &centroid.values)
1079                        }
1080                        VectorMetric::Euclidean => {
1081                            ailake_vec::euclidean_distance(query, &centroid.values)
1082                        }
1083                        VectorMetric::DotProduct => {
1084                            -ailake_vec::dot_product(query, &centroid.values)
1085                        }
1086                    };
1087                    if dist - centroid.radius > config.pruning_threshold {
1088                        return vec![];
1089                    }
1090                }
1091
1092                if let Some(index) = &shard.index {
1093                    // Ready shard: HNSW or IVF-PQ search (dispatched by AnyIndex).
1094                    let local_results = index.search(query, candidate_k, config.ef_search);
1095                    if config.rerank_factor.is_some() {
1096                        if let Some(raw) = &shard.raw_vectors {
1097                            local_results
1098                                .into_iter()
1099                                .map(|(row_id, _approx_dist)| {
1100                                    let idx = row_id.as_u64() as usize;
1101                                    let exact_dist = raw
1102                                        .get(idx)
1103                                        .map(|v| exact_distance(self.metric, query, v))
1104                                        .unwrap_or(f32::INFINITY);
1105                                    SearchResult {
1106                                        row_id,
1107                                        distance: exact_dist,
1108                                        file_path: shard.entry.path.clone(),
1109                                    }
1110                                })
1111                                .collect()
1112                        } else {
1113                            local_results
1114                                .into_iter()
1115                                .map(|(row_id, distance)| SearchResult {
1116                                    row_id,
1117                                    distance,
1118                                    file_path: shard.entry.path.clone(),
1119                                })
1120                                .collect()
1121                        }
1122                    } else {
1123                        local_results
1124                            .into_iter()
1125                            .map(|(row_id, distance)| SearchResult {
1126                                row_id,
1127                                distance,
1128                                file_path: shard.entry.path.clone(),
1129                            })
1130                            .collect()
1131                    }
1132                } else if let Some(raw) = &shard.raw_vectors {
1133                    // Indexing shard: exact flat scan.
1134                    flat_search(raw, query, candidate_k, self.metric)
1135                        .into_iter()
1136                        .map(|(row_id, distance)| SearchResult {
1137                            row_id,
1138                            distance,
1139                            file_path: shard.entry.path.clone(),
1140                        })
1141                        .collect()
1142                } else {
1143                    vec![]
1144                }
1145            })
1146            .collect();
1147
1148        all_results.sort_by(|a, b| {
1149            a.distance
1150                .partial_cmp(&b.distance)
1151                .unwrap_or(std::cmp::Ordering::Equal)
1152        });
1153        all_results.truncate(config.top_k);
1154        all_results
1155    }
1156}
1157
1158/// Pure BM25 full-text search across all Parquet files in the table.
1159///
1160/// Scans every surviving file (O(N) complexity), scores each row with BM25 against
1161/// `query_text`, and returns the global top-k by score. IDF stats are loaded from
1162/// `metadata/ailake_bm25_stats.bin` (written by `TableWriter` when `bm25_text_column`
1163/// is configured). If the stats file is absent, IDF defaults to an empty corpus
1164/// (all terms treated as maximally rare — directionally correct but less precise).
1165///
1166/// For pure-lexical search at scale (millions of rows, hundreds of files), consider
1167/// using SQL `LIKE` / `ILIKE` via DuckDB/Trino over the Iceberg-compatible table.
1168/// This function is best suited for small-medium tables or as a lexical complement
1169/// to `search()` for tables where the document count per file is manageable.
1170pub async fn search_text(
1171    table: &TableIdent,
1172    query_text: &str,
1173    text_columns: &[&str],
1174    top_k: usize,
1175    catalog: Arc<dyn CatalogProvider>,
1176    store: Arc<dyn Store>,
1177    partition_filter: Option<&str>,
1178) -> AilakeResult<Vec<SearchResult>> {
1179    use arrow_array::cast::AsArray;
1180
1181    if text_columns.is_empty() {
1182        return Err(AilakeError::InvalidArgument(
1183            "search_text requires at least one text column".into(),
1184        ));
1185    }
1186
1187    let all_files = catalog.list_files(table, None).await?;
1188    let table_meta = catalog.load_table(table).await?;
1189
1190    // Partition pruning
1191    let files: Vec<_> = if let Some(pv) = partition_filter {
1192        all_files
1193            .into_iter()
1194            .filter(|f| f.partition_value.as_deref() == Some(pv))
1195            .collect()
1196    } else {
1197        all_files
1198    };
1199
1200    // Load BM25 stats
1201    let stats_path = table_meta
1202        .properties
1203        .get(crate::bm25::BM25_STATS_PATH_PROP)
1204        .map(String::as_str)
1205        .unwrap_or(crate::bm25::BM25_STATS_FILE);
1206    let stats = match store.get(stats_path).await {
1207        Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1208        Err(_) => {
1209            debug!(
1210                "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1211                stats_path
1212            );
1213            crate::bm25::IdfStats::default()
1214        }
1215    };
1216    let scorer = crate::bm25::BM25Scorer::new(&stats);
1217
1218    // Phase H: equality delete filter for search_text results.
1219    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1220        Ok(edfs) if !edfs.is_empty() => {
1221            match EqualityDeleteFilter::from_files(&store, &edfs).await {
1222                Ok(f) => f,
1223                Err(e) => {
1224                    warn!("ailake: equality delete filter build failed in search_text: {e}");
1225                    EqualityDeleteFilter::empty()
1226                }
1227            }
1228        }
1229        _ => EqualityDeleteFilter::empty(),
1230    };
1231
1232    let mut results: Vec<SearchResult> = Vec::new();
1233
1234    for file_entry in &files {
1235        let file_bytes = store.get(&file_entry.path).await?;
1236        // Use dim=0 — we only read the Parquet columns, not the HNSW.
1237        let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1238
1239        // Fast path: per-file Tantivy index (O(log N) via inverted index).
1240        // Falls back to BM25 O(N) brute-force for files without an FTS section.
1241        if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1242            match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1243                Ok(fts) => {
1244                    let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1245                    if !hits.is_empty() {
1246                        // Load batch only for equality delete checking (not for scoring).
1247                        let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1248                        let (raw_batch, _) = reader2.read_parquet()?;
1249                        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1250                        for hit in hits {
1251                            let row_idx = hit.row_id as usize;
1252                            if row_idx >= batch.num_rows() {
1253                                continue;
1254                            }
1255                            if eq_del_filter.should_delete_row(&batch, row_idx) {
1256                                continue;
1257                            }
1258                            results.push(SearchResult {
1259                                row_id: RowId::new(hit.row_id),
1260                                distance: -hit.score,
1261                                file_path: file_entry.path.clone(),
1262                            });
1263                        }
1264                    }
1265                    continue; // skip O(N) BM25 fallback
1266                }
1267                Err(e) => {
1268                    warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1269                    // fall through to BM25 brute-force
1270                }
1271            }
1272        }
1273
1274        // Fallback: O(N) BM25 brute-force — unchanged from pre-Phase-T behaviour.
1275        let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1276        let (raw_batch, _) = reader_fb.read_parquet()?;
1277        // Phase G: fill missing columns for old files before BM25 text extraction.
1278        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1279
1280        for row_idx in 0..batch.num_rows() {
1281            // Phase H: skip rows matched by equality delete predicate.
1282            if eq_del_filter.should_delete_row(&batch, row_idx) {
1283                continue;
1284            }
1285            let doc_text: String = text_columns
1286                .iter()
1287                .filter_map(|&col| {
1288                    batch.column_by_name(col).and_then(|arr| {
1289                        arr.as_string_opt::<i32>().and_then(|sa| {
1290                            if sa.is_valid(row_idx) {
1291                                Some(sa.value(row_idx).to_string())
1292                            } else {
1293                                None
1294                            }
1295                        })
1296                    })
1297                })
1298                .collect::<Vec<_>>()
1299                .join(" ");
1300
1301            if doc_text.is_empty() {
1302                continue;
1303            }
1304
1305            let bm25 = scorer.score(query_text, &doc_text);
1306            if bm25 > 0.0 {
1307                // Negate so that sort-ascending = best-first (lower distance = higher BM25).
1308                results.push(SearchResult {
1309                    row_id: RowId::new(row_idx as u64),
1310                    distance: -bm25,
1311                    file_path: file_entry.path.clone(),
1312                });
1313            }
1314        }
1315    }
1316
1317    results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1318    results.truncate(top_k);
1319    Ok(results)
1320}
1321
1322/// Fetch full row data for a slice of search results.
1323///
1324/// Groups results by Parquet file, reads each file once, extracts the matching rows
1325/// via `arrow_select::take`, then concatenates everything back in original top-k order
1326/// with a `_distance: Float32` column appended.
1327///
1328/// Use this immediately after `search()` to retrieve the actual text / metadata
1329/// columns (e.g. `chunk_text`, `document_title`) alongside the distance scores.
1330pub async fn fetch_rows(
1331    results: &[SearchResult],
1332    store: Arc<dyn Store>,
1333    vector_column: &str,
1334    dim: u32,
1335    schema_fields: &[SchemaField],
1336) -> AilakeResult<RecordBatch> {
1337    use std::collections::HashMap;
1338
1339    use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1340    use arrow_schema::{DataType, Field, Schema};
1341    use arrow_select::{concat::concat_batches, take::take};
1342
1343    if results.is_empty() {
1344        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1345    }
1346
1347    // Group by file path; preserve original position for re-sorting.
1348    let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1349    for (i, r) in results.iter().enumerate() {
1350        by_file
1351            .entry(r.file_path.as_str())
1352            .or_default()
1353            .push((r.row_id.as_u64(), r.distance, i));
1354    }
1355
1356    use arrow_array::FixedSizeListArray;
1357
1358    // `vector_column` is deliberately excluded from the batch AilakeFileReader::read_parquet()
1359    // returns — it's decoded separately into `vectors: Vec<Vec<f32>>` and re-appended below as
1360    // a FixedSizeList<Float32> field. SchemaFiller has no way to know that; left unfiltered it
1361    // treats the vector column as "missing" (it genuinely isn't in the tabular batch) and
1362    // injects a synthetic column for it — wrong type (falls through iceberg_type_to_arrow's
1363    // Utf8 fallback for the vector column's Iceberg type string) and a duplicate field name
1364    // once the real decoded vector column is appended, breaking pandas' arrow->pandas
1365    // conversion (`Unsupported cast from fixed_size_list<...> to large_utf8`). Found writing
1366    // the regression test for the *other* schema-projection bug this function has — filtering
1367    // it out here is required for schema-filling to be correct at all in fetch_rows.
1368    let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1369        .iter()
1370        .filter(|sf| sf.name != vector_column)
1371        .cloned()
1372        .collect();
1373
1374    // (original_index, distance, single-row RecordBatch, decoded F32 vector)
1375    let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1376
1377    for (file_path, rows) in &by_file {
1378        let bytes = store.get(file_path).await?;
1379        let reader = AilakeFileReader::new(bytes, vector_column, dim);
1380        let (raw_batch, vectors) = reader.read_parquet()?;
1381        // Project against the table's *current* Iceberg schema — old files written
1382        // before a metadata-only evolve_schema/add_column don't physically have the
1383        // new column. Without this, a file that happens to land first in `collected`
1384        // silently drives `base_schema` below and the new column never appears in the
1385        // response at all (not even as null) — confirmed live via Spark's
1386        // AilakeNative.scan() and ailake.search(fetch_data=True), both of which call
1387        // this function. Same fix SchemaFiller::fill already applies on the
1388        // pointer-search path (search()); this was the one full-row-fetch path it
1389        // never reached.
1390        let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1391
1392        for &(row_id, distance, pos) in rows {
1393            let idx = row_id as usize;
1394            if idx >= batch.num_rows() {
1395                tracing::warn!(
1396                    "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1397                    idx,
1398                    batch.num_rows(),
1399                    file_path
1400                );
1401                continue;
1402            }
1403
1404            let indices = UInt32Array::from(vec![idx as u32]);
1405            let row_cols: Vec<ArrayRef> = batch
1406                .columns()
1407                .iter()
1408                .map(|col| {
1409                    take(col.as_ref(), &indices, None)
1410                        .map_err(|e| AilakeError::Arrow(e.to_string()))
1411                })
1412                .collect::<AilakeResult<Vec<_>>>()?;
1413
1414            let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1415                .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1416
1417            // Capture decoded F32 vector for this row (empty vec if not available).
1418            let vec = vectors
1419                .get(idx)
1420                .cloned()
1421                .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1422
1423            collected.push((pos, distance, row_batch, vec));
1424        }
1425    }
1426
1427    if collected.is_empty() {
1428        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1429    }
1430
1431    // Restore original top-k order from the search results slice.
1432    collected.sort_by_key(|(pos, _, _, _)| *pos);
1433
1434    let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1435    let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1436    let base_schema = collected[0].2.schema();
1437
1438    let combined =
1439        concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1440
1441    // Build FixedSizeList<Float32> column with decoded vectors (F32, not raw F16 bytes).
1442    let flat_vecs: Vec<f32> = collected
1443        .iter()
1444        .flat_map(|(_, _, _, v)| v.iter().copied())
1445        .collect();
1446    let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1447    let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1448    let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1449    let vec_field = Arc::new(Field::new(
1450        vector_column,
1451        DataType::FixedSizeList(item_field, dim as i32),
1452        false,
1453    ));
1454
1455    // Schema: tabular cols, then decoded vector col, then _distance.
1456    let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1457    fields.push(vec_field);
1458    fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1459    let new_schema = Arc::new(Schema::new(fields));
1460
1461    let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1462    columns.push(Arc::new(vec_col));
1463    columns.push(Arc::new(Float32Array::from(distances)));
1464
1465    RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1466}
1467
1468/// Load per-file BM25 Bloom filters from the Puffin stats file for the current snapshot.
1469///
1470/// Returns a map of `file_path → BloomFilter`. Empty map = no stats file available
1471/// (V2 table, first write, or fetch failure). The scanner applies Bloom pruning only
1472/// when the map is non-empty.
1473async fn load_bloom_map(
1474    table_meta: &ailake_catalog::TableMetadata,
1475    store: &dyn Store,
1476) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1477    let stats_path = match &table_meta.current_statistics_path {
1478        Some(p) => p.clone(),
1479        None => return std::collections::HashMap::new(),
1480    };
1481    let bytes = match store.get(&stats_path).await {
1482        Ok(b) => b,
1483        Err(e) => {
1484            debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1485            return std::collections::HashMap::new();
1486        }
1487    };
1488    let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1489    let bloom_entries = match reader.read_bm25_blooms() {
1490        Ok(e) => e,
1491        Err(e) => {
1492            warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1493            return std::collections::HashMap::new();
1494        }
1495    };
1496    bloom_entries
1497        .into_iter()
1498        .filter_map(|entry| {
1499            let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1500            Some((entry.path, bf))
1501        })
1502        .collect()
1503}
1504
1505#[cfg(test)]
1506mod tests {
1507    use super::*;
1508    use crate::writer::MultiVectorBatch;
1509    use ailake_catalog::{HadoopCatalog, TableIdent};
1510    use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1511    use ailake_store::LocalStore;
1512    use arrow_array::{Int32Array, RecordBatch};
1513    use arrow_schema::{DataType, Field, Schema};
1514    use std::sync::Arc;
1515    use tempfile::TempDir;
1516
1517    fn make_policy(dim: u32) -> VectorStoragePolicy {
1518        VectorStoragePolicy {
1519            column_name: "embedding".to_string(),
1520            dim,
1521            metric: VectorMetric::Cosine,
1522            precision: VectorPrecision::F16,
1523            pq: None,
1524            keep_raw_for_reranking: true,
1525            pre_normalize: false,
1526            hnsw_m: None,
1527            hnsw_ef_construction: None,
1528            ivf_residual: false,
1529            embedding_model: None,
1530            modality: None,
1531            partition_by: None,
1532            partition_value: None,
1533            partition_column_type: None,
1534            partition_fields: vec![],
1535        }
1536    }
1537
1538    async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1539        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1540        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1541        let table = TableIdent::new("default", "table");
1542
1543        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1544        let ids: Vec<i32> = (0..rows as i32).collect();
1545        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1546
1547        // Each row i has embedding with 1.0 at dimension i and 0 elsewhere (unit basis vectors)
1548        let embeddings: Vec<Vec<f32>> = (0..rows)
1549            .map(|i| {
1550                let mut v = vec![0.0f32; dim];
1551                v[i % dim] = 1.0;
1552                v
1553            })
1554            .collect();
1555
1556        let mut writer =
1557            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1558                .await
1559                .unwrap();
1560        writer.write_batch(&batch, &embeddings).await.unwrap();
1561        writer.commit().await.unwrap();
1562    }
1563
1564    #[tokio::test]
1565    async fn rerank_returns_correct_top_k_count() {
1566        let dir = TempDir::new().unwrap();
1567        let dim = 8usize;
1568        write_demo_table(&dir, dim, 8).await;
1569
1570        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1571        let catalog: Arc<dyn CatalogProvider> =
1572            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1573        let table = TableIdent::new("default", "table");
1574
1575        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1576        let config = SearchConfig {
1577            top_k: 3,
1578            ef_search: 50,
1579            pruning_threshold: f32::INFINITY,
1580            rerank_factor: Some(2),
1581            score_fn: None,
1582            partition_filter: None,
1583            hybrid: None,
1584        };
1585
1586        let results = search(
1587            &table,
1588            &query,
1589            config,
1590            "embedding",
1591            dim as u32,
1592            catalog,
1593            store,
1594        )
1595        .await
1596        .unwrap();
1597
1598        assert_eq!(results.len(), 3);
1599    }
1600
1601    #[tokio::test]
1602    async fn rerank_nearest_is_exact_match() {
1603        let dir = TempDir::new().unwrap();
1604        let dim = 8usize;
1605        write_demo_table(&dir, dim, 8).await;
1606
1607        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1608        let catalog: Arc<dyn CatalogProvider> =
1609            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1610        let table = TableIdent::new("default", "table");
1611
1612        // Row 0 has [1,0,0,...] — cosine distance to same query is 0
1613        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1614        let config = SearchConfig {
1615            top_k: 1,
1616            ef_search: 50,
1617            pruning_threshold: f32::INFINITY,
1618            rerank_factor: Some(4),
1619            score_fn: None,
1620            partition_filter: None,
1621            hybrid: None,
1622        };
1623
1624        let results = search(
1625            &table,
1626            &query,
1627            config,
1628            "embedding",
1629            dim as u32,
1630            catalog,
1631            store,
1632        )
1633        .await
1634        .unwrap();
1635
1636        assert_eq!(results.len(), 1);
1637        // Exact cosine distance between identical unit vectors is ~0 (F16 rounding allowed)
1638        assert!(
1639            results[0].distance < 1e-3,
1640            "distance was {}",
1641            results[0].distance
1642        );
1643        assert_eq!(results[0].row_id, RowId::new(0));
1644    }
1645
1646    #[tokio::test]
1647    async fn no_rerank_matches_default_behavior() {
1648        let dir = TempDir::new().unwrap();
1649        let dim = 4usize;
1650        write_demo_table(&dir, dim, 4).await;
1651
1652        let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1653        let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1654        let cat_a: Arc<dyn CatalogProvider> =
1655            Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
1656        let cat_b: Arc<dyn CatalogProvider> =
1657            Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
1658        let table = TableIdent::new("default", "table");
1659
1660        let query = vec![1.0f32, 0.0, 0.0, 0.0];
1661        let cfg_plain = SearchConfig {
1662            top_k: 2,
1663            ef_search: 50,
1664            pruning_threshold: f32::INFINITY,
1665            rerank_factor: None,
1666            score_fn: None,
1667            partition_filter: None,
1668            hybrid: None,
1669        };
1670        let cfg_rerank = SearchConfig {
1671            top_k: 2,
1672            ef_search: 50,
1673            pruning_threshold: f32::INFINITY,
1674            rerank_factor: Some(2),
1675            score_fn: None,
1676            partition_filter: None,
1677            hybrid: None,
1678        };
1679
1680        let plain = search(
1681            &table,
1682            &query,
1683            cfg_plain,
1684            "embedding",
1685            dim as u32,
1686            cat_a,
1687            store_a,
1688        )
1689        .await
1690        .unwrap();
1691        let reranked = search(
1692            &table,
1693            &query,
1694            cfg_rerank,
1695            "embedding",
1696            dim as u32,
1697            cat_b,
1698            store_b,
1699        )
1700        .await
1701        .unwrap();
1702
1703        // Both should return same top-1 result (row 0, distance ~0)
1704        assert_eq!(plain[0].row_id, reranked[0].row_id);
1705    }
1706
1707    #[tokio::test]
1708    async fn multimodal_rrf_returns_top_k() {
1709        let dir = TempDir::new().unwrap();
1710        let dim = 4usize;
1711        write_demo_table(&dir, dim, 4).await;
1712
1713        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1714        let catalog: Arc<dyn CatalogProvider> =
1715            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1716        let table = TableIdent::new("default", "table");
1717
1718        // Two modal queries using the same column (single-column table).
1719        // Different queries to exercise RRF merging.
1720        let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
1721        let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
1722
1723        let queries = vec![
1724            ModalQuery {
1725                column: "embedding",
1726                query: &q1,
1727                weight: 0.7,
1728                dim: dim as u32,
1729            },
1730            ModalQuery {
1731                column: "embedding",
1732                query: &q2,
1733                weight: 0.3,
1734                dim: dim as u32,
1735            },
1736        ];
1737
1738        let config = SearchConfig {
1739            top_k: 2,
1740            ef_search: 50,
1741            pruning_threshold: f32::INFINITY,
1742            rerank_factor: None,
1743            score_fn: None,
1744            partition_filter: None,
1745            hybrid: None,
1746        };
1747
1748        let results =
1749            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
1750                .await
1751                .unwrap();
1752
1753        assert_eq!(results.len(), 2);
1754        // RRF score stored as -distance; all should be negative
1755        assert!(results[0].distance <= 0.0);
1756        // Top result should be one of rows 0 or 1 (nearest to q1 or q2)
1757        assert!(results[0].row_id.as_u64() < 4);
1758    }
1759
1760    /// True cross-modal test: two columns with DIFFERENT dims (4 + 2).
1761    /// Verifies that search_multimodal correctly routes to each column's HNSW
1762    /// and that the dim validation in search() handles secondary columns.
1763    #[tokio::test]
1764    async fn multimodal_rrf_cross_modal_different_dims() {
1765        let dir = TempDir::new().unwrap();
1766        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1767        let catalog: Arc<dyn CatalogProvider> =
1768            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1769        let table = TableIdent::new("default", "table");
1770
1771        // Write a 2-column table: "embedding" dim=4, "img_embedding" dim=2
1772        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1773        let rows = 4usize;
1774        let ids: Vec<i32> = (0..rows as i32).collect();
1775        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1776
1777        let text_embs: Vec<Vec<f32>> = (0..rows)
1778            .map(|i| {
1779                let mut v = vec![0.0f32; 4];
1780                v[i % 4] = 1.0;
1781                v
1782            })
1783            .collect();
1784        let img_embs: Vec<Vec<f32>> = (0..rows)
1785            .map(|i| {
1786                let mut v = vec![0.0f32; 2];
1787                v[i % 2] = 1.0;
1788                v
1789            })
1790            .collect();
1791
1792        let text_policy = make_policy(4);
1793        let img_policy = VectorStoragePolicy {
1794            column_name: "img_embedding".to_string(),
1795            dim: 2,
1796            metric: VectorMetric::Cosine,
1797            precision: VectorPrecision::F16,
1798            pq: None,
1799            keep_raw_for_reranking: true,
1800            pre_normalize: false,
1801            hnsw_m: None,
1802            hnsw_ef_construction: None,
1803            ivf_residual: false,
1804            embedding_model: None,
1805            modality: None,
1806            partition_by: None,
1807            partition_value: None,
1808            partition_column_type: None,
1809            partition_fields: vec![],
1810        };
1811
1812        let mut writer = crate::TableWriter::create_or_open(
1813            catalog.clone(),
1814            store.clone(),
1815            text_policy,
1816            table.clone(),
1817            2,
1818        )
1819        .await
1820        .unwrap();
1821
1822        let batches = [
1823            MultiVectorBatch {
1824                policy: make_policy(4),
1825                embeddings: &text_embs,
1826            },
1827            MultiVectorBatch {
1828                policy: img_policy,
1829                embeddings: &img_embs,
1830            },
1831        ];
1832        writer.write_batch_multi(&batch, &batches).await.unwrap();
1833        writer.commit().await.unwrap();
1834
1835        // Cross-modal search: text query (dim=4) + image query (dim=2).
1836        let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
1837        let q_img = vec![1.0f32, 0.0];
1838
1839        let queries = vec![
1840            ModalQuery {
1841                column: "embedding",
1842                query: &q_text,
1843                weight: 0.6,
1844                dim: 4,
1845            },
1846            ModalQuery {
1847                column: "img_embedding",
1848                query: &q_img,
1849                weight: 0.4,
1850                dim: 2,
1851            },
1852        ];
1853        let config = SearchConfig {
1854            top_k: 2,
1855            ef_search: 50,
1856            pruning_threshold: f32::INFINITY,
1857            rerank_factor: None,
1858            score_fn: None,
1859            partition_filter: None,
1860            hybrid: None,
1861        };
1862
1863        let results =
1864            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
1865                .await
1866                .unwrap();
1867
1868        assert!(!results.is_empty(), "should return results");
1869        assert!(results[0].distance <= 0.0, "distance is -rrf_score");
1870        // Row 0 is nearest to both q_text=[1,0,0,0] and q_img=[1,0]
1871        assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
1872    }
1873
1874    /// Regression: `fetch_rows` used to build its output schema from whichever file's
1875    /// physical Parquet schema happened to be read first — a file written before a
1876    /// metadata-only `evolve_schema`/`add_column` never physically has the new column,
1877    /// so it was silently absent from the response instead of projected as null.
1878    /// Confirmed live via Spark's `AilakeNative.scan()` and
1879    /// `ailake.search(fetch_data=True)`, both backed by this function.
1880    #[tokio::test]
1881    async fn fetch_rows_projects_evolved_column_as_null() {
1882        use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
1883
1884        let dir = TempDir::new().unwrap();
1885        let dim = 8usize;
1886        write_demo_table(&dir, dim, 8).await;
1887
1888        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1889        let catalog: Arc<dyn CatalogProvider> =
1890            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1891        let table = TableIdent::new("default", "table");
1892
1893        // Metadata-only schema evolution — no data files rewritten, so every existing
1894        // file on disk still physically lacks the "note" column.
1895        catalog
1896            .evolve_schema(
1897                &table,
1898                SchemaEvolution::new().add_column(AddColumnRequest {
1899                    name: "note".to_string(),
1900                    iceberg_type: "string".to_string(),
1901                    required: false,
1902                    initial_default: None,
1903                    write_default: None,
1904                    doc: None,
1905                }),
1906            )
1907            .await
1908            .unwrap();
1909
1910        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1911        let config = SearchConfig {
1912            top_k: 3,
1913            ef_search: 50,
1914            pruning_threshold: f32::INFINITY,
1915            rerank_factor: None,
1916            score_fn: None,
1917            partition_filter: None,
1918            hybrid: None,
1919        };
1920        let results = search(
1921            &table,
1922            &query,
1923            config,
1924            "embedding",
1925            dim as u32,
1926            Arc::clone(&catalog),
1927            Arc::clone(&store),
1928        )
1929        .await
1930        .unwrap();
1931        assert_eq!(results.len(), 3);
1932
1933        let table_meta = catalog.load_table(&table).await.unwrap();
1934        let batch = fetch_rows(
1935            &results,
1936            store,
1937            "embedding",
1938            dim as u32,
1939            &table_meta.schema_fields,
1940        )
1941        .await
1942        .unwrap();
1943
1944        let note_col = batch
1945            .column_by_name("note")
1946            .expect("evolved 'note' column must be present, not silently dropped");
1947        assert_eq!(note_col.len(), 3);
1948        assert_eq!(
1949            note_col.null_count(),
1950            3,
1951            "old files predate 'note' — every value must be null, not an error or a missing column"
1952        );
1953    }
1954
1955    /// Regression: the schema-projection fix above (`fetch_rows_projects_evolved_column_as_null`)
1956    /// initially introduced its own bug — `SchemaFiller::fill` was called with the *unfiltered*
1957    /// current-schema field list, which includes the vector column itself. Since
1958    /// `AilakeFileReader::read_parquet()` deliberately returns the vector column out-of-band
1959    /// (as `vectors: Vec<Vec<f32>>`, not as part of the tabular `RecordBatch`), the filler saw
1960    /// it as "missing" and injected a synthetic column for it — wrong-typed (`iceberg_type_to_arrow`'s
1961    /// `Utf8` fallback) and a duplicate of the real decoded vector column appended a few lines
1962    /// later, breaking pandas' arrow→pandas conversion for *every* `fetch_data=True`/`scan()`
1963    /// call, not just evolved-schema ones. Caught immediately by testing against real
1964    /// pandas conversion (not just the Rust arrow API) — this test guards it at the Rust level too.
1965    #[tokio::test]
1966    async fn fetch_rows_does_not_duplicate_vector_column() {
1967        let dir = TempDir::new().unwrap();
1968        let dim = 8usize;
1969        write_demo_table(&dir, dim, 8).await;
1970
1971        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1972        let catalog: Arc<dyn CatalogProvider> =
1973            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1974        let table = TableIdent::new("default", "table");
1975
1976        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1977        let config = SearchConfig {
1978            top_k: 3,
1979            ef_search: 50,
1980            pruning_threshold: f32::INFINITY,
1981            rerank_factor: None,
1982            score_fn: None,
1983            partition_filter: None,
1984            hybrid: None,
1985        };
1986        let results = search(
1987            &table,
1988            &query,
1989            config,
1990            "embedding",
1991            dim as u32,
1992            Arc::clone(&catalog),
1993            Arc::clone(&store),
1994        )
1995        .await
1996        .unwrap();
1997
1998        let table_meta = catalog.load_table(&table).await.unwrap();
1999        let batch = fetch_rows(
2000            &results,
2001            store,
2002            "embedding",
2003            dim as u32,
2004            &table_meta.schema_fields,
2005        )
2006        .await
2007        .unwrap();
2008
2009        let batch_schema = batch.schema();
2010        let embedding_fields: Vec<_> = batch_schema
2011            .fields()
2012            .iter()
2013            .filter(|f| f.name() == "embedding")
2014            .collect();
2015        assert_eq!(
2016            embedding_fields.len(),
2017            1,
2018            "exactly one 'embedding' field expected, got: {:?}",
2019            batch_schema
2020        );
2021        assert!(
2022            matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2023            "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2024            embedding_fields[0].data_type()
2025        );
2026    }
2027}