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, 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    for file_entry in &surviving_files {
334        let file_bytes: Bytes = store.get(&file_entry.path).await?;
335        let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
336
337        // V3 Deletion Vector: fetch bitmap once per file (range GET from Puffin .dvd).
338        // None for V2 tables or V3 files with no deletes. On fetch error: warn + continue
339        // without mask (surfacing deleted rows is safer than hard-failing the search).
340        let dv_bitmap: Option<roaring::RoaringBitmap> =
341            if let Some(ref dv) = file_entry.deletion_vector {
342                match crate::dv::load_deletion_vector(&store, dv).await {
343                    Ok(bm) => {
344                        debug!(
345                            "ailake: DV loaded ({} deletions) for {}",
346                            bm.len(),
347                            file_entry.path
348                        );
349                        Some(bm)
350                    }
351                    Err(e) => {
352                        warn!(
353                            "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
354                            file_entry.path
355                        );
356                        None
357                    }
358                }
359            } else {
360                None
361            };
362
363        // Parquet read required for: flat scan fallback, exact reranking, score_fn, hybrid,
364        // or when equality delete filter must check column values per-row.
365        let need_parquet = file_entry.index_status == IndexStatus::Indexing
366            || !reader.is_ailake_file()
367            || config.rerank_factor.is_some()
368            || config.score_fn.is_some()
369            || use_hybrid
370            || !eq_del_filter.is_empty();
371
372        if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
373            debug!(
374                "ailake: flat scan fallback for {} (index_status={:?})",
375                file_entry.path, file_entry.index_status
376            );
377            let (raw_batch, raw_vectors) = reader.read_parquet()?;
378            // Phase G: inject columns added via schema evolution with initial_default values.
379            let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
380            for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
381                // Skip rows marked as deleted by a V3 Deletion Vector.
382                if dv_bitmap
383                    .as_ref()
384                    .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
385                {
386                    continue;
387                }
388                // Phase H: skip rows matched by an equality delete predicate.
389                if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
390                    continue;
391                }
392                if use_hybrid {
393                    let text = extract_text_for_row(
394                        &batch,
395                        row_id.as_u64() as usize,
396                        config.hybrid.as_ref().unwrap(),
397                    );
398                    raw_candidates.push((row_id, distance, file_entry.path.clone(), text));
399                } else {
400                    let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
401                    all_results.push(SearchResult {
402                        row_id,
403                        distance: final_score,
404                        file_path: file_entry.path.clone(),
405                    });
406                }
407            }
408            continue;
409        }
410
411        let index = reader.load_any_index_for_column(vector_column)?;
412        let local_results = index.search(query, candidate_k, config.ef_search);
413
414        let parquet_data = if need_parquet {
415            let (raw_batch, raw_vecs) = reader.read_parquet()?;
416            // Phase G: fill missing columns for old files before score_fn / hybrid BM25.
417            let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
418            Some((filled, raw_vecs))
419        } else {
420            None
421        };
422
423        for (row_id, approx_dist) in local_results {
424            // Skip rows marked as deleted by a V3 Deletion Vector.
425            if dv_bitmap
426                .as_ref()
427                .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
428            {
429                continue;
430            }
431            let idx = row_id.as_u64() as usize;
432            // Phase H: skip rows matched by an equality delete predicate.
433            // parquet_data is always loaded when eq_del_filter is non-empty (see need_parquet).
434            if let Some((ref batch, _)) = parquet_data {
435                if eq_del_filter.should_delete_row(batch, idx) {
436                    continue;
437                }
438            }
439
440            let distance = if config.rerank_factor.is_some() {
441                match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
442                    Some(v) => exact_distance(metric, query, v),
443                    None => {
444                        error!(
445                            "ailake: invariant violated — row_id {} out of bounds \
446                             (file={}); Parquet and HNSW node count out of sync; \
447                             run compaction to rebuild",
448                            idx, file_entry.path
449                        );
450                        f32::INFINITY
451                    }
452                }
453            } else {
454                approx_dist
455            };
456
457            if use_hybrid {
458                let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
459                    extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
460                });
461                raw_candidates.push((row_id, distance, file_entry.path.clone(), text));
462            } else {
463                let final_score = if let Some((ref batch, _)) = parquet_data {
464                    apply_score_fn(&config.score_fn, distance, row_id, batch)
465                } else {
466                    distance
467                };
468                all_results.push(SearchResult {
469                    row_id,
470                    distance: final_score,
471                    file_path: file_entry.path.clone(),
472                });
473            }
474        }
475    }
476
477    // Hybrid BM25 fusion: applied after all HNSW candidates are collected.
478    if let Some(ref h) = config.hybrid {
479        let empty_stats = crate::bm25::IdfStats::default();
480        let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
481        let scorer = crate::bm25::BM25Scorer::new(stats);
482
483        // Compute BM25 scores before sorting so they stay positionally aligned.
484        let bm25_scores_pre: Vec<f32> = raw_candidates
485            .iter()
486            .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
487            .collect();
488
489        // Zip BM25 scores into candidates so they sort together — avoids index mismatch
490        // when raw_candidates is reordered by distance below.
491        let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
492            raw_candidates.into_iter().zip(bm25_scores_pre).collect();
493        candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
494        let n = candidates_with_bm25.len();
495
496        let vec_ranks: Vec<usize> = (0..n).collect();
497
498        // Rank by BM25 score descending using post-sort aligned scores.
499        let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
500            .iter()
501            .map(|(_, b)| *b)
502            .enumerate()
503            .collect();
504        bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
505        let mut bm25_rank_of = vec![0usize; n];
506        for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
507            bm25_rank_of[*idx] = rank;
508        }
509
510        use crate::bm25::{linear_score, rrf_score, HybridFusion};
511
512        let fused: Vec<f32> = match h.fusion {
513            HybridFusion::Rrf => vec_ranks
514                .iter()
515                .enumerate()
516                .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
517                .collect(),
518            HybridFusion::Linear => {
519                let min_d = candidates_with_bm25
520                    .iter()
521                    .map(|(r, _)| r.1)
522                    .fold(f32::INFINITY, f32::min);
523                let max_d = candidates_with_bm25
524                    .iter()
525                    .map(|(r, _)| r.1)
526                    .fold(f32::NEG_INFINITY, f32::max);
527                let min_b = candidates_with_bm25
528                    .iter()
529                    .map(|(_, b)| *b)
530                    .fold(f32::INFINITY, f32::min);
531                let max_b = candidates_with_bm25
532                    .iter()
533                    .map(|(_, b)| *b)
534                    .fold(f32::NEG_INFINITY, f32::max);
535                candidates_with_bm25
536                    .iter()
537                    .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
538                    .collect()
539            }
540        };
541
542        for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
543            all_results.push(SearchResult {
544                row_id,
545                distance: fused[i],
546                file_path,
547            });
548        }
549
550        // For RRF: lower (more negative) = better; for Linear: lower = better. Same convention.
551        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
552    } else {
553        all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
554    }
555
556    all_results.truncate(config.top_k);
557    Ok(all_results)
558}
559
560/// Extract concatenated text from specified columns for a single row.
561fn extract_text_for_row(
562    batch: &RecordBatch,
563    row_idx: usize,
564    hybrid: &crate::bm25::HybridConfig,
565) -> String {
566    use arrow_array::cast::AsArray;
567    hybrid
568        .text_columns
569        .iter()
570        .filter_map(|col| {
571            batch.column_by_name(col).and_then(|arr| {
572                arr.as_string_opt::<i32>().and_then(|sa| {
573                    if row_idx < sa.len() && sa.is_valid(row_idx) {
574                        Some(sa.value(row_idx).to_string())
575                    } else {
576                        None
577                    }
578                })
579            })
580        })
581        .collect::<Vec<_>>()
582        .join(" ")
583}
584
585/// One query arm in a cross-modal search.
586#[derive(Debug, Clone)]
587pub struct ModalQuery<'a> {
588    /// Vector column to search (must exist in the table).
589    pub column: &'a str,
590    /// Query vector for this modality.
591    pub query: &'a [f32],
592    /// Relative weight applied in the RRF formula: `weight / (k + rank)`.
593    /// `1.0` means equal weight across all modalities.
594    pub weight: f32,
595    /// Dimensionality of this column's vectors. `0` = auto-detect from table metadata
596    /// (`ailake.dim-<column>` for secondary columns, `ailake.vector-dim` for primary).
597    pub dim: u32,
598}
599
600/// Fusion method for combining results from multiple vector columns.
601#[derive(Debug, Clone, Copy, PartialEq, Eq)]
602pub enum FusionMethod {
603    /// Reciprocal Rank Fusion: `score(d) = Σ weight_i / (k + rank_i(d))`.
604    /// `k = 60` (standard). Returned `SearchResult.distance` = `-rrf_score`
605    /// so that sort-ascending-by-distance gives the correct RRF ranking.
606    Rrf,
607}
608
609/// Cross-modal search: run independent HNSW searches across N vector columns,
610/// then fuse per-column ranked lists using Reciprocal Rank Fusion.
611///
612/// Each `ModalQuery` specifies a column name, its query vector, RRF weight, and dim.
613/// When `ModalQuery.dim == 0`, the dim is auto-detected from `ailake.dim-<col>` /
614/// `ailake.vector-dim` in table metadata.
615/// Results are de-duplicated by `(file_path, row_id)` and ranked by aggregate
616/// RRF score. `SearchResult.distance` stores `-rrf_score` (lower = better) so
617/// existing sort-ascending callers get the correct ordering.
618pub async fn search_multimodal(
619    table: &TableIdent,
620    queries: &[ModalQuery<'_>],
621    config: SearchConfig,
622    catalog: Arc<dyn CatalogProvider>,
623    store: Arc<dyn Store>,
624    fusion: FusionMethod,
625) -> AilakeResult<Vec<SearchResult>> {
626    use std::collections::HashMap;
627
628    if queries.is_empty() {
629        return Err(AilakeError::InvalidArgument(
630            "search_multimodal requires at least one ModalQuery".into(),
631        ));
632    }
633
634    // Load table metadata once for dim auto-detection and metric resolution.
635    let table_meta = catalog.load_table(table).await?;
636    let primary_col = table_meta
637        .properties
638        .get("ailake.vector-column")
639        .cloned()
640        .unwrap_or_default();
641    let primary_dim: u32 = table_meta
642        .properties
643        .get("ailake.vector-dim")
644        .and_then(|s| s.parse().ok())
645        .unwrap_or(0);
646
647    // Fetch more candidates per column so RRF has enough to fuse.
648    let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
649
650    let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
651    for mq in queries {
652        // Resolve dim: caller-supplied > per-column property > primary column dim.
653        let resolved_dim = if mq.dim > 0 {
654            mq.dim
655        } else if mq.column == primary_col {
656            primary_dim
657        } else {
658            table_meta
659                .properties
660                .get(&format!("ailake.dim-{}", mq.column))
661                .and_then(|s| s.parse().ok())
662                .unwrap_or(mq.query.len() as u32)
663        };
664
665        let col_config = SearchConfig {
666            top_k: per_col_k,
667            ef_search: config.ef_search,
668            pruning_threshold: config.pruning_threshold,
669            rerank_factor: config.rerank_factor,
670            score_fn: None,
671            partition_filter: config.partition_filter.clone(),
672            hybrid: None,
673        };
674        let results = search(
675            table,
676            mq.query,
677            col_config,
678            mq.column,
679            resolved_dim,
680            catalog.clone(),
681            store.clone(),
682        )
683        .await?;
684        per_col_results.push((mq.weight, results));
685    }
686
687    // RRF fusion: accumulate score per (file_path, row_id).
688    const K: f32 = 60.0;
689    let mut scores: HashMap<(String, u64), f32> = HashMap::new();
690
691    for (weight, results) in &per_col_results {
692        for (rank, r) in results.iter().enumerate() {
693            let key = (r.file_path.clone(), r.row_id.as_u64());
694            let rrf = weight / (K + rank as f32 + 1.0);
695            *scores.entry(key).or_insert(0.0) += rrf;
696        }
697    }
698
699    // Build SearchResult list sorted by descending RRF score.
700    // Store `-rrf_score` as `.distance` so callers sorting ascending get correct order.
701    let all_files = catalog.list_files(table, None).await?;
702    let _ = all_files; // centroid not needed for fusion — just need file_path+row_id
703
704    // Collect unique candidates: prefer the row's appearance in the first column's results.
705    let mut seen: HashMap<(String, u64), f32> = HashMap::new();
706    for (_, results) in &per_col_results {
707        for r in results {
708            let key = (r.file_path.clone(), r.row_id.as_u64());
709            let rrf_score = *scores.get(&key).unwrap_or(&0.0);
710            seen.entry(key).or_insert(rrf_score);
711        }
712    }
713
714    let mut fused: Vec<SearchResult> = seen
715        .into_iter()
716        .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
717            row_id: RowId::new(row_id_u64),
718            distance: -rrf_score,
719            file_path,
720        })
721        .collect();
722
723    fused.sort_by(|a, b| {
724        a.distance
725            .partial_cmp(&b.distance)
726            .unwrap_or(std::cmp::Ordering::Equal)
727    });
728    fused.truncate(config.top_k);
729
730    let _ = fusion; // only RRF implemented; enum is extensible
731
732    Ok(fused)
733}
734
735/// Apply `score_fn` to a single result row, or return `distance` unchanged.
736///
737/// Slices the batch to a 1-row RecordBatch at `row_id` and calls the fn.
738/// If `score_fn` is `None` or the row index is out of bounds, returns `distance`.
739#[inline]
740fn apply_score_fn(
741    score_fn: &Option<ScoreFn>,
742    distance: f32,
743    row_id: RowId,
744    batch: &RecordBatch,
745) -> f32 {
746    match score_fn {
747        None => distance,
748        Some(f) => {
749            let idx = row_id.as_u64() as usize;
750            if idx < batch.num_rows() {
751                f.call(distance, &batch.slice(idx, 1))
752            } else {
753                distance
754            }
755        }
756    }
757}
758
759/// Brute-force top-k search over raw vectors. Used for Indexing shards.
760fn flat_search(
761    raw: &[Vec<f32>],
762    query: &[f32],
763    top_k: usize,
764    metric: VectorMetric,
765) -> Vec<(RowId, f32)> {
766    let mut results: Vec<(RowId, f32)> = raw
767        .iter()
768        .enumerate()
769        .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
770        .collect();
771    results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
772    results.truncate(top_k);
773    results
774}
775
776fn parse_metric(s: &str) -> VectorMetric {
777    match s {
778        "euclidean" => VectorMetric::Euclidean,
779        "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
780        "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
781        _ => VectorMetric::Cosine,
782    }
783}
784
785/// Pre-loaded search session: all HNSW indexes loaded into memory once.
786///
787/// Useful for benchmarks and servers that issue many queries against the same
788/// snapshot. Avoids re-loading and re-deserializing indexes on every call.
789pub struct SearchSession {
790    shards: Vec<LoadedShard>,
791    metric: VectorMetric,
792}
793
794struct LoadedShard {
795    entry: DataFileEntry,
796    /// None when the shard is still being indexed (IndexStatus::Indexing).
797    index: Option<AnyIndex>,
798    /// Raw F32 vectors: always present for Indexing shards (flat scan), optionally
799    /// present for Ready shards when `load_raw = true` (reranking).
800    raw_vectors: Option<Vec<Vec<f32>>>,
801}
802
803impl SearchSession {
804    /// Load all indexes for the latest snapshot into memory.
805    ///
806    /// Pass `load_raw = true` when reranking will be used (`rerank_factor` is
807    /// `Some`); it reads the full parquet columns so exact distances are
808    /// available without extra I/O during `search_query`.
809    pub async fn load(
810        table: &TableIdent,
811        vector_column: &str,
812        dim: u32,
813        catalog: Arc<dyn CatalogProvider>,
814        store: Arc<dyn Store>,
815        load_raw: bool,
816    ) -> AilakeResult<Self> {
817        let all_files = catalog.list_files(table, None).await?;
818        let table_meta = catalog.load_table(table).await?;
819        let metric = parse_metric(
820            table_meta
821                .properties
822                .get("ailake.vector-metric")
823                .map(String::as_str)
824                .unwrap_or("cosine"),
825        );
826
827        let mut shards = Vec::with_capacity(all_files.len());
828        for entry in all_files {
829            let file_bytes: Bytes = store.get(&entry.path).await?;
830            let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
831
832            if entry.index_status == IndexStatus::Indexing {
833                // HNSW not yet built — load raw vectors for flat scan.
834                let (_, raw_vecs) = reader.read_parquet()?;
835                shards.push(LoadedShard {
836                    entry,
837                    index: None,
838                    raw_vectors: Some(raw_vecs),
839                });
840            } else if reader.is_ailake_file() {
841                let mut index = reader.load_any_index_for_column(vector_column)?;
842                let raw_vectors = if load_raw {
843                    index.quantize_to_f16();
844                    let (_, vecs) = reader.read_parquet()?;
845                    Some(vecs)
846                } else {
847                    None
848                };
849                shards.push(LoadedShard {
850                    entry,
851                    index: Some(index),
852                    raw_vectors,
853                });
854            }
855        }
856
857        Ok(Self { shards, metric })
858    }
859
860    /// Number of loaded shards.
861    pub fn shard_count(&self) -> usize {
862        self.shards.len()
863    }
864
865    /// Search multiple queries in one call.
866    ///
867    /// For shards with raw vectors (Indexing or reranking): dispatches to GPU batch
868    /// matmul when a CUDA device is available, falling back to CPU flat scan.
869    /// For indexed shards (HNSW / IVF-PQ): rayon parallel-map over queries — graph
870    /// traversal is inherently sequential and has no GPU batch path.
871    ///
872    /// Returns one `Vec<SearchResult>` per input query, in the same order.
873    pub fn search_batch(
874        &self,
875        queries: &[Vec<f32>],
876        config: &SearchConfig,
877    ) -> Vec<Vec<SearchResult>> {
878        if queries.is_empty() {
879            return vec![];
880        }
881
882        let n_queries = queries.len();
883        let candidate_k = match config.rerank_factor {
884            Some(factor) => config.top_k * factor,
885            None => config.top_k,
886        };
887        let use_nvidia = ailake_index::hardware::detect_cuda();
888        let use_amd = ailake_index::hardware::detect_rocm();
889
890        // Accumulate per-query results across all shards.
891        let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
892
893        for shard in &self.shards {
894            if let Some(raw) = &shard.raw_vectors {
895                // Flat-scan shard — try GPU batch path (NVIDIA first, then AMD ROCm).
896                if !raw.is_empty() {
897                    let dim = raw[0].len();
898                    let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
899                    let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
900                    let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
901
902                    let gpu_batch = if use_nvidia {
903                        ailake_index::gpu::try_nvidia_search_batch(
904                            &q_refs,
905                            &row_ids,
906                            &flat,
907                            dim,
908                            self.metric,
909                            candidate_k,
910                        )
911                    } else if use_amd {
912                        ailake_index::gpu::try_rocm_search_batch(
913                            &q_refs,
914                            &row_ids,
915                            &flat,
916                            dim,
917                            self.metric,
918                            candidate_k,
919                        )
920                    } else {
921                        None
922                    };
923
924                    if let Some(batch) = gpu_batch {
925                        for (qi, results) in batch.into_iter().enumerate() {
926                            for (row_id, distance) in results {
927                                all_results[qi].push(SearchResult {
928                                    row_id,
929                                    distance,
930                                    file_path: shard.entry.path.clone(),
931                                });
932                            }
933                        }
934                        continue;
935                    }
936                }
937
938                // CPU fallback for flat scan.
939                for (qi, query) in queries.iter().enumerate() {
940                    for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
941                        all_results[qi].push(SearchResult {
942                            row_id,
943                            distance,
944                            file_path: shard.entry.path.clone(),
945                        });
946                    }
947                }
948            } else if let Some(index) = &shard.index {
949                // Indexed shard — rayon parallel-map over queries.
950                let shard_results: Vec<Vec<SearchResult>> = queries
951                    .par_iter()
952                    .map(|query| {
953                        index
954                            .search(query, candidate_k, config.ef_search)
955                            .into_iter()
956                            .map(|(row_id, distance)| SearchResult {
957                                row_id,
958                                distance,
959                                file_path: shard.entry.path.clone(),
960                            })
961                            .collect()
962                    })
963                    .collect();
964
965                for (qi, results) in shard_results.into_iter().enumerate() {
966                    all_results[qi].extend(results);
967                }
968            }
969        }
970
971        // Sort + truncate per query.
972        for results in &mut all_results {
973            results.sort_by(|a, b| {
974                a.distance
975                    .partial_cmp(&b.distance)
976                    .unwrap_or(std::cmp::Ordering::Equal)
977            });
978            results.truncate(config.top_k);
979        }
980
981        all_results
982    }
983
984    /// Search using pre-loaded indexes. No I/O — pure in-memory search.
985    pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
986        let candidate_k = match config.rerank_factor {
987            Some(factor) => config.top_k * factor,
988            None => config.top_k,
989        };
990
991        let mut all_results: Vec<SearchResult> = self
992            .shards
993            .par_iter()
994            .flat_map(|shard| {
995                // Geometric pruning per shard.
996                if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
997                    let dist = match self.metric {
998                        VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
999                            ailake_vec::cosine_distance(query, &centroid.values)
1000                        }
1001                        VectorMetric::Euclidean => {
1002                            ailake_vec::euclidean_distance(query, &centroid.values)
1003                        }
1004                        VectorMetric::DotProduct => {
1005                            -ailake_vec::dot_product(query, &centroid.values)
1006                        }
1007                    };
1008                    if dist - centroid.radius > config.pruning_threshold {
1009                        return vec![];
1010                    }
1011                }
1012
1013                if let Some(index) = &shard.index {
1014                    // Ready shard: HNSW or IVF-PQ search (dispatched by AnyIndex).
1015                    let local_results = index.search(query, candidate_k, config.ef_search);
1016                    if config.rerank_factor.is_some() {
1017                        if let Some(raw) = &shard.raw_vectors {
1018                            local_results
1019                                .into_iter()
1020                                .map(|(row_id, _approx_dist)| {
1021                                    let idx = row_id.as_u64() as usize;
1022                                    let exact_dist = raw
1023                                        .get(idx)
1024                                        .map(|v| exact_distance(self.metric, query, v))
1025                                        .unwrap_or(f32::INFINITY);
1026                                    SearchResult {
1027                                        row_id,
1028                                        distance: exact_dist,
1029                                        file_path: shard.entry.path.clone(),
1030                                    }
1031                                })
1032                                .collect()
1033                        } else {
1034                            local_results
1035                                .into_iter()
1036                                .map(|(row_id, distance)| SearchResult {
1037                                    row_id,
1038                                    distance,
1039                                    file_path: shard.entry.path.clone(),
1040                                })
1041                                .collect()
1042                        }
1043                    } else {
1044                        local_results
1045                            .into_iter()
1046                            .map(|(row_id, distance)| SearchResult {
1047                                row_id,
1048                                distance,
1049                                file_path: shard.entry.path.clone(),
1050                            })
1051                            .collect()
1052                    }
1053                } else if let Some(raw) = &shard.raw_vectors {
1054                    // Indexing shard: exact flat scan.
1055                    flat_search(raw, query, candidate_k, self.metric)
1056                        .into_iter()
1057                        .map(|(row_id, distance)| SearchResult {
1058                            row_id,
1059                            distance,
1060                            file_path: shard.entry.path.clone(),
1061                        })
1062                        .collect()
1063                } else {
1064                    vec![]
1065                }
1066            })
1067            .collect();
1068
1069        all_results.sort_by(|a, b| {
1070            a.distance
1071                .partial_cmp(&b.distance)
1072                .unwrap_or(std::cmp::Ordering::Equal)
1073        });
1074        all_results.truncate(config.top_k);
1075        all_results
1076    }
1077}
1078
1079/// Pure BM25 full-text search across all Parquet files in the table.
1080///
1081/// Scans every surviving file (O(N) complexity), scores each row with BM25 against
1082/// `query_text`, and returns the global top-k by score. IDF stats are loaded from
1083/// `metadata/ailake_bm25_stats.bin` (written by `TableWriter` when `bm25_text_column`
1084/// is configured). If the stats file is absent, IDF defaults to an empty corpus
1085/// (all terms treated as maximally rare — directionally correct but less precise).
1086///
1087/// For pure-lexical search at scale (millions of rows, hundreds of files), consider
1088/// using SQL `LIKE` / `ILIKE` via DuckDB/Trino over the Iceberg-compatible table.
1089/// This function is best suited for small-medium tables or as a lexical complement
1090/// to `search()` for tables where the document count per file is manageable.
1091pub async fn search_text(
1092    table: &TableIdent,
1093    query_text: &str,
1094    text_columns: &[&str],
1095    top_k: usize,
1096    catalog: Arc<dyn CatalogProvider>,
1097    store: Arc<dyn Store>,
1098    partition_filter: Option<&str>,
1099) -> AilakeResult<Vec<SearchResult>> {
1100    use arrow_array::cast::AsArray;
1101
1102    if text_columns.is_empty() {
1103        return Err(AilakeError::InvalidArgument(
1104            "search_text requires at least one text column".into(),
1105        ));
1106    }
1107
1108    let all_files = catalog.list_files(table, None).await?;
1109    let table_meta = catalog.load_table(table).await?;
1110
1111    // Partition pruning
1112    let files: Vec<_> = if let Some(pv) = partition_filter {
1113        all_files
1114            .into_iter()
1115            .filter(|f| f.partition_value.as_deref() == Some(pv))
1116            .collect()
1117    } else {
1118        all_files
1119    };
1120
1121    // Load BM25 stats
1122    let stats_path = table_meta
1123        .properties
1124        .get(crate::bm25::BM25_STATS_PATH_PROP)
1125        .map(String::as_str)
1126        .unwrap_or(crate::bm25::BM25_STATS_FILE);
1127    let stats = match store.get(stats_path).await {
1128        Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1129        Err(_) => {
1130            debug!(
1131                "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1132                stats_path
1133            );
1134            crate::bm25::IdfStats::default()
1135        }
1136    };
1137    let scorer = crate::bm25::BM25Scorer::new(&stats);
1138
1139    // Phase H: equality delete filter for search_text results.
1140    let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1141        Ok(edfs) if !edfs.is_empty() => {
1142            match EqualityDeleteFilter::from_files(&store, &edfs).await {
1143                Ok(f) => f,
1144                Err(e) => {
1145                    warn!("ailake: equality delete filter build failed in search_text: {e}");
1146                    EqualityDeleteFilter::empty()
1147                }
1148            }
1149        }
1150        _ => EqualityDeleteFilter::empty(),
1151    };
1152
1153    let mut results: Vec<SearchResult> = Vec::new();
1154
1155    for file_entry in &files {
1156        let file_bytes = store.get(&file_entry.path).await?;
1157        // Use dim=0 — we only read the Parquet columns, not the HNSW.
1158        let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1159
1160        // Fast path: per-file Tantivy index (O(log N) via inverted index).
1161        // Falls back to BM25 O(N) brute-force for files without an FTS section.
1162        if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1163            match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1164                Ok(fts) => {
1165                    let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1166                    if !hits.is_empty() {
1167                        // Load batch only for equality delete checking (not for scoring).
1168                        let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1169                        let (raw_batch, _) = reader2.read_parquet()?;
1170                        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1171                        for hit in hits {
1172                            let row_idx = hit.row_id as usize;
1173                            if row_idx >= batch.num_rows() {
1174                                continue;
1175                            }
1176                            if eq_del_filter.should_delete_row(&batch, row_idx) {
1177                                continue;
1178                            }
1179                            results.push(SearchResult {
1180                                row_id: RowId::new(hit.row_id),
1181                                distance: -hit.score,
1182                                file_path: file_entry.path.clone(),
1183                            });
1184                        }
1185                    }
1186                    continue; // skip O(N) BM25 fallback
1187                }
1188                Err(e) => {
1189                    warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1190                    // fall through to BM25 brute-force
1191                }
1192            }
1193        }
1194
1195        // Fallback: O(N) BM25 brute-force — unchanged from pre-Phase-T behaviour.
1196        let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1197        let (raw_batch, _) = reader_fb.read_parquet()?;
1198        // Phase G: fill missing columns for old files before BM25 text extraction.
1199        let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1200
1201        for row_idx in 0..batch.num_rows() {
1202            // Phase H: skip rows matched by equality delete predicate.
1203            if eq_del_filter.should_delete_row(&batch, row_idx) {
1204                continue;
1205            }
1206            let doc_text: String = text_columns
1207                .iter()
1208                .filter_map(|&col| {
1209                    batch.column_by_name(col).and_then(|arr| {
1210                        arr.as_string_opt::<i32>().and_then(|sa| {
1211                            if sa.is_valid(row_idx) {
1212                                Some(sa.value(row_idx).to_string())
1213                            } else {
1214                                None
1215                            }
1216                        })
1217                    })
1218                })
1219                .collect::<Vec<_>>()
1220                .join(" ");
1221
1222            if doc_text.is_empty() {
1223                continue;
1224            }
1225
1226            let bm25 = scorer.score(query_text, &doc_text);
1227            if bm25 > 0.0 {
1228                // Negate so that sort-ascending = best-first (lower distance = higher BM25).
1229                results.push(SearchResult {
1230                    row_id: RowId::new(row_idx as u64),
1231                    distance: -bm25,
1232                    file_path: file_entry.path.clone(),
1233                });
1234            }
1235        }
1236    }
1237
1238    results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1239    results.truncate(top_k);
1240    Ok(results)
1241}
1242
1243/// Fetch full row data for a slice of search results.
1244///
1245/// Groups results by Parquet file, reads each file once, extracts the matching rows
1246/// via `arrow_select::take`, then concatenates everything back in original top-k order
1247/// with a `_distance: Float32` column appended.
1248///
1249/// Use this immediately after `search()` to retrieve the actual text / metadata
1250/// columns (e.g. `chunk_text`, `document_title`) alongside the distance scores.
1251pub async fn fetch_rows(
1252    results: &[SearchResult],
1253    store: Arc<dyn Store>,
1254    vector_column: &str,
1255    dim: u32,
1256) -> AilakeResult<RecordBatch> {
1257    use std::collections::HashMap;
1258
1259    use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1260    use arrow_schema::{DataType, Field, Schema};
1261    use arrow_select::{concat::concat_batches, take::take};
1262
1263    if results.is_empty() {
1264        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1265    }
1266
1267    // Group by file path; preserve original position for re-sorting.
1268    let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1269    for (i, r) in results.iter().enumerate() {
1270        by_file
1271            .entry(r.file_path.as_str())
1272            .or_default()
1273            .push((r.row_id.as_u64(), r.distance, i));
1274    }
1275
1276    use arrow_array::FixedSizeListArray;
1277
1278    // (original_index, distance, single-row RecordBatch, decoded F32 vector)
1279    let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1280
1281    for (file_path, rows) in &by_file {
1282        let bytes = store.get(file_path).await?;
1283        let reader = AilakeFileReader::new(bytes, vector_column, dim);
1284        let (batch, vectors) = reader.read_parquet()?;
1285
1286        for &(row_id, distance, pos) in rows {
1287            let idx = row_id as usize;
1288            if idx >= batch.num_rows() {
1289                tracing::warn!(
1290                    "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1291                    idx,
1292                    batch.num_rows(),
1293                    file_path
1294                );
1295                continue;
1296            }
1297
1298            let indices = UInt32Array::from(vec![idx as u32]);
1299            let row_cols: Vec<ArrayRef> = batch
1300                .columns()
1301                .iter()
1302                .map(|col| {
1303                    take(col.as_ref(), &indices, None)
1304                        .map_err(|e| AilakeError::Arrow(e.to_string()))
1305                })
1306                .collect::<AilakeResult<Vec<_>>>()?;
1307
1308            let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1309                .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1310
1311            // Capture decoded F32 vector for this row (empty vec if not available).
1312            let vec = vectors
1313                .get(idx)
1314                .cloned()
1315                .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1316
1317            collected.push((pos, distance, row_batch, vec));
1318        }
1319    }
1320
1321    if collected.is_empty() {
1322        return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1323    }
1324
1325    // Restore original top-k order from the search results slice.
1326    collected.sort_by_key(|(pos, _, _, _)| *pos);
1327
1328    let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1329    let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1330    let base_schema = collected[0].2.schema();
1331
1332    let combined =
1333        concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1334
1335    // Build FixedSizeList<Float32> column with decoded vectors (F32, not raw F16 bytes).
1336    let flat_vecs: Vec<f32> = collected
1337        .iter()
1338        .flat_map(|(_, _, _, v)| v.iter().copied())
1339        .collect();
1340    let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1341    let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1342    let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1343    let vec_field = Arc::new(Field::new(
1344        vector_column,
1345        DataType::FixedSizeList(item_field, dim as i32),
1346        false,
1347    ));
1348
1349    // Schema: tabular cols, then decoded vector col, then _distance.
1350    let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1351    fields.push(vec_field);
1352    fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1353    let new_schema = Arc::new(Schema::new(fields));
1354
1355    let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1356    columns.push(Arc::new(vec_col));
1357    columns.push(Arc::new(Float32Array::from(distances)));
1358
1359    RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1360}
1361
1362/// Load per-file BM25 Bloom filters from the Puffin stats file for the current snapshot.
1363///
1364/// Returns a map of `file_path → BloomFilter`. Empty map = no stats file available
1365/// (V2 table, first write, or fetch failure). The scanner applies Bloom pruning only
1366/// when the map is non-empty.
1367async fn load_bloom_map(
1368    table_meta: &ailake_catalog::TableMetadata,
1369    store: &dyn Store,
1370) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1371    let stats_path = match &table_meta.current_statistics_path {
1372        Some(p) => p.clone(),
1373        None => return std::collections::HashMap::new(),
1374    };
1375    let bytes = match store.get(&stats_path).await {
1376        Ok(b) => b,
1377        Err(e) => {
1378            debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1379            return std::collections::HashMap::new();
1380        }
1381    };
1382    let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1383    let bloom_entries = match reader.read_bm25_blooms() {
1384        Ok(e) => e,
1385        Err(e) => {
1386            warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1387            return std::collections::HashMap::new();
1388        }
1389    };
1390    bloom_entries
1391        .into_iter()
1392        .filter_map(|entry| {
1393            let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1394            Some((entry.path, bf))
1395        })
1396        .collect()
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401    use super::*;
1402    use crate::writer::MultiVectorBatch;
1403    use ailake_catalog::{HadoopCatalog, TableIdent};
1404    use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1405    use ailake_store::LocalStore;
1406    use arrow_array::{Int32Array, RecordBatch};
1407    use arrow_schema::{DataType, Field, Schema};
1408    use std::sync::Arc;
1409    use tempfile::TempDir;
1410
1411    fn make_policy(dim: u32) -> VectorStoragePolicy {
1412        VectorStoragePolicy {
1413            column_name: "embedding".to_string(),
1414            dim,
1415            metric: VectorMetric::Cosine,
1416            precision: VectorPrecision::F16,
1417            pq: None,
1418            keep_raw_for_reranking: true,
1419            pre_normalize: false,
1420            hnsw_m: None,
1421            hnsw_ef_construction: None,
1422            ivf_residual: false,
1423            embedding_model: None,
1424            modality: None,
1425            partition_by: None,
1426            partition_value: None,
1427            partition_column_type: None,
1428            partition_fields: vec![],
1429        }
1430    }
1431
1432    async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1433        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1434        let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1435        let table = TableIdent::new("default", "table");
1436
1437        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1438        let ids: Vec<i32> = (0..rows as i32).collect();
1439        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1440
1441        // Each row i has embedding with 1.0 at dimension i and 0 elsewhere (unit basis vectors)
1442        let embeddings: Vec<Vec<f32>> = (0..rows)
1443            .map(|i| {
1444                let mut v = vec![0.0f32; dim];
1445                v[i % dim] = 1.0;
1446                v
1447            })
1448            .collect();
1449
1450        let mut writer =
1451            crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1452                .await
1453                .unwrap();
1454        writer.write_batch(&batch, &embeddings).await.unwrap();
1455        writer.commit().await.unwrap();
1456    }
1457
1458    #[tokio::test]
1459    async fn rerank_returns_correct_top_k_count() {
1460        let dir = TempDir::new().unwrap();
1461        let dim = 8usize;
1462        write_demo_table(&dir, dim, 8).await;
1463
1464        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1465        let catalog: Arc<dyn CatalogProvider> =
1466            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1467        let table = TableIdent::new("default", "table");
1468
1469        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1470        let config = SearchConfig {
1471            top_k: 3,
1472            ef_search: 50,
1473            pruning_threshold: f32::INFINITY,
1474            rerank_factor: Some(2),
1475            score_fn: None,
1476            partition_filter: None,
1477            hybrid: None,
1478        };
1479
1480        let results = search(
1481            &table,
1482            &query,
1483            config,
1484            "embedding",
1485            dim as u32,
1486            catalog,
1487            store,
1488        )
1489        .await
1490        .unwrap();
1491
1492        assert_eq!(results.len(), 3);
1493    }
1494
1495    #[tokio::test]
1496    async fn rerank_nearest_is_exact_match() {
1497        let dir = TempDir::new().unwrap();
1498        let dim = 8usize;
1499        write_demo_table(&dir, dim, 8).await;
1500
1501        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1502        let catalog: Arc<dyn CatalogProvider> =
1503            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1504        let table = TableIdent::new("default", "table");
1505
1506        // Row 0 has [1,0,0,...] — cosine distance to same query is 0
1507        let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1508        let config = SearchConfig {
1509            top_k: 1,
1510            ef_search: 50,
1511            pruning_threshold: f32::INFINITY,
1512            rerank_factor: Some(4),
1513            score_fn: None,
1514            partition_filter: None,
1515            hybrid: None,
1516        };
1517
1518        let results = search(
1519            &table,
1520            &query,
1521            config,
1522            "embedding",
1523            dim as u32,
1524            catalog,
1525            store,
1526        )
1527        .await
1528        .unwrap();
1529
1530        assert_eq!(results.len(), 1);
1531        // Exact cosine distance between identical unit vectors is ~0 (F16 rounding allowed)
1532        assert!(
1533            results[0].distance < 1e-3,
1534            "distance was {}",
1535            results[0].distance
1536        );
1537        assert_eq!(results[0].row_id, RowId::new(0));
1538    }
1539
1540    #[tokio::test]
1541    async fn no_rerank_matches_default_behavior() {
1542        let dir = TempDir::new().unwrap();
1543        let dim = 4usize;
1544        write_demo_table(&dir, dim, 4).await;
1545
1546        let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1547        let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1548        let cat_a: Arc<dyn CatalogProvider> =
1549            Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
1550        let cat_b: Arc<dyn CatalogProvider> =
1551            Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
1552        let table = TableIdent::new("default", "table");
1553
1554        let query = vec![1.0f32, 0.0, 0.0, 0.0];
1555        let cfg_plain = SearchConfig {
1556            top_k: 2,
1557            ef_search: 50,
1558            pruning_threshold: f32::INFINITY,
1559            rerank_factor: None,
1560            score_fn: None,
1561            partition_filter: None,
1562            hybrid: None,
1563        };
1564        let cfg_rerank = SearchConfig {
1565            top_k: 2,
1566            ef_search: 50,
1567            pruning_threshold: f32::INFINITY,
1568            rerank_factor: Some(2),
1569            score_fn: None,
1570            partition_filter: None,
1571            hybrid: None,
1572        };
1573
1574        let plain = search(
1575            &table,
1576            &query,
1577            cfg_plain,
1578            "embedding",
1579            dim as u32,
1580            cat_a,
1581            store_a,
1582        )
1583        .await
1584        .unwrap();
1585        let reranked = search(
1586            &table,
1587            &query,
1588            cfg_rerank,
1589            "embedding",
1590            dim as u32,
1591            cat_b,
1592            store_b,
1593        )
1594        .await
1595        .unwrap();
1596
1597        // Both should return same top-1 result (row 0, distance ~0)
1598        assert_eq!(plain[0].row_id, reranked[0].row_id);
1599    }
1600
1601    #[tokio::test]
1602    async fn multimodal_rrf_returns_top_k() {
1603        let dir = TempDir::new().unwrap();
1604        let dim = 4usize;
1605        write_demo_table(&dir, dim, 4).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        // Two modal queries using the same column (single-column table).
1613        // Different queries to exercise RRF merging.
1614        let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
1615        let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
1616
1617        let queries = vec![
1618            ModalQuery {
1619                column: "embedding",
1620                query: &q1,
1621                weight: 0.7,
1622                dim: dim as u32,
1623            },
1624            ModalQuery {
1625                column: "embedding",
1626                query: &q2,
1627                weight: 0.3,
1628                dim: dim as u32,
1629            },
1630        ];
1631
1632        let config = SearchConfig {
1633            top_k: 2,
1634            ef_search: 50,
1635            pruning_threshold: f32::INFINITY,
1636            rerank_factor: None,
1637            score_fn: None,
1638            partition_filter: None,
1639            hybrid: None,
1640        };
1641
1642        let results =
1643            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
1644                .await
1645                .unwrap();
1646
1647        assert_eq!(results.len(), 2);
1648        // RRF score stored as -distance; all should be negative
1649        assert!(results[0].distance <= 0.0);
1650        // Top result should be one of rows 0 or 1 (nearest to q1 or q2)
1651        assert!(results[0].row_id.as_u64() < 4);
1652    }
1653
1654    /// True cross-modal test: two columns with DIFFERENT dims (4 + 2).
1655    /// Verifies that search_multimodal correctly routes to each column's HNSW
1656    /// and that the dim validation in search() handles secondary columns.
1657    #[tokio::test]
1658    async fn multimodal_rrf_cross_modal_different_dims() {
1659        let dir = TempDir::new().unwrap();
1660        let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1661        let catalog: Arc<dyn CatalogProvider> =
1662            Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1663        let table = TableIdent::new("default", "table");
1664
1665        // Write a 2-column table: "embedding" dim=4, "img_embedding" dim=2
1666        let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1667        let rows = 4usize;
1668        let ids: Vec<i32> = (0..rows as i32).collect();
1669        let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1670
1671        let text_embs: Vec<Vec<f32>> = (0..rows)
1672            .map(|i| {
1673                let mut v = vec![0.0f32; 4];
1674                v[i % 4] = 1.0;
1675                v
1676            })
1677            .collect();
1678        let img_embs: Vec<Vec<f32>> = (0..rows)
1679            .map(|i| {
1680                let mut v = vec![0.0f32; 2];
1681                v[i % 2] = 1.0;
1682                v
1683            })
1684            .collect();
1685
1686        let text_policy = make_policy(4);
1687        let img_policy = VectorStoragePolicy {
1688            column_name: "img_embedding".to_string(),
1689            dim: 2,
1690            metric: VectorMetric::Cosine,
1691            precision: VectorPrecision::F16,
1692            pq: None,
1693            keep_raw_for_reranking: true,
1694            pre_normalize: false,
1695            hnsw_m: None,
1696            hnsw_ef_construction: None,
1697            ivf_residual: false,
1698            embedding_model: None,
1699            modality: None,
1700            partition_by: None,
1701            partition_value: None,
1702            partition_column_type: None,
1703            partition_fields: vec![],
1704        };
1705
1706        let mut writer = crate::TableWriter::create_or_open(
1707            catalog.clone(),
1708            store.clone(),
1709            text_policy,
1710            table.clone(),
1711            2,
1712        )
1713        .await
1714        .unwrap();
1715
1716        let batches = [
1717            MultiVectorBatch {
1718                policy: make_policy(4),
1719                embeddings: &text_embs,
1720            },
1721            MultiVectorBatch {
1722                policy: img_policy,
1723                embeddings: &img_embs,
1724            },
1725        ];
1726        writer.write_batch_multi(&batch, &batches).await.unwrap();
1727        writer.commit().await.unwrap();
1728
1729        // Cross-modal search: text query (dim=4) + image query (dim=2).
1730        let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
1731        let q_img = vec![1.0f32, 0.0];
1732
1733        let queries = vec![
1734            ModalQuery {
1735                column: "embedding",
1736                query: &q_text,
1737                weight: 0.6,
1738                dim: 4,
1739            },
1740            ModalQuery {
1741                column: "img_embedding",
1742                query: &q_img,
1743                weight: 0.4,
1744                dim: 2,
1745            },
1746        ];
1747        let config = SearchConfig {
1748            top_k: 2,
1749            ef_search: 50,
1750            pruning_threshold: f32::INFINITY,
1751            rerank_factor: None,
1752            score_fn: None,
1753            partition_filter: None,
1754            hybrid: None,
1755        };
1756
1757        let results =
1758            search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
1759                .await
1760                .unwrap();
1761
1762        assert!(!results.is_empty(), "should return results");
1763        assert!(results[0].distance <= 0.0, "distance is -rrf_score");
1764        // Row 0 is nearest to both q_text=[1,0,0,0] and q_img=[1,0]
1765        assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
1766    }
1767}