1use std::sync::Arc;
3
4use futures::future::try_join_all;
5use rayon::prelude::*;
6use tracing::{debug, error, warn};
7
8use ailake_catalog::{
9 CatalogProvider, DataFileEntry, IndexStatus, SchemaField, TableIdent, TableMetadata,
10};
11use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, RowId, VectorMetric};
12use ailake_file::AilakeFileReader;
13use ailake_index::AnyIndex;
14use ailake_store::Store;
15use ailake_vec::exact_distance;
16use arrow_array::{Array, RecordBatch};
17use bytes::Bytes;
18
19use crate::equality_delete::EqualityDeleteFilter;
20use crate::pruner::{BloomPruner, VectorPruner};
21use crate::schema_filler::SchemaFiller;
22
23#[allow(clippy::type_complexity)]
53pub struct ScoreFn(pub std::sync::Arc<dyn Fn(f32, &RecordBatch) -> f32 + Send + Sync>);
54
55impl ScoreFn {
56 pub fn new(f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static) -> Self {
57 Self(std::sync::Arc::new(f))
58 }
59
60 #[inline]
61 pub fn call(&self, distance: f32, row: &RecordBatch) -> f32 {
62 (self.0)(distance, row)
63 }
64}
65
66impl Clone for ScoreFn {
67 fn clone(&self) -> Self {
68 Self(std::sync::Arc::clone(&self.0))
69 }
70}
71
72impl std::fmt::Debug for ScoreFn {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.write_str("ScoreFn(<fn>)")
75 }
76}
77
78#[derive(Debug, Clone)]
79pub struct SearchConfig {
80 pub top_k: usize,
81 pub ef_search: usize,
82 pub pruning_threshold: f32,
86 pub rerank_factor: Option<usize>,
91 pub hybrid: Option<crate::bm25::HybridConfig>,
102 pub score_fn: Option<ScoreFn>,
115 pub partition_filter: Option<String>,
119 pub column_filter: Option<ailake_core::ColumnFilter>,
128}
129
130impl Default for SearchConfig {
131 fn default() -> Self {
132 Self {
133 top_k: 10,
134 ef_search: 50,
135 pruning_threshold: f32::INFINITY,
136 rerank_factor: None,
137 score_fn: None,
138 partition_filter: None,
139 hybrid: None,
140 column_filter: None,
141 }
142 }
143}
144
145impl SearchConfig {
146 pub fn with_column_filter(mut self, filter: ailake_core::ColumnFilter) -> Self {
147 self.column_filter = Some(filter);
148 self
149 }
150
151 pub fn with_pruning(mut self, threshold: f32) -> Self {
152 self.pruning_threshold = threshold;
153 self
154 }
155
156 pub fn with_reranking(mut self, factor: usize) -> Self {
157 self.rerank_factor = Some(factor);
158 self
159 }
160
161 pub fn with_score_fn(
162 mut self,
163 f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static,
164 ) -> Self {
165 self.score_fn = Some(ScoreFn::new(f));
166 self
167 }
168
169 pub fn with_hybrid(mut self, cfg: crate::bm25::HybridConfig) -> Self {
170 self.hybrid = Some(cfg);
171 self
172 }
173}
174
175#[derive(Debug)]
176pub struct SearchResult {
177 pub row_id: RowId,
178 pub distance: f32,
179 pub file_path: String,
180}
181
182pub async fn search(
190 table: &TableIdent,
191 query: &[f32],
192 config: SearchConfig,
193 vector_column: &str,
194 dim: u32,
195 catalog: Arc<dyn CatalogProvider>,
196 store: Arc<dyn Store>,
197) -> AilakeResult<Vec<SearchResult>> {
198 let all_files = catalog.list_files(table, None).await?;
200
201 let table_meta = catalog.load_table(table).await?;
203
204 let primary_col = table_meta
209 .properties
210 .get("ailake.vector-column")
211 .map(String::as_str)
212 .unwrap_or("");
213 let stored_dim_key = if vector_column == primary_col {
214 "ailake.vector-dim".to_string()
215 } else {
216 format!("ailake.dim-{vector_column}")
217 };
218 if let Some(table_dim_str) = table_meta.properties.get(&stored_dim_key) {
219 if let Ok(table_dim) = table_dim_str.parse::<u32>() {
220 let query_dim = query.len() as u32;
221 if query_dim != table_dim {
222 let table_model = table_meta
223 .properties
224 .get(EmbeddingModelInfo::property_key())
225 .cloned()
226 .unwrap_or_else(|| format!("dim={}", table_dim));
227 return Err(AilakeError::ModelMismatch {
228 table_model,
229 table_dim,
230 batch_model: format!("query dim={}", query_dim),
231 batch_dim: query_dim,
232 });
233 }
234 }
235 }
236
237 let metric_key = if vector_column == primary_col {
239 "ailake.vector-metric".to_string()
240 } else {
241 format!("ailake.metric-{vector_column}")
242 };
243 let metric = parse_metric(
244 table_meta
245 .properties
246 .get(&metric_key)
247 .or_else(|| table_meta.properties.get("ailake.vector-metric"))
248 .map(String::as_str)
249 .unwrap_or("cosine"),
250 );
251
252 let all_files = if let Some(ref pv) = config.partition_filter {
254 let before = all_files.len();
255 let filtered: Vec<_> = all_files
256 .into_iter()
257 .filter(|f| f.partition_value.as_deref() == Some(pv.as_str()))
258 .collect();
259 debug!(
260 "ailake: partition pruning '{}' — {}/{} files survive",
261 pv,
262 filtered.len(),
263 before
264 );
265 filtered
266 } else {
267 all_files
268 };
269
270 let total_files = all_files.len();
272 let surviving_files = VectorPruner::prune(all_files, query, metric, config.pruning_threshold);
273 debug!(
274 "ailake: geometric pruning — {}/{} files survive (threshold={})",
275 surviving_files.len(),
276 total_files,
277 config.pruning_threshold
278 );
279
280 let surviving_files = if let Some(ref h) = config.hybrid {
283 let bloom_map = load_bloom_map(&table_meta, store.as_ref()).await;
284 if !bloom_map.is_empty() {
285 BloomPruner::prune(surviving_files, &h.query_text, &bloom_map)
286 } else {
287 surviving_files
288 }
289 } else {
290 surviving_files
291 };
292
293 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
297 Ok(edfs) if !edfs.is_empty() => {
298 match EqualityDeleteFilter::from_files(&store, &edfs).await {
299 Ok(f) => f,
300 Err(e) => {
301 warn!("ailake: equality delete filter build failed: {e} — rows may appear");
302 EqualityDeleteFilter::empty()
303 }
304 }
305 }
306 _ => EqualityDeleteFilter::empty(),
307 };
308
309 let candidate_k = match (&config.hybrid, config.rerank_factor) {
311 (Some(h), rf) => {
312 let pool = h.candidate_pool.unwrap_or(config.top_k * 10);
313 pool.max(rf.map_or(config.top_k, |f| f * config.top_k))
314 }
315 (None, Some(factor)) => config.top_k * factor,
316 (None, None) => config.top_k,
317 };
318
319 let use_hybrid = config.hybrid.is_some();
320
321 let bm25_stats: Option<crate::bm25::IdfStats> = if let Some(ref h) = config.hybrid {
323 if h.text_columns.is_empty() {
324 None
325 } else {
326 let stats_path = table_meta
327 .properties
328 .get(crate::bm25::BM25_STATS_PATH_PROP)
329 .map(String::as_str)
330 .unwrap_or(crate::bm25::BM25_STATS_FILE);
331 match store.get(stats_path).await {
332 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).ok(),
333 Err(_) => {
334 debug!(
335 "ailake: BM25 stats not found at '{}' — falling back to empty corpus IDF",
336 stats_path
337 );
338 None
339 }
340 }
341 }
342 } else {
343 None
344 };
345
346 let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
349 let mut all_results: Vec<SearchResult> = Vec::new();
350
351 let mut flat_scan_deferred = 0usize;
358 let mut flat_scan_unexpected = 0usize;
359
360 let outcomes: Vec<FileSearchOutcome> = try_join_all(surviving_files.iter().map(|file_entry| {
369 search_one_file(
370 file_entry,
371 query,
372 candidate_k,
373 metric,
374 &table_meta,
375 &config,
376 vector_column,
377 dim,
378 &store,
379 &eq_del_filter,
380 use_hybrid,
381 )
382 }))
383 .await?;
384
385 for outcome in outcomes {
386 match outcome.flat_scan {
387 Some(FlatScanKind::Deferred) => flat_scan_deferred += 1,
388 Some(FlatScanKind::Unexpected) => flat_scan_unexpected += 1,
389 None => {}
390 }
391 all_results.extend(outcome.results);
392 raw_candidates.extend(outcome.candidates);
393 }
394
395 if flat_scan_unexpected > 0 {
396 warn!(
397 "ailake: search degraded — {}/{} files scanned without an AI-Lake index \
398 (unexpected — likely external rewrites; {} more in expected deferred-indexing \
399 state). Run compaction to restore O(log N) search on affected files",
400 flat_scan_unexpected,
401 surviving_files.len(),
402 flat_scan_deferred
403 );
404 } else if flat_scan_deferred > 0 {
405 debug!(
406 "ailake: search — {}/{} files scanned via flat fallback (deferred indexing)",
407 flat_scan_deferred,
408 surviving_files.len()
409 );
410 }
411
412 if let Some(ref h) = config.hybrid {
414 let empty_stats = crate::bm25::IdfStats::default();
415 let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
416 let scorer = crate::bm25::BM25Scorer::new(stats);
417
418 let bm25_scores_pre: Vec<f32> = raw_candidates
420 .iter()
421 .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
422 .collect();
423
424 let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
427 raw_candidates.into_iter().zip(bm25_scores_pre).collect();
428 candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
429 let n = candidates_with_bm25.len();
430
431 let vec_ranks: Vec<usize> = (0..n).collect();
432
433 let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
435 .iter()
436 .map(|(_, b)| *b)
437 .enumerate()
438 .collect();
439 bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
440 let mut bm25_rank_of = vec![0usize; n];
441 for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
442 bm25_rank_of[*idx] = rank;
443 }
444
445 use crate::bm25::{linear_score, rrf_score, HybridFusion};
446
447 let fused: Vec<f32> = match h.fusion {
448 HybridFusion::Rrf => vec_ranks
449 .iter()
450 .enumerate()
451 .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
452 .collect(),
453 HybridFusion::Linear => {
454 let min_d = candidates_with_bm25
455 .iter()
456 .map(|(r, _)| r.1)
457 .fold(f32::INFINITY, f32::min);
458 let max_d = candidates_with_bm25
459 .iter()
460 .map(|(r, _)| r.1)
461 .fold(f32::NEG_INFINITY, f32::max);
462 let min_b = candidates_with_bm25
463 .iter()
464 .map(|(_, b)| *b)
465 .fold(f32::INFINITY, f32::min);
466 let max_b = candidates_with_bm25
467 .iter()
468 .map(|(_, b)| *b)
469 .fold(f32::NEG_INFINITY, f32::max);
470 candidates_with_bm25
471 .iter()
472 .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
473 .collect()
474 }
475 };
476
477 for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
478 all_results.push(SearchResult {
479 row_id,
480 distance: fused[i],
481 file_path,
482 });
483 }
484
485 all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
487 } else {
488 all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
489 }
490
491 all_results.truncate(config.top_k);
492 Ok(all_results)
493}
494
495enum FlatScanKind {
499 Deferred,
501 Unexpected,
505}
506
507#[derive(Default)]
510struct FileSearchOutcome {
511 results: Vec<SearchResult>,
513 candidates: Vec<(RowId, f32, String, String)>,
515 flat_scan: Option<FlatScanKind>,
516}
517
518#[allow(clippy::too_many_arguments)]
525async fn search_one_file(
526 file_entry: &DataFileEntry,
527 query: &[f32],
528 candidate_k: usize,
529 metric: VectorMetric,
530 table_meta: &TableMetadata,
531 config: &SearchConfig,
532 vector_column: &str,
533 dim: u32,
534 store: &Arc<dyn Store>,
535 eq_del_filter: &EqualityDeleteFilter,
536 use_hybrid: bool,
537) -> AilakeResult<FileSearchOutcome> {
538 let mut outcome = FileSearchOutcome::default();
539
540 let dv_bitmap: Option<roaring::RoaringBitmap> = if let Some(ref dv) = file_entry.deletion_vector
546 {
547 match crate::dv::load_deletion_vector(store, dv).await {
548 Ok(bm) => {
549 debug!(
550 "ailake: DV loaded ({} deletions) for {}",
551 bm.len(),
552 file_entry.path
553 );
554 Some(bm)
555 }
556 Err(e) => {
557 warn!(
558 "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
559 file_entry.path
560 );
561 None
562 }
563 }
564 } else {
565 None
566 };
567
568 let primary_col = table_meta
578 .properties
579 .get("ailake.vector-column")
580 .map(String::as_str)
581 .unwrap_or("");
582 let fast_path_eligible = config.column_filter.is_none()
583 && config.rerank_factor.is_none()
584 && config.score_fn.is_none()
585 && !use_hybrid
586 && eq_del_filter.is_empty()
587 && file_entry.index_status == IndexStatus::Ready
588 && !file_entry.is_foreign()
589 && vector_column == primary_col;
590
591 if fast_path_eligible {
592 match crate::index_loader::load_primary_index(store, &file_entry.path).await {
593 Ok(index) => {
594 let local_results = index.search(query, candidate_k, config.ef_search);
595 for (row_id, approx_dist) in local_results {
596 if dv_bitmap
597 .as_ref()
598 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
599 {
600 continue;
601 }
602 outcome.results.push(SearchResult {
603 row_id,
604 distance: approx_dist,
605 file_path: file_entry.path.clone(),
606 });
607 }
608 return Ok(outcome);
609 }
610 Err(e) => {
611 debug!(
612 "ailake: range-GET fast path failed for {} ({e}) — falling back to full-file GET",
613 file_entry.path
614 );
615 }
616 }
617 }
618
619 let file_bytes: Bytes = store.get(&file_entry.path).await?;
620 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
621
622 let matching_row_ids: Option<std::collections::HashSet<u64>> =
629 match config.column_filter.as_ref() {
630 Some(filter) => {
631 let matching = reader.matching_row_ids(filter)?;
632 if matching.is_empty() {
633 return Ok(outcome);
634 }
635 Some(matching)
636 }
637 None => None,
638 };
639
640 let need_parquet = file_entry.index_status == IndexStatus::Indexing
643 || !reader.is_ailake_file()
644 || config.rerank_factor.is_some()
645 || config.score_fn.is_some()
646 || use_hybrid
647 || !eq_del_filter.is_empty();
648
649 if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
650 match file_entry.index_status {
651 IndexStatus::Indexing => {
652 outcome.flat_scan = Some(FlatScanKind::Deferred);
653 debug!(
654 "ailake: flat scan fallback for {} — index build in progress \
655 (deferred write, expected to resolve once background job completes)",
656 file_entry.path
657 );
658 }
659 IndexStatus::Failed => {
660 outcome.flat_scan = Some(FlatScanKind::Unexpected);
666 warn!(
667 "ailake: flat scan fallback for {} — background index build failed \
668 permanently ({}); serving via flat scan until the next compaction \
669 rebuilds the index",
670 file_entry.path,
671 file_entry
672 .index_error
673 .as_deref()
674 .unwrap_or("no error recorded")
675 );
676 }
677 IndexStatus::Ready => {
678 outcome.flat_scan = Some(FlatScanKind::Unexpected);
682 if file_entry.is_foreign() {
683 warn!(
684 "ailake: flat scan fallback for {} — file has no AI-Lake index \
685 and no centroid; likely rewritten by a generic Iceberg engine \
686 (OPTIMIZE / rewrite_data_files) with no knowledge of AI-Lake. \
687 Results are still correct (exact O(N) scan), but degraded until \
688 this file is recompacted by the AI-Lake SDK",
689 file_entry.path
690 );
691 } else {
692 warn!(
693 "ailake: flat scan fallback for {} — marked Ready but has no \
694 loadable AI-Lake index despite a recorded centroid; internal \
695 inconsistency, not an external rewrite. Run compaction to rebuild",
696 file_entry.path
697 );
698 }
699 }
700 }
701 let (raw_batch, raw_vectors) = reader.read_parquet()?;
702 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
704 for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
705 if dv_bitmap
707 .as_ref()
708 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
709 {
710 continue;
711 }
712 if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
714 continue;
715 }
716 if matching_row_ids
719 .as_ref()
720 .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
721 {
722 continue;
723 }
724 if use_hybrid {
725 let text = extract_text_for_row(
726 &batch,
727 row_id.as_u64() as usize,
728 config.hybrid.as_ref().unwrap(),
729 );
730 outcome
731 .candidates
732 .push((row_id, distance, file_entry.path.clone(), text));
733 } else {
734 let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
735 outcome.results.push(SearchResult {
736 row_id,
737 distance: final_score,
738 file_path: file_entry.path.clone(),
739 });
740 }
741 }
742 return Ok(outcome);
743 }
744
745 let index = reader.load_any_index_for_column(vector_column)?;
746 let local_results = index.search(query, candidate_k, config.ef_search);
747
748 let parquet_data = if need_parquet {
749 let (raw_batch, raw_vecs) = reader.read_parquet()?;
750 let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
752 Some((filled, raw_vecs))
753 } else {
754 None
755 };
756
757 for (row_id, approx_dist) in local_results {
758 if dv_bitmap
760 .as_ref()
761 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
762 {
763 continue;
764 }
765 let idx = row_id.as_u64() as usize;
766 if let Some((ref batch, _)) = parquet_data {
769 if eq_del_filter.should_delete_row(batch, idx) {
770 continue;
771 }
772 }
773 if matching_row_ids
776 .as_ref()
777 .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
778 {
779 continue;
780 }
781
782 let distance = if config.rerank_factor.is_some() {
783 match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
784 Some(v) => exact_distance(metric, query, v),
785 None => {
786 error!(
787 "ailake: invariant violated — row_id {} out of bounds \
788 (file={}); Parquet and HNSW node count out of sync; \
789 run compaction to rebuild",
790 idx, file_entry.path
791 );
792 f32::INFINITY
793 }
794 }
795 } else {
796 approx_dist
797 };
798
799 if use_hybrid {
800 let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
801 extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
802 });
803 outcome
804 .candidates
805 .push((row_id, distance, file_entry.path.clone(), text));
806 } else {
807 let final_score = if let Some((ref batch, _)) = parquet_data {
808 apply_score_fn(&config.score_fn, distance, row_id, batch)
809 } else {
810 distance
811 };
812 outcome.results.push(SearchResult {
813 row_id,
814 distance: final_score,
815 file_path: file_entry.path.clone(),
816 });
817 }
818 }
819
820 Ok(outcome)
821}
822
823fn extract_text_for_row(
825 batch: &RecordBatch,
826 row_idx: usize,
827 hybrid: &crate::bm25::HybridConfig,
828) -> String {
829 use arrow_array::cast::AsArray;
830 hybrid
831 .text_columns
832 .iter()
833 .filter_map(|col| {
834 batch.column_by_name(col).and_then(|arr| {
835 arr.as_string_opt::<i32>().and_then(|sa| {
836 if row_idx < sa.len() && sa.is_valid(row_idx) {
837 Some(sa.value(row_idx).to_string())
838 } else {
839 None
840 }
841 })
842 })
843 })
844 .collect::<Vec<_>>()
845 .join(" ")
846}
847
848#[derive(Debug, Clone)]
850pub struct ModalQuery<'a> {
851 pub column: &'a str,
853 pub query: &'a [f32],
855 pub weight: f32,
858 pub dim: u32,
861}
862
863#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum FusionMethod {
866 Rrf,
870}
871
872pub async fn search_multimodal(
882 table: &TableIdent,
883 queries: &[ModalQuery<'_>],
884 config: SearchConfig,
885 catalog: Arc<dyn CatalogProvider>,
886 store: Arc<dyn Store>,
887 fusion: FusionMethod,
888) -> AilakeResult<Vec<SearchResult>> {
889 use std::collections::HashMap;
890
891 if queries.is_empty() {
892 return Err(AilakeError::InvalidArgument(
893 "search_multimodal requires at least one ModalQuery".into(),
894 ));
895 }
896
897 let table_meta = catalog.load_table(table).await?;
899 let primary_col = table_meta
900 .properties
901 .get("ailake.vector-column")
902 .cloned()
903 .unwrap_or_default();
904 let primary_dim: u32 = table_meta
905 .properties
906 .get("ailake.vector-dim")
907 .and_then(|s| s.parse().ok())
908 .unwrap_or(0);
909
910 let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
912
913 let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
914 for mq in queries {
915 let resolved_dim = if mq.dim > 0 {
917 mq.dim
918 } else if mq.column == primary_col {
919 primary_dim
920 } else {
921 table_meta
922 .properties
923 .get(&format!("ailake.dim-{}", mq.column))
924 .and_then(|s| s.parse().ok())
925 .unwrap_or(mq.query.len() as u32)
926 };
927
928 let col_config = SearchConfig {
929 top_k: per_col_k,
930 ef_search: config.ef_search,
931 pruning_threshold: config.pruning_threshold,
932 rerank_factor: config.rerank_factor,
933 score_fn: None,
934 partition_filter: config.partition_filter.clone(),
935 hybrid: None,
936 column_filter: config.column_filter.clone(),
937 };
938 let results = search(
939 table,
940 mq.query,
941 col_config,
942 mq.column,
943 resolved_dim,
944 catalog.clone(),
945 store.clone(),
946 )
947 .await?;
948 per_col_results.push((mq.weight, results));
949 }
950
951 const K: f32 = 60.0;
953 let mut scores: HashMap<(String, u64), f32> = HashMap::new();
954
955 for (weight, results) in &per_col_results {
956 for (rank, r) in results.iter().enumerate() {
957 let key = (r.file_path.clone(), r.row_id.as_u64());
958 let rrf = weight / (K + rank as f32 + 1.0);
959 *scores.entry(key).or_insert(0.0) += rrf;
960 }
961 }
962
963 let all_files = catalog.list_files(table, None).await?;
966 let _ = all_files; let mut seen: HashMap<(String, u64), f32> = HashMap::new();
970 for (_, results) in &per_col_results {
971 for r in results {
972 let key = (r.file_path.clone(), r.row_id.as_u64());
973 let rrf_score = *scores.get(&key).unwrap_or(&0.0);
974 seen.entry(key).or_insert(rrf_score);
975 }
976 }
977
978 let mut fused: Vec<SearchResult> = seen
979 .into_iter()
980 .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
981 row_id: RowId::new(row_id_u64),
982 distance: -rrf_score,
983 file_path,
984 })
985 .collect();
986
987 fused.sort_by(|a, b| {
988 a.distance
989 .partial_cmp(&b.distance)
990 .unwrap_or(std::cmp::Ordering::Equal)
991 });
992 fused.truncate(config.top_k);
993
994 let _ = fusion; Ok(fused)
997}
998
999#[inline]
1004fn apply_score_fn(
1005 score_fn: &Option<ScoreFn>,
1006 distance: f32,
1007 row_id: RowId,
1008 batch: &RecordBatch,
1009) -> f32 {
1010 match score_fn {
1011 None => distance,
1012 Some(f) => {
1013 let idx = row_id.as_u64() as usize;
1014 if idx < batch.num_rows() {
1015 f.call(distance, &batch.slice(idx, 1))
1016 } else {
1017 distance
1018 }
1019 }
1020 }
1021}
1022
1023fn flat_search(
1025 raw: &[Vec<f32>],
1026 query: &[f32],
1027 top_k: usize,
1028 metric: VectorMetric,
1029) -> Vec<(RowId, f32)> {
1030 let mut results: Vec<(RowId, f32)> = raw
1031 .iter()
1032 .enumerate()
1033 .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
1034 .collect();
1035 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1036 results.truncate(top_k);
1037 results
1038}
1039
1040fn parse_metric(s: &str) -> VectorMetric {
1041 match s {
1042 "euclidean" => VectorMetric::Euclidean,
1043 "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
1044 "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
1045 _ => VectorMetric::Cosine,
1046 }
1047}
1048
1049pub struct SearchSession {
1060 shards: Vec<LoadedShard>,
1061 metric: VectorMetric,
1062}
1063
1064struct LoadedShard {
1065 entry: DataFileEntry,
1066 index: Option<AnyIndex>,
1068 raw_vectors: Option<Vec<Vec<f32>>>,
1071}
1072
1073impl SearchSession {
1074 pub async fn load(
1080 table: &TableIdent,
1081 vector_column: &str,
1082 dim: u32,
1083 catalog: Arc<dyn CatalogProvider>,
1084 store: Arc<dyn Store>,
1085 load_raw: bool,
1086 ) -> AilakeResult<Self> {
1087 let all_files = catalog.list_files(table, None).await?;
1088 let table_meta = catalog.load_table(table).await?;
1089 let metric = parse_metric(
1090 table_meta
1091 .properties
1092 .get("ailake.vector-metric")
1093 .map(String::as_str)
1094 .unwrap_or("cosine"),
1095 );
1096
1097 let mut shards = Vec::with_capacity(all_files.len());
1098 for entry in all_files {
1099 let file_bytes: Bytes = store.get(&entry.path).await?;
1100 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
1101
1102 if entry.index_status == IndexStatus::Indexing {
1103 let (_, raw_vecs) = reader.read_parquet()?;
1105 shards.push(LoadedShard {
1106 entry,
1107 index: None,
1108 raw_vectors: Some(raw_vecs),
1109 });
1110 } else if reader.is_ailake_file() {
1111 let mut index = reader.load_any_index_for_column(vector_column)?;
1112 let raw_vectors = if load_raw {
1113 index.quantize_to_f16();
1114 let (_, vecs) = reader.read_parquet()?;
1115 Some(vecs)
1116 } else {
1117 None
1118 };
1119 shards.push(LoadedShard {
1120 entry,
1121 index: Some(index),
1122 raw_vectors,
1123 });
1124 }
1125 }
1126
1127 Ok(Self { shards, metric })
1128 }
1129
1130 pub fn shard_count(&self) -> usize {
1132 self.shards.len()
1133 }
1134
1135 pub fn search_batch(
1144 &self,
1145 queries: &[Vec<f32>],
1146 config: &SearchConfig,
1147 ) -> Vec<Vec<SearchResult>> {
1148 if queries.is_empty() {
1149 return vec![];
1150 }
1151
1152 let n_queries = queries.len();
1153 let candidate_k = match config.rerank_factor {
1154 Some(factor) => config.top_k * factor,
1155 None => config.top_k,
1156 };
1157 let use_nvidia = ailake_index::hardware::detect_cuda();
1158 let use_amd = ailake_index::hardware::detect_rocm();
1159
1160 let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
1162
1163 for shard in &self.shards {
1164 if let Some(raw) = &shard.raw_vectors {
1165 if !raw.is_empty() {
1167 let dim = raw[0].len();
1168 let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
1169 let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
1170 let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1171
1172 let gpu_batch = if use_nvidia {
1173 ailake_index::gpu::try_nvidia_search_batch(
1174 &q_refs,
1175 &row_ids,
1176 &flat,
1177 dim,
1178 self.metric,
1179 candidate_k,
1180 )
1181 } else if use_amd {
1182 ailake_index::gpu::try_rocm_search_batch(
1183 &q_refs,
1184 &row_ids,
1185 &flat,
1186 dim,
1187 self.metric,
1188 candidate_k,
1189 )
1190 } else {
1191 None
1192 };
1193
1194 if let Some(batch) = gpu_batch {
1195 for (qi, results) in batch.into_iter().enumerate() {
1196 for (row_id, distance) in results {
1197 all_results[qi].push(SearchResult {
1198 row_id,
1199 distance,
1200 file_path: shard.entry.path.clone(),
1201 });
1202 }
1203 }
1204 continue;
1205 }
1206 }
1207
1208 for (qi, query) in queries.iter().enumerate() {
1210 for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1211 all_results[qi].push(SearchResult {
1212 row_id,
1213 distance,
1214 file_path: shard.entry.path.clone(),
1215 });
1216 }
1217 }
1218 } else if let Some(index) = &shard.index {
1219 let shard_results: Vec<Vec<SearchResult>> = queries
1221 .par_iter()
1222 .map(|query| {
1223 index
1224 .search(query, candidate_k, config.ef_search)
1225 .into_iter()
1226 .map(|(row_id, distance)| SearchResult {
1227 row_id,
1228 distance,
1229 file_path: shard.entry.path.clone(),
1230 })
1231 .collect()
1232 })
1233 .collect();
1234
1235 for (qi, results) in shard_results.into_iter().enumerate() {
1236 all_results[qi].extend(results);
1237 }
1238 }
1239 }
1240
1241 for results in &mut all_results {
1243 results.sort_by(|a, b| {
1244 a.distance
1245 .partial_cmp(&b.distance)
1246 .unwrap_or(std::cmp::Ordering::Equal)
1247 });
1248 results.truncate(config.top_k);
1249 }
1250
1251 all_results
1252 }
1253
1254 pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1256 let candidate_k = match config.rerank_factor {
1257 Some(factor) => config.top_k * factor,
1258 None => config.top_k,
1259 };
1260
1261 let mut all_results: Vec<SearchResult> = self
1262 .shards
1263 .par_iter()
1264 .flat_map(|shard| {
1265 if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1267 let dist = match self.metric {
1268 VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1269 ailake_vec::cosine_distance(query, ¢roid.values)
1270 }
1271 VectorMetric::Euclidean => {
1272 ailake_vec::euclidean_distance(query, ¢roid.values)
1273 }
1274 VectorMetric::DotProduct => {
1275 -ailake_vec::dot_product(query, ¢roid.values)
1276 }
1277 };
1278 if dist - centroid.radius > config.pruning_threshold {
1279 return vec![];
1280 }
1281 }
1282
1283 if let Some(index) = &shard.index {
1284 let local_results = index.search(query, candidate_k, config.ef_search);
1286 if config.rerank_factor.is_some() {
1287 if let Some(raw) = &shard.raw_vectors {
1288 local_results
1289 .into_iter()
1290 .map(|(row_id, _approx_dist)| {
1291 let idx = row_id.as_u64() as usize;
1292 let exact_dist = raw
1293 .get(idx)
1294 .map(|v| exact_distance(self.metric, query, v))
1295 .unwrap_or(f32::INFINITY);
1296 SearchResult {
1297 row_id,
1298 distance: exact_dist,
1299 file_path: shard.entry.path.clone(),
1300 }
1301 })
1302 .collect()
1303 } else {
1304 local_results
1305 .into_iter()
1306 .map(|(row_id, distance)| SearchResult {
1307 row_id,
1308 distance,
1309 file_path: shard.entry.path.clone(),
1310 })
1311 .collect()
1312 }
1313 } else {
1314 local_results
1315 .into_iter()
1316 .map(|(row_id, distance)| SearchResult {
1317 row_id,
1318 distance,
1319 file_path: shard.entry.path.clone(),
1320 })
1321 .collect()
1322 }
1323 } else if let Some(raw) = &shard.raw_vectors {
1324 flat_search(raw, query, candidate_k, self.metric)
1326 .into_iter()
1327 .map(|(row_id, distance)| SearchResult {
1328 row_id,
1329 distance,
1330 file_path: shard.entry.path.clone(),
1331 })
1332 .collect()
1333 } else {
1334 vec![]
1335 }
1336 })
1337 .collect();
1338
1339 all_results.sort_by(|a, b| {
1340 a.distance
1341 .partial_cmp(&b.distance)
1342 .unwrap_or(std::cmp::Ordering::Equal)
1343 });
1344 all_results.truncate(config.top_k);
1345 all_results
1346 }
1347}
1348
1349pub async fn search_text(
1362 table: &TableIdent,
1363 query_text: &str,
1364 text_columns: &[&str],
1365 top_k: usize,
1366 catalog: Arc<dyn CatalogProvider>,
1367 store: Arc<dyn Store>,
1368 partition_filter: Option<&str>,
1369) -> AilakeResult<Vec<SearchResult>> {
1370 use arrow_array::cast::AsArray;
1371
1372 if text_columns.is_empty() {
1373 return Err(AilakeError::InvalidArgument(
1374 "search_text requires at least one text column".into(),
1375 ));
1376 }
1377
1378 let all_files = catalog.list_files(table, None).await?;
1379 let table_meta = catalog.load_table(table).await?;
1380
1381 let files: Vec<_> = if let Some(pv) = partition_filter {
1383 all_files
1384 .into_iter()
1385 .filter(|f| f.partition_value.as_deref() == Some(pv))
1386 .collect()
1387 } else {
1388 all_files
1389 };
1390
1391 let stats_path = table_meta
1393 .properties
1394 .get(crate::bm25::BM25_STATS_PATH_PROP)
1395 .map(String::as_str)
1396 .unwrap_or(crate::bm25::BM25_STATS_FILE);
1397 let stats = match store.get(stats_path).await {
1398 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1399 Err(_) => {
1400 debug!(
1401 "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1402 stats_path
1403 );
1404 crate::bm25::IdfStats::default()
1405 }
1406 };
1407 let scorer = crate::bm25::BM25Scorer::new(&stats);
1408
1409 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1411 Ok(edfs) if !edfs.is_empty() => {
1412 match EqualityDeleteFilter::from_files(&store, &edfs).await {
1413 Ok(f) => f,
1414 Err(e) => {
1415 warn!("ailake: equality delete filter build failed in search_text: {e}");
1416 EqualityDeleteFilter::empty()
1417 }
1418 }
1419 }
1420 _ => EqualityDeleteFilter::empty(),
1421 };
1422
1423 let mut results: Vec<SearchResult> = Vec::new();
1424
1425 for file_entry in &files {
1426 let file_bytes = store.get(&file_entry.path).await?;
1427 let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1429
1430 if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1433 match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1434 Ok(fts) => {
1435 let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1436 if !hits.is_empty() {
1437 let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1439 let (raw_batch, _) = reader2.read_parquet()?;
1440 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1441 for hit in hits {
1442 let row_idx = hit.row_id as usize;
1443 if row_idx >= batch.num_rows() {
1444 continue;
1445 }
1446 if eq_del_filter.should_delete_row(&batch, row_idx) {
1447 continue;
1448 }
1449 results.push(SearchResult {
1450 row_id: RowId::new(hit.row_id),
1451 distance: -hit.score,
1452 file_path: file_entry.path.clone(),
1453 });
1454 }
1455 }
1456 continue; }
1458 Err(e) => {
1459 warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1460 }
1462 }
1463 }
1464
1465 let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1467 let (raw_batch, _) = reader_fb.read_parquet()?;
1468 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1470
1471 for row_idx in 0..batch.num_rows() {
1472 if eq_del_filter.should_delete_row(&batch, row_idx) {
1474 continue;
1475 }
1476 let doc_text: String = text_columns
1477 .iter()
1478 .filter_map(|&col| {
1479 batch.column_by_name(col).and_then(|arr| {
1480 arr.as_string_opt::<i32>().and_then(|sa| {
1481 if sa.is_valid(row_idx) {
1482 Some(sa.value(row_idx).to_string())
1483 } else {
1484 None
1485 }
1486 })
1487 })
1488 })
1489 .collect::<Vec<_>>()
1490 .join(" ");
1491
1492 if doc_text.is_empty() {
1493 continue;
1494 }
1495
1496 let bm25 = scorer.score(query_text, &doc_text);
1497 if bm25 > 0.0 {
1498 results.push(SearchResult {
1500 row_id: RowId::new(row_idx as u64),
1501 distance: -bm25,
1502 file_path: file_entry.path.clone(),
1503 });
1504 }
1505 }
1506 }
1507
1508 results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1509 results.truncate(top_k);
1510 Ok(results)
1511}
1512
1513pub async fn fetch_rows(
1522 results: &[SearchResult],
1523 store: Arc<dyn Store>,
1524 vector_column: &str,
1525 dim: u32,
1526 schema_fields: &[SchemaField],
1527) -> AilakeResult<RecordBatch> {
1528 use std::collections::HashMap;
1529
1530 use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1531 use arrow_schema::{DataType, Field, Schema};
1532 use arrow_select::{concat::concat_batches, take::take};
1533
1534 if results.is_empty() {
1535 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1536 }
1537
1538 let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1540 for (i, r) in results.iter().enumerate() {
1541 by_file
1542 .entry(r.file_path.as_str())
1543 .or_default()
1544 .push((r.row_id.as_u64(), r.distance, i));
1545 }
1546
1547 use arrow_array::FixedSizeListArray;
1548
1549 let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1560 .iter()
1561 .filter(|sf| sf.name != vector_column)
1562 .cloned()
1563 .collect();
1564
1565 let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1567
1568 for (file_path, rows) in &by_file {
1569 let bytes = store.get(file_path).await?;
1570 let reader = AilakeFileReader::new(bytes, vector_column, dim);
1571 let (raw_batch, vectors) = reader.read_parquet()?;
1572 let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1582
1583 for &(row_id, distance, pos) in rows {
1584 let idx = row_id as usize;
1585 if idx >= batch.num_rows() {
1586 tracing::warn!(
1587 "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1588 idx,
1589 batch.num_rows(),
1590 file_path
1591 );
1592 continue;
1593 }
1594
1595 let indices = UInt32Array::from(vec![idx as u32]);
1596 let row_cols: Vec<ArrayRef> = batch
1597 .columns()
1598 .iter()
1599 .map(|col| {
1600 take(col.as_ref(), &indices, None)
1601 .map_err(|e| AilakeError::Arrow(e.to_string()))
1602 })
1603 .collect::<AilakeResult<Vec<_>>>()?;
1604
1605 let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1606 .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1607
1608 let vec = vectors
1610 .get(idx)
1611 .cloned()
1612 .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1613
1614 collected.push((pos, distance, row_batch, vec));
1615 }
1616 }
1617
1618 if collected.is_empty() {
1619 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1620 }
1621
1622 collected.sort_by_key(|(pos, _, _, _)| *pos);
1624
1625 let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1626 let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1627 let base_schema = collected[0].2.schema();
1628
1629 let combined =
1630 concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1631
1632 let flat_vecs: Vec<f32> = collected
1634 .iter()
1635 .flat_map(|(_, _, _, v)| v.iter().copied())
1636 .collect();
1637 let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1638 let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1639 let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1640 let vec_field = Arc::new(Field::new(
1641 vector_column,
1642 DataType::FixedSizeList(item_field, dim as i32),
1643 false,
1644 ));
1645
1646 let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1648 fields.push(vec_field);
1649 fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1650 let new_schema = Arc::new(Schema::new(fields));
1651
1652 let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1653 columns.push(Arc::new(vec_col));
1654 columns.push(Arc::new(Float32Array::from(distances)));
1655
1656 RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1657}
1658
1659async fn load_bloom_map(
1665 table_meta: &ailake_catalog::TableMetadata,
1666 store: &dyn Store,
1667) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1668 let stats_path = match &table_meta.current_statistics_path {
1669 Some(p) => p.clone(),
1670 None => return std::collections::HashMap::new(),
1671 };
1672 let bytes = match store.get(&stats_path).await {
1673 Ok(b) => b,
1674 Err(e) => {
1675 debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1676 return std::collections::HashMap::new();
1677 }
1678 };
1679 let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1680 let bloom_entries = match reader.read_bm25_blooms() {
1681 Ok(e) => e,
1682 Err(e) => {
1683 warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1684 return std::collections::HashMap::new();
1685 }
1686 };
1687 bloom_entries
1688 .into_iter()
1689 .filter_map(|entry| {
1690 let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1691 Some((entry.path, bf))
1692 })
1693 .collect()
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698 use super::*;
1699 use crate::writer::MultiVectorBatch;
1700 use ailake_catalog::{HadoopCatalog, TableIdent};
1701 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1702 use ailake_store::LocalStore;
1703 use arrow_array::{Int32Array, RecordBatch};
1704 use arrow_schema::{DataType, Field, Schema};
1705 use std::sync::Arc;
1706 use tempfile::TempDir;
1707
1708 fn make_policy(dim: u32) -> VectorStoragePolicy {
1709 VectorStoragePolicy {
1710 column_name: "embedding".to_string(),
1711 dim,
1712 metric: VectorMetric::Cosine,
1713 precision: VectorPrecision::F16,
1714 pq: None,
1715 keep_raw_for_reranking: true,
1716 pre_normalize: false,
1717 hnsw_m: None,
1718 hnsw_ef_construction: None,
1719 ivf_residual: false,
1720 embedding_model: None,
1721 modality: None,
1722 partition_by: None,
1723 partition_value: None,
1724 partition_column_type: None,
1725 partition_fields: vec![],
1726 }
1727 }
1728
1729 async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1730 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1731 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1732 let table = TableIdent::new("default", "table");
1733
1734 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1735 let ids: Vec<i32> = (0..rows as i32).collect();
1736 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1737
1738 let embeddings: Vec<Vec<f32>> = (0..rows)
1740 .map(|i| {
1741 let mut v = vec![0.0f32; dim];
1742 v[i % dim] = 1.0;
1743 v
1744 })
1745 .collect();
1746
1747 let mut writer =
1748 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1749 .await
1750 .unwrap();
1751 writer.write_batch(&batch, &embeddings).await.unwrap();
1752 writer.commit().await.unwrap();
1753 }
1754
1755 async fn write_demo_table_with_category(dir: &TempDir, dim: usize, rows: usize) {
1760 use arrow_array::StringArray;
1761 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1762 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1763 let table = TableIdent::new("default", "table");
1764
1765 let schema = Arc::new(Schema::new(vec![
1766 Field::new("id", DataType::Int32, false),
1767 Field::new("category", DataType::Utf8, false),
1768 ]));
1769 let ids: Vec<i32> = (0..rows as i32).collect();
1770 let categories: Vec<&str> = (0..rows)
1771 .map(|i| if i % 2 == 0 { "even" } else { "odd" })
1772 .collect();
1773 let batch = RecordBatch::try_new(
1774 schema,
1775 vec![
1776 Arc::new(Int32Array::from(ids)),
1777 Arc::new(StringArray::from(categories)),
1778 ],
1779 )
1780 .unwrap();
1781
1782 let embeddings: Vec<Vec<f32>> = (0..rows)
1783 .map(|i| {
1784 let mut v = vec![0.0f32; dim];
1785 v[i % dim] = 1.0;
1786 v
1787 })
1788 .collect();
1789
1790 let mut writer =
1791 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1792 .await
1793 .unwrap();
1794 writer.write_batch(&batch, &embeddings).await.unwrap();
1795 writer.commit().await.unwrap();
1796 }
1797
1798 #[tokio::test]
1799 async fn column_filter_preserves_row_identity_on_indexed_path() {
1800 let dir = TempDir::new().unwrap();
1808 let dim = 8usize;
1809 write_demo_table_with_category(&dir, dim, 8).await;
1810
1811 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1812 let catalog: Arc<dyn CatalogProvider> =
1813 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1814 let table = TableIdent::new("default", "table");
1815
1816 let mut query = vec![0.0f32; dim];
1818 query[4] = 1.0;
1819
1820 let config = SearchConfig {
1821 top_k: 1,
1822 ef_search: 50,
1823 pruning_threshold: f32::INFINITY,
1824 rerank_factor: None,
1825 score_fn: None,
1826 partition_filter: None,
1827 hybrid: None,
1828 column_filter: Some(ailake_core::ColumnFilter::eq(
1829 "category",
1830 ailake_core::FilterValue::Str("even".to_string()),
1831 )),
1832 };
1833
1834 let results = search(
1835 &table,
1836 &query,
1837 config,
1838 "embedding",
1839 dim as u32,
1840 catalog,
1841 store,
1842 )
1843 .await
1844 .unwrap();
1845
1846 assert_eq!(results.len(), 1);
1847 assert_eq!(
1848 results[0].row_id.as_u64(),
1849 4,
1850 "row_id must be the true file-relative position, not a filtered-batch index"
1851 );
1852 }
1853
1854 #[tokio::test]
1855 async fn column_filter_excludes_non_matching_rows() {
1856 let dir = TempDir::new().unwrap();
1857 let dim = 8usize;
1858 write_demo_table_with_category(&dir, dim, 8).await;
1859
1860 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1861 let catalog: Arc<dyn CatalogProvider> =
1862 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1863 let table = TableIdent::new("default", "table");
1864
1865 let mut query = vec![0.0f32; dim];
1867 query[3] = 1.0;
1868
1869 let config = SearchConfig {
1870 top_k: 8,
1871 ef_search: 50,
1872 pruning_threshold: f32::INFINITY,
1873 rerank_factor: None,
1874 score_fn: None,
1875 partition_filter: None,
1876 hybrid: None,
1877 column_filter: Some(ailake_core::ColumnFilter::eq(
1878 "category",
1879 ailake_core::FilterValue::Str("even".to_string()),
1880 )),
1881 };
1882
1883 let results = search(
1884 &table,
1885 &query,
1886 config,
1887 "embedding",
1888 dim as u32,
1889 catalog,
1890 store,
1891 )
1892 .await
1893 .unwrap();
1894
1895 assert_eq!(results.len(), 4, "only the 4 even rows should survive");
1896 for r in &results {
1897 assert_eq!(
1898 r.row_id.as_u64() % 2,
1899 0,
1900 "row {} is not even",
1901 r.row_id.as_u64()
1902 );
1903 }
1904 }
1905
1906 #[tokio::test]
1907 async fn column_filter_no_match_returns_empty() {
1908 let dir = TempDir::new().unwrap();
1909 let dim = 8usize;
1910 write_demo_table_with_category(&dir, dim, 8).await;
1911
1912 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1913 let catalog: Arc<dyn CatalogProvider> =
1914 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1915 let table = TableIdent::new("default", "table");
1916
1917 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1918 let config = SearchConfig {
1919 top_k: 8,
1920 ef_search: 50,
1921 pruning_threshold: f32::INFINITY,
1922 rerank_factor: None,
1923 score_fn: None,
1924 partition_filter: None,
1925 hybrid: None,
1926 column_filter: Some(ailake_core::ColumnFilter::eq(
1927 "category",
1928 ailake_core::FilterValue::Str("nonexistent".to_string()),
1929 )),
1930 };
1931
1932 let results = search(
1933 &table,
1934 &query,
1935 config,
1936 "embedding",
1937 dim as u32,
1938 catalog,
1939 store,
1940 )
1941 .await
1942 .unwrap();
1943 assert!(results.is_empty());
1944 }
1945
1946 struct CountingStore {
1953 inner: Arc<dyn Store>,
1954 ranges_by_path: std::sync::Mutex<std::collections::HashMap<String, Vec<(u64, u64)>>>,
1955 }
1956
1957 impl CountingStore {
1958 fn new(inner: Arc<dyn Store>) -> Self {
1959 Self {
1960 inner,
1961 ranges_by_path: std::sync::Mutex::new(std::collections::HashMap::new()),
1962 }
1963 }
1964
1965 fn record(&self, path: &str, start: u64, end: u64) {
1966 self.ranges_by_path
1967 .lock()
1968 .unwrap()
1969 .entry(path.to_string())
1970 .or_default()
1971 .push((start, end));
1972 }
1973
1974 fn min_offset_for(&self, path: &str) -> Option<u64> {
1978 self.ranges_by_path
1979 .lock()
1980 .unwrap()
1981 .get(path)
1982 .and_then(|ranges| ranges.iter().map(|(s, _)| *s).min())
1983 }
1984 }
1985
1986 #[async_trait::async_trait]
1987 impl Store for CountingStore {
1988 async fn get(&self, path: &str) -> AilakeResult<Bytes> {
1989 let b = self.inner.get(path).await?;
1990 self.record(path, 0, b.len() as u64);
1991 Ok(b)
1992 }
1993 async fn get_range(&self, path: &str, range: std::ops::Range<u64>) -> AilakeResult<Bytes> {
1994 let b = self.inner.get_range(path, range.clone()).await?;
1995 self.record(path, range.start, range.end);
1996 Ok(b)
1997 }
1998 async fn put(&self, path: &str, data: Bytes) -> AilakeResult<()> {
1999 self.inner.put(path, data).await
2000 }
2001 async fn list(&self, prefix: &str) -> AilakeResult<Vec<String>> {
2002 self.inner.list(prefix).await
2003 }
2004 async fn file_size(&self, path: &str) -> AilakeResult<u64> {
2005 self.inner.file_size(path).await
2006 }
2007 async fn exists(&self, path: &str) -> AilakeResult<bool> {
2008 self.inner.exists(path).await
2009 }
2010 async fn delete(&self, path: &str) -> AilakeResult<()> {
2011 self.inner.delete(path).await
2012 }
2013 }
2014
2015 #[tokio::test]
2016 async fn range_get_fast_path_never_touches_tabular_data_section() {
2017 let dir = TempDir::new().unwrap();
2018 let dim = 64usize;
2019 write_demo_table(&dir, dim, 400).await;
2020
2021 let local: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2022 let data_files = local.list("data").await.unwrap();
2023 assert_eq!(
2024 data_files.len(),
2025 1,
2026 "expect a single part file for this test"
2027 );
2028 let data_path = data_files[0].clone();
2029
2030 let full_bytes = local.get(&data_path).await.unwrap();
2033 let ailk_offset = ailake_file::AilakeFileReader::new(full_bytes, "embedding", dim as u32)
2034 .ailk_offset()
2035 .unwrap();
2036
2037 let counting = Arc::new(CountingStore::new(local.clone()));
2038 let store: Arc<dyn Store> = counting.clone();
2039 let catalog: Arc<dyn CatalogProvider> =
2040 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2041 let table = TableIdent::new("default", "table");
2042
2043 let mut query = vec![0.0f32; dim];
2044 query[3] = 1.0;
2045 let config = SearchConfig {
2046 top_k: 5,
2047 ef_search: 50,
2048 pruning_threshold: f32::INFINITY,
2049 rerank_factor: None,
2050 score_fn: None,
2051 partition_filter: None,
2052 hybrid: None,
2053 column_filter: None,
2054 };
2055
2056 let results = search(
2057 &table,
2058 &query,
2059 config,
2060 "embedding",
2061 dim as u32,
2062 catalog,
2063 store,
2064 )
2065 .await
2066 .unwrap();
2067 assert!(!results.is_empty());
2068
2069 let min_offset = counting
2076 .min_offset_for(&data_path)
2077 .expect("should have fetched something from the data file");
2078 assert!(
2079 min_offset >= ailk_offset,
2080 "fast path fetched bytes from the tabular/vector data section: \
2081 min_offset={min_offset} < ailk_offset={ailk_offset}"
2082 );
2083 }
2084
2085 #[tokio::test]
2086 async fn range_get_fast_path_matches_full_file_path_results() {
2087 let dir = TempDir::new().unwrap();
2093 let dim = 16usize;
2094 write_demo_table(&dir, dim, 20).await;
2095
2096 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2097 let catalog: Arc<dyn CatalogProvider> =
2098 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2099 let table = TableIdent::new("default", "table");
2100
2101 let mut query = vec![0.0f32; dim];
2102 query[3] = 1.0;
2103
2104 let fast_config = SearchConfig {
2105 top_k: 5,
2106 ef_search: 50,
2107 pruning_threshold: f32::INFINITY,
2108 rerank_factor: None,
2109 score_fn: None,
2110 partition_filter: None,
2111 hybrid: None,
2112 column_filter: None,
2113 };
2114 let fast_results = search(
2115 &table,
2116 &query,
2117 fast_config,
2118 "embedding",
2119 dim as u32,
2120 catalog.clone(),
2121 store.clone(),
2122 )
2123 .await
2124 .unwrap();
2125
2126 let slow_config = SearchConfig {
2127 top_k: 5,
2128 ef_search: 50,
2129 pruning_threshold: f32::INFINITY,
2130 rerank_factor: None,
2131 score_fn: None,
2132 partition_filter: None,
2133 hybrid: None,
2134 column_filter: Some(ailake_core::ColumnFilter::new(
2135 "id",
2136 ailake_core::FilterOp::Gte,
2137 ailake_core::FilterValue::I64(0),
2138 )),
2139 };
2140 let slow_results = search(
2141 &table,
2142 &query,
2143 slow_config,
2144 "embedding",
2145 dim as u32,
2146 catalog,
2147 store,
2148 )
2149 .await
2150 .unwrap();
2151
2152 assert_eq!(fast_results.len(), slow_results.len());
2153 assert!(!fast_results.is_empty());
2154 for (f, s) in fast_results.iter().zip(slow_results.iter()) {
2155 assert_eq!(f.row_id, s.row_id, "fast/slow path row_id mismatch");
2156 assert!(
2157 (f.distance - s.distance).abs() < 1e-4,
2158 "fast/slow path distance mismatch: {} vs {}",
2159 f.distance,
2160 s.distance
2161 );
2162 }
2163 }
2164
2165 #[tokio::test]
2166 async fn rerank_returns_correct_top_k_count() {
2167 let dir = TempDir::new().unwrap();
2168 let dim = 8usize;
2169 write_demo_table(&dir, dim, 8).await;
2170
2171 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2172 let catalog: Arc<dyn CatalogProvider> =
2173 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2174 let table = TableIdent::new("default", "table");
2175
2176 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2177 let config = SearchConfig {
2178 top_k: 3,
2179 ef_search: 50,
2180 pruning_threshold: f32::INFINITY,
2181 rerank_factor: Some(2),
2182 score_fn: None,
2183 partition_filter: None,
2184 hybrid: None,
2185 column_filter: None,
2186 };
2187
2188 let results = search(
2189 &table,
2190 &query,
2191 config,
2192 "embedding",
2193 dim as u32,
2194 catalog,
2195 store,
2196 )
2197 .await
2198 .unwrap();
2199
2200 assert_eq!(results.len(), 3);
2201 }
2202
2203 #[tokio::test]
2204 async fn rerank_nearest_is_exact_match() {
2205 let dir = TempDir::new().unwrap();
2206 let dim = 8usize;
2207 write_demo_table(&dir, dim, 8).await;
2208
2209 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2210 let catalog: Arc<dyn CatalogProvider> =
2211 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2212 let table = TableIdent::new("default", "table");
2213
2214 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2216 let config = SearchConfig {
2217 top_k: 1,
2218 ef_search: 50,
2219 pruning_threshold: f32::INFINITY,
2220 rerank_factor: Some(4),
2221 score_fn: None,
2222 partition_filter: None,
2223 hybrid: None,
2224 column_filter: None,
2225 };
2226
2227 let results = search(
2228 &table,
2229 &query,
2230 config,
2231 "embedding",
2232 dim as u32,
2233 catalog,
2234 store,
2235 )
2236 .await
2237 .unwrap();
2238
2239 assert_eq!(results.len(), 1);
2240 assert!(
2242 results[0].distance < 1e-3,
2243 "distance was {}",
2244 results[0].distance
2245 );
2246 assert_eq!(results[0].row_id, RowId::new(0));
2247 }
2248
2249 #[tokio::test]
2250 async fn no_rerank_matches_default_behavior() {
2251 let dir = TempDir::new().unwrap();
2252 let dim = 4usize;
2253 write_demo_table(&dir, dim, 4).await;
2254
2255 let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2256 let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2257 let cat_a: Arc<dyn CatalogProvider> =
2258 Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
2259 let cat_b: Arc<dyn CatalogProvider> =
2260 Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
2261 let table = TableIdent::new("default", "table");
2262
2263 let query = vec![1.0f32, 0.0, 0.0, 0.0];
2264 let cfg_plain = SearchConfig {
2265 top_k: 2,
2266 ef_search: 50,
2267 pruning_threshold: f32::INFINITY,
2268 rerank_factor: None,
2269 score_fn: None,
2270 partition_filter: None,
2271 hybrid: None,
2272 column_filter: None,
2273 };
2274 let cfg_rerank = SearchConfig {
2275 top_k: 2,
2276 ef_search: 50,
2277 pruning_threshold: f32::INFINITY,
2278 rerank_factor: Some(2),
2279 score_fn: None,
2280 partition_filter: None,
2281 hybrid: None,
2282 column_filter: None,
2283 };
2284
2285 let plain = search(
2286 &table,
2287 &query,
2288 cfg_plain,
2289 "embedding",
2290 dim as u32,
2291 cat_a,
2292 store_a,
2293 )
2294 .await
2295 .unwrap();
2296 let reranked = search(
2297 &table,
2298 &query,
2299 cfg_rerank,
2300 "embedding",
2301 dim as u32,
2302 cat_b,
2303 store_b,
2304 )
2305 .await
2306 .unwrap();
2307
2308 assert_eq!(plain[0].row_id, reranked[0].row_id);
2310 }
2311
2312 #[tokio::test]
2313 async fn multimodal_rrf_returns_top_k() {
2314 let dir = TempDir::new().unwrap();
2315 let dim = 4usize;
2316 write_demo_table(&dir, dim, 4).await;
2317
2318 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2319 let catalog: Arc<dyn CatalogProvider> =
2320 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2321 let table = TableIdent::new("default", "table");
2322
2323 let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
2326 let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
2327
2328 let queries = vec![
2329 ModalQuery {
2330 column: "embedding",
2331 query: &q1,
2332 weight: 0.7,
2333 dim: dim as u32,
2334 },
2335 ModalQuery {
2336 column: "embedding",
2337 query: &q2,
2338 weight: 0.3,
2339 dim: dim as u32,
2340 },
2341 ];
2342
2343 let config = SearchConfig {
2344 top_k: 2,
2345 ef_search: 50,
2346 pruning_threshold: f32::INFINITY,
2347 rerank_factor: None,
2348 score_fn: None,
2349 partition_filter: None,
2350 hybrid: None,
2351 column_filter: None,
2352 };
2353
2354 let results =
2355 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2356 .await
2357 .unwrap();
2358
2359 assert_eq!(results.len(), 2);
2360 assert!(results[0].distance <= 0.0);
2362 assert!(results[0].row_id.as_u64() < 4);
2364 }
2365
2366 #[tokio::test]
2370 async fn multimodal_rrf_cross_modal_different_dims() {
2371 let dir = TempDir::new().unwrap();
2372 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2373 let catalog: Arc<dyn CatalogProvider> =
2374 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2375 let table = TableIdent::new("default", "table");
2376
2377 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2379 let rows = 4usize;
2380 let ids: Vec<i32> = (0..rows as i32).collect();
2381 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2382
2383 let text_embs: Vec<Vec<f32>> = (0..rows)
2384 .map(|i| {
2385 let mut v = vec![0.0f32; 4];
2386 v[i % 4] = 1.0;
2387 v
2388 })
2389 .collect();
2390 let img_embs: Vec<Vec<f32>> = (0..rows)
2391 .map(|i| {
2392 let mut v = vec![0.0f32; 2];
2393 v[i % 2] = 1.0;
2394 v
2395 })
2396 .collect();
2397
2398 let text_policy = make_policy(4);
2399 let img_policy = VectorStoragePolicy {
2400 column_name: "img_embedding".to_string(),
2401 dim: 2,
2402 metric: VectorMetric::Cosine,
2403 precision: VectorPrecision::F16,
2404 pq: None,
2405 keep_raw_for_reranking: true,
2406 pre_normalize: false,
2407 hnsw_m: None,
2408 hnsw_ef_construction: None,
2409 ivf_residual: false,
2410 embedding_model: None,
2411 modality: None,
2412 partition_by: None,
2413 partition_value: None,
2414 partition_column_type: None,
2415 partition_fields: vec![],
2416 };
2417
2418 let mut writer = crate::TableWriter::create_or_open(
2419 catalog.clone(),
2420 store.clone(),
2421 text_policy,
2422 table.clone(),
2423 2,
2424 )
2425 .await
2426 .unwrap();
2427
2428 let batches = [
2429 MultiVectorBatch {
2430 policy: make_policy(4),
2431 embeddings: &text_embs,
2432 },
2433 MultiVectorBatch {
2434 policy: img_policy,
2435 embeddings: &img_embs,
2436 },
2437 ];
2438 writer.write_batch_multi(&batch, &batches).await.unwrap();
2439 writer.commit().await.unwrap();
2440
2441 let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
2443 let q_img = vec![1.0f32, 0.0];
2444
2445 let queries = vec![
2446 ModalQuery {
2447 column: "embedding",
2448 query: &q_text,
2449 weight: 0.6,
2450 dim: 4,
2451 },
2452 ModalQuery {
2453 column: "img_embedding",
2454 query: &q_img,
2455 weight: 0.4,
2456 dim: 2,
2457 },
2458 ];
2459 let config = SearchConfig {
2460 top_k: 2,
2461 ef_search: 50,
2462 pruning_threshold: f32::INFINITY,
2463 rerank_factor: None,
2464 score_fn: None,
2465 partition_filter: None,
2466 hybrid: None,
2467 column_filter: None,
2468 };
2469
2470 let results =
2471 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2472 .await
2473 .unwrap();
2474
2475 assert!(!results.is_empty(), "should return results");
2476 assert!(results[0].distance <= 0.0, "distance is -rrf_score");
2477 assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
2479 }
2480
2481 #[tokio::test]
2488 async fn fetch_rows_projects_evolved_column_as_null() {
2489 use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
2490
2491 let dir = TempDir::new().unwrap();
2492 let dim = 8usize;
2493 write_demo_table(&dir, dim, 8).await;
2494
2495 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2496 let catalog: Arc<dyn CatalogProvider> =
2497 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2498 let table = TableIdent::new("default", "table");
2499
2500 catalog
2503 .evolve_schema(
2504 &table,
2505 SchemaEvolution::new().add_column(AddColumnRequest {
2506 name: "note".to_string(),
2507 iceberg_type: "string".to_string(),
2508 required: false,
2509 initial_default: None,
2510 write_default: None,
2511 doc: None,
2512 }),
2513 )
2514 .await
2515 .unwrap();
2516
2517 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2518 let config = SearchConfig {
2519 top_k: 3,
2520 ef_search: 50,
2521 pruning_threshold: f32::INFINITY,
2522 rerank_factor: None,
2523 score_fn: None,
2524 partition_filter: None,
2525 hybrid: None,
2526 column_filter: None,
2527 };
2528 let results = search(
2529 &table,
2530 &query,
2531 config,
2532 "embedding",
2533 dim as u32,
2534 Arc::clone(&catalog),
2535 Arc::clone(&store),
2536 )
2537 .await
2538 .unwrap();
2539 assert_eq!(results.len(), 3);
2540
2541 let table_meta = catalog.load_table(&table).await.unwrap();
2542 let batch = fetch_rows(
2543 &results,
2544 store,
2545 "embedding",
2546 dim as u32,
2547 &table_meta.schema_fields,
2548 )
2549 .await
2550 .unwrap();
2551
2552 let note_col = batch
2553 .column_by_name("note")
2554 .expect("evolved 'note' column must be present, not silently dropped");
2555 assert_eq!(note_col.len(), 3);
2556 assert_eq!(
2557 note_col.null_count(),
2558 3,
2559 "old files predate 'note' — every value must be null, not an error or a missing column"
2560 );
2561 }
2562
2563 #[tokio::test]
2574 async fn fetch_rows_does_not_duplicate_vector_column() {
2575 let dir = TempDir::new().unwrap();
2576 let dim = 8usize;
2577 write_demo_table(&dir, dim, 8).await;
2578
2579 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2580 let catalog: Arc<dyn CatalogProvider> =
2581 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2582 let table = TableIdent::new("default", "table");
2583
2584 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2585 let config = SearchConfig {
2586 top_k: 3,
2587 ef_search: 50,
2588 pruning_threshold: f32::INFINITY,
2589 rerank_factor: None,
2590 score_fn: None,
2591 partition_filter: None,
2592 hybrid: None,
2593 column_filter: None,
2594 };
2595 let results = search(
2596 &table,
2597 &query,
2598 config,
2599 "embedding",
2600 dim as u32,
2601 Arc::clone(&catalog),
2602 Arc::clone(&store),
2603 )
2604 .await
2605 .unwrap();
2606
2607 let table_meta = catalog.load_table(&table).await.unwrap();
2608 let batch = fetch_rows(
2609 &results,
2610 store,
2611 "embedding",
2612 dim as u32,
2613 &table_meta.schema_fields,
2614 )
2615 .await
2616 .unwrap();
2617
2618 let batch_schema = batch.schema();
2619 let embedding_fields: Vec<_> = batch_schema
2620 .fields()
2621 .iter()
2622 .filter(|f| f.name() == "embedding")
2623 .collect();
2624 assert_eq!(
2625 embedding_fields.len(),
2626 1,
2627 "exactly one 'embedding' field expected, got: {:?}",
2628 batch_schema
2629 );
2630 assert!(
2631 matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2632 "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2633 embedding_fields[0].data_type()
2634 );
2635 }
2636}