1use 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#[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 pub pruning_threshold: f32,
83 pub rerank_factor: Option<usize>,
88 pub hybrid: Option<crate::bm25::HybridConfig>,
99 pub score_fn: Option<ScoreFn>,
112 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
164pub 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 let all_files = catalog.list_files(table, None).await?;
182
183 let table_meta = catalog.load_table(table).await?;
185
186 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 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 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 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 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 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 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 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 let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
331 let mut all_results: Vec<SearchResult> = Vec::new();
332
333 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 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 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 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 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 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 if dv_bitmap
439 .as_ref()
440 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
441 {
442 continue;
443 }
444 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 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 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 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 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 let bm25_scores_pre: Vec<f32> = raw_candidates
558 .iter()
559 .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
560 .collect();
561
562 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 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 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
633fn 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#[derive(Debug, Clone)]
660pub struct ModalQuery<'a> {
661 pub column: &'a str,
663 pub query: &'a [f32],
665 pub weight: f32,
668 pub dim: u32,
671}
672
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675pub enum FusionMethod {
676 Rrf,
680}
681
682pub 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 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 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 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 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 let all_files = catalog.list_files(table, None).await?;
775 let _ = all_files; 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; Ok(fused)
806}
807
808#[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
832fn 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
858pub struct SearchSession {
869 shards: Vec<LoadedShard>,
870 metric: VectorMetric,
871}
872
873struct LoadedShard {
874 entry: DataFileEntry,
875 index: Option<AnyIndex>,
877 raw_vectors: Option<Vec<Vec<f32>>>,
880}
881
882impl SearchSession {
883 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 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 pub fn shard_count(&self) -> usize {
941 self.shards.len()
942 }
943
944 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 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 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 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 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 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 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 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, ¢roid.values)
1079 }
1080 VectorMetric::Euclidean => {
1081 ailake_vec::euclidean_distance(query, ¢roid.values)
1082 }
1083 VectorMetric::DotProduct => {
1084 -ailake_vec::dot_product(query, ¢roid.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 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 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
1158pub 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 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 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 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 let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1238
1239 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 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; }
1267 Err(e) => {
1268 warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1269 }
1271 }
1272 }
1273
1274 let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1276 let (raw_batch, _) = reader_fb.read_parquet()?;
1277 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1279
1280 for row_idx in 0..batch.num_rows() {
1281 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 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
1322pub 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 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 let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1369 .iter()
1370 .filter(|sf| sf.name != vector_column)
1371 .cloned()
1372 .collect();
1373
1374 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 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 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 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 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 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
1468async 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 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 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 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 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 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 assert!(results[0].distance <= 0.0);
1756 assert!(results[0].row_id.as_u64() < 4);
1758 }
1759
1760 #[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 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 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 assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
1872 }
1873
1874 #[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 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 #[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}