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 if config.top_k > ailake_core::MAX_TOP_K {
199 return Err(AilakeError::InvalidArgument(format!(
200 "top_k {} exceeds maximum supported value ({})",
201 config.top_k,
202 ailake_core::MAX_TOP_K
203 )));
204 }
205 let all_files = catalog.list_files(table, None).await?;
207
208 let table_meta = catalog.load_table(table).await?;
210
211 let primary_col = table_meta
216 .properties
217 .get("ailake.vector-column")
218 .map(String::as_str)
219 .unwrap_or("");
220 let stored_dim_key = if vector_column == primary_col {
221 "ailake.vector-dim".to_string()
222 } else {
223 format!("ailake.dim-{vector_column}")
224 };
225 if let Some(table_dim_str) = table_meta.properties.get(&stored_dim_key) {
226 if let Ok(table_dim) = table_dim_str.parse::<u32>() {
227 let query_dim = query.len() as u32;
228 if query_dim != table_dim {
229 let table_model = table_meta
230 .properties
231 .get(EmbeddingModelInfo::property_key())
232 .cloned()
233 .unwrap_or_else(|| format!("dim={}", table_dim));
234 return Err(AilakeError::ModelMismatch {
235 table_model,
236 table_dim,
237 batch_model: format!("query dim={}", query_dim),
238 batch_dim: query_dim,
239 });
240 }
241 }
242 }
243
244 let metric_key = if vector_column == primary_col {
246 "ailake.vector-metric".to_string()
247 } else {
248 format!("ailake.metric-{vector_column}")
249 };
250 let metric = parse_metric(
251 table_meta
252 .properties
253 .get(&metric_key)
254 .or_else(|| table_meta.properties.get("ailake.vector-metric"))
255 .map(String::as_str)
256 .unwrap_or("cosine"),
257 );
258
259 let all_files = if let Some(ref pv) = config.partition_filter {
261 let before = all_files.len();
262 let filtered: Vec<_> = all_files
263 .into_iter()
264 .filter(|f| f.partition_value.as_deref() == Some(pv.as_str()))
265 .collect();
266 debug!(
267 "ailake: partition pruning '{}' — {}/{} files survive",
268 pv,
269 filtered.len(),
270 before
271 );
272 filtered
273 } else {
274 all_files
275 };
276
277 let total_files = all_files.len();
279 let surviving_files = VectorPruner::prune(all_files, query, metric, config.pruning_threshold);
280 debug!(
281 "ailake: geometric pruning — {}/{} files survive (threshold={})",
282 surviving_files.len(),
283 total_files,
284 config.pruning_threshold
285 );
286
287 let surviving_files = if let Some(ref h) = config.hybrid {
290 let bloom_map = load_bloom_map(&table_meta, store.as_ref()).await;
291 if !bloom_map.is_empty() {
292 BloomPruner::prune(surviving_files, &h.query_text, &bloom_map)
293 } else {
294 surviving_files
295 }
296 } else {
297 surviving_files
298 };
299
300 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
304 Ok(edfs) if !edfs.is_empty() => {
305 match EqualityDeleteFilter::from_files(&store, &edfs).await {
306 Ok(f) => f,
307 Err(e) => {
308 warn!("ailake: equality delete filter build failed: {e} — rows may appear");
309 EqualityDeleteFilter::empty()
310 }
311 }
312 }
313 _ => EqualityDeleteFilter::empty(),
314 };
315
316 let candidate_k = match (&config.hybrid, config.rerank_factor) {
318 (Some(h), rf) => {
319 let pool = h.candidate_pool.unwrap_or(config.top_k * 10);
320 pool.max(rf.map_or(config.top_k, |f| f * config.top_k))
321 }
322 (None, Some(factor)) => config.top_k * factor,
323 (None, None) => config.top_k,
324 };
325
326 let use_hybrid = config.hybrid.is_some();
327
328 let bm25_stats: Option<crate::bm25::IdfStats> = if let Some(ref h) = config.hybrid {
330 if h.text_columns.is_empty() {
331 None
332 } else {
333 let stats_path = table_meta
334 .properties
335 .get(crate::bm25::BM25_STATS_PATH_PROP)
336 .map(String::as_str)
337 .unwrap_or(crate::bm25::BM25_STATS_FILE);
338 match store.get(stats_path).await {
339 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).ok(),
340 Err(_) => {
341 debug!(
342 "ailake: BM25 stats not found at '{}' — falling back to empty corpus IDF",
343 stats_path
344 );
345 None
346 }
347 }
348 }
349 } else {
350 None
351 };
352
353 let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
356 let mut all_results: Vec<SearchResult> = Vec::new();
357
358 let mut flat_scan_deferred = 0usize;
365 let mut flat_scan_unexpected = 0usize;
366
367 let outcomes: Vec<FileSearchOutcome> = try_join_all(surviving_files.iter().map(|file_entry| {
376 search_one_file(
377 file_entry,
378 query,
379 candidate_k,
380 metric,
381 &table_meta,
382 &config,
383 vector_column,
384 dim,
385 &store,
386 &eq_del_filter,
387 use_hybrid,
388 )
389 }))
390 .await?;
391
392 for outcome in outcomes {
393 match outcome.flat_scan {
394 Some(FlatScanKind::Deferred) => flat_scan_deferred += 1,
395 Some(FlatScanKind::Unexpected) => flat_scan_unexpected += 1,
396 None => {}
397 }
398 all_results.extend(outcome.results);
399 raw_candidates.extend(outcome.candidates);
400 }
401
402 if flat_scan_unexpected > 0 {
403 warn!(
404 "ailake: search degraded — {}/{} files scanned without an AI-Lake index \
405 (unexpected — likely external rewrites; {} more in expected deferred-indexing \
406 state). Run compaction to restore O(log N) search on affected files",
407 flat_scan_unexpected,
408 surviving_files.len(),
409 flat_scan_deferred
410 );
411 } else if flat_scan_deferred > 0 {
412 debug!(
413 "ailake: search — {}/{} files scanned via flat fallback (deferred indexing)",
414 flat_scan_deferred,
415 surviving_files.len()
416 );
417 }
418
419 if let Some(ref h) = config.hybrid {
421 let empty_stats = crate::bm25::IdfStats::default();
422 let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
423 let scorer = crate::bm25::BM25Scorer::new(stats);
424
425 let bm25_scores_pre: Vec<f32> = raw_candidates
427 .iter()
428 .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
429 .collect();
430
431 let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
434 raw_candidates.into_iter().zip(bm25_scores_pre).collect();
435 candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
436 let n = candidates_with_bm25.len();
437
438 let vec_ranks: Vec<usize> = (0..n).collect();
439
440 let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
442 .iter()
443 .map(|(_, b)| *b)
444 .enumerate()
445 .collect();
446 bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
447 let mut bm25_rank_of = vec![0usize; n];
448 for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
449 bm25_rank_of[*idx] = rank;
450 }
451
452 use crate::bm25::{linear_score, rrf_score, HybridFusion};
453
454 let fused: Vec<f32> = match h.fusion {
455 HybridFusion::Rrf => vec_ranks
456 .iter()
457 .enumerate()
458 .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
459 .collect(),
460 HybridFusion::Linear => {
461 let min_d = candidates_with_bm25
462 .iter()
463 .map(|(r, _)| r.1)
464 .fold(f32::INFINITY, f32::min);
465 let max_d = candidates_with_bm25
466 .iter()
467 .map(|(r, _)| r.1)
468 .fold(f32::NEG_INFINITY, f32::max);
469 let min_b = candidates_with_bm25
470 .iter()
471 .map(|(_, b)| *b)
472 .fold(f32::INFINITY, f32::min);
473 let max_b = candidates_with_bm25
474 .iter()
475 .map(|(_, b)| *b)
476 .fold(f32::NEG_INFINITY, f32::max);
477 candidates_with_bm25
478 .iter()
479 .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
480 .collect()
481 }
482 };
483
484 for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
485 all_results.push(SearchResult {
486 row_id,
487 distance: fused[i],
488 file_path,
489 });
490 }
491
492 all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
494 } else {
495 all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
496 }
497
498 all_results.truncate(config.top_k);
499 Ok(all_results)
500}
501
502enum FlatScanKind {
506 Deferred,
508 Unexpected,
512}
513
514#[derive(Default)]
517struct FileSearchOutcome {
518 results: Vec<SearchResult>,
520 candidates: Vec<(RowId, f32, String, String)>,
522 flat_scan: Option<FlatScanKind>,
523}
524
525#[allow(clippy::too_many_arguments)]
532async fn search_one_file(
533 file_entry: &DataFileEntry,
534 query: &[f32],
535 candidate_k: usize,
536 metric: VectorMetric,
537 table_meta: &TableMetadata,
538 config: &SearchConfig,
539 vector_column: &str,
540 dim: u32,
541 store: &Arc<dyn Store>,
542 eq_del_filter: &EqualityDeleteFilter,
543 use_hybrid: bool,
544) -> AilakeResult<FileSearchOutcome> {
545 let mut outcome = FileSearchOutcome::default();
546
547 let dv_bitmap: Option<roaring::RoaringBitmap> = if let Some(ref dv) = file_entry.deletion_vector
553 {
554 match crate::dv::load_deletion_vector(store, dv).await {
555 Ok(bm) => {
556 debug!(
557 "ailake: DV loaded ({} deletions) for {}",
558 bm.len(),
559 file_entry.path
560 );
561 Some(bm)
562 }
563 Err(e) => {
564 warn!(
565 "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
566 file_entry.path
567 );
568 None
569 }
570 }
571 } else {
572 None
573 };
574
575 let primary_col = table_meta
585 .properties
586 .get("ailake.vector-column")
587 .map(String::as_str)
588 .unwrap_or("");
589 let fast_path_eligible = config.column_filter.is_none()
590 && config.rerank_factor.is_none()
591 && config.score_fn.is_none()
592 && !use_hybrid
593 && eq_del_filter.is_empty()
594 && file_entry.index_status == IndexStatus::Ready
595 && !file_entry.is_foreign()
596 && vector_column == primary_col;
597
598 if fast_path_eligible {
599 match crate::index_loader::load_primary_index(store, &file_entry.path).await {
600 Ok(index) => {
601 let local_results = index.search(query, candidate_k, config.ef_search);
602 for (row_id, approx_dist) in local_results {
603 if dv_bitmap
604 .as_ref()
605 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
606 {
607 continue;
608 }
609 outcome.results.push(SearchResult {
610 row_id,
611 distance: approx_dist,
612 file_path: file_entry.path.clone(),
613 });
614 }
615 return Ok(outcome);
616 }
617 Err(e) => {
618 debug!(
619 "ailake: range-GET fast path failed for {} ({e}) — falling back to full-file GET",
620 file_entry.path
621 );
622 }
623 }
624 }
625
626 let file_bytes: Bytes = store.get(&file_entry.path).await?;
627 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
628
629 let matching_row_ids: Option<std::collections::HashSet<u64>> =
636 match config.column_filter.as_ref() {
637 Some(filter) => {
638 let matching = reader.matching_row_ids(filter)?;
639 if matching.is_empty() {
640 return Ok(outcome);
641 }
642 Some(matching)
643 }
644 None => None,
645 };
646
647 let need_parquet = file_entry.index_status == IndexStatus::Indexing
650 || !reader.is_ailake_file()
651 || config.rerank_factor.is_some()
652 || config.score_fn.is_some()
653 || use_hybrid
654 || !eq_del_filter.is_empty();
655
656 if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
657 match file_entry.index_status {
658 IndexStatus::Indexing => {
659 outcome.flat_scan = Some(FlatScanKind::Deferred);
660 debug!(
661 "ailake: flat scan fallback for {} — index build in progress \
662 (deferred write, expected to resolve once background job completes)",
663 file_entry.path
664 );
665 }
666 IndexStatus::Failed => {
667 outcome.flat_scan = Some(FlatScanKind::Unexpected);
673 warn!(
674 "ailake: flat scan fallback for {} — background index build failed \
675 permanently ({}); serving via flat scan until the next compaction \
676 rebuilds the index",
677 file_entry.path,
678 file_entry
679 .index_error
680 .as_deref()
681 .unwrap_or("no error recorded")
682 );
683 }
684 IndexStatus::Ready => {
685 outcome.flat_scan = Some(FlatScanKind::Unexpected);
689 if file_entry.is_foreign() {
690 warn!(
691 "ailake: flat scan fallback for {} — file has no AI-Lake index \
692 and no centroid; likely rewritten by a generic Iceberg engine \
693 (OPTIMIZE / rewrite_data_files) with no knowledge of AI-Lake. \
694 Results are still correct (exact O(N) scan), but degraded until \
695 this file is recompacted by the AI-Lake SDK",
696 file_entry.path
697 );
698 } else {
699 warn!(
700 "ailake: flat scan fallback for {} — marked Ready but has no \
701 loadable AI-Lake index despite a recorded centroid; internal \
702 inconsistency, not an external rewrite. Run compaction to rebuild",
703 file_entry.path
704 );
705 }
706 }
707 }
708 let (raw_batch, raw_vectors) = reader.read_parquet()?;
709 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
711 for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
712 if dv_bitmap
714 .as_ref()
715 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
716 {
717 continue;
718 }
719 if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
721 continue;
722 }
723 if matching_row_ids
726 .as_ref()
727 .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
728 {
729 continue;
730 }
731 if use_hybrid {
732 let text = extract_text_for_row(
733 &batch,
734 row_id.as_u64() as usize,
735 config.hybrid.as_ref().unwrap(),
736 );
737 outcome
738 .candidates
739 .push((row_id, distance, file_entry.path.clone(), text));
740 } else {
741 let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
742 outcome.results.push(SearchResult {
743 row_id,
744 distance: final_score,
745 file_path: file_entry.path.clone(),
746 });
747 }
748 }
749 return Ok(outcome);
750 }
751
752 let index = reader.load_any_index_for_column(vector_column)?;
753 let local_results = index.search(query, candidate_k, config.ef_search);
754
755 let parquet_data = if need_parquet {
756 let (raw_batch, raw_vecs) = reader.read_parquet()?;
757 let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
759 Some((filled, raw_vecs))
760 } else {
761 None
762 };
763
764 for (row_id, approx_dist) in local_results {
765 if dv_bitmap
767 .as_ref()
768 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
769 {
770 continue;
771 }
772 let idx = row_id.as_u64() as usize;
773 if let Some((ref batch, _)) = parquet_data {
776 if eq_del_filter.should_delete_row(batch, idx) {
777 continue;
778 }
779 }
780 if matching_row_ids
783 .as_ref()
784 .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
785 {
786 continue;
787 }
788
789 let distance = if config.rerank_factor.is_some() {
790 match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
791 Some(v) => exact_distance(metric, query, v),
792 None => {
793 error!(
794 "ailake: invariant violated — row_id {} out of bounds \
795 (file={}); Parquet and HNSW node count out of sync; \
796 run compaction to rebuild",
797 idx, file_entry.path
798 );
799 f32::INFINITY
800 }
801 }
802 } else {
803 approx_dist
804 };
805
806 if use_hybrid {
807 let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
808 extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
809 });
810 outcome
811 .candidates
812 .push((row_id, distance, file_entry.path.clone(), text));
813 } else {
814 let final_score = if let Some((ref batch, _)) = parquet_data {
815 apply_score_fn(&config.score_fn, distance, row_id, batch)
816 } else {
817 distance
818 };
819 outcome.results.push(SearchResult {
820 row_id,
821 distance: final_score,
822 file_path: file_entry.path.clone(),
823 });
824 }
825 }
826
827 Ok(outcome)
828}
829
830fn extract_text_for_row(
832 batch: &RecordBatch,
833 row_idx: usize,
834 hybrid: &crate::bm25::HybridConfig,
835) -> String {
836 use arrow_array::cast::AsArray;
837 hybrid
838 .text_columns
839 .iter()
840 .filter_map(|col| {
841 batch.column_by_name(col).and_then(|arr| {
842 arr.as_string_opt::<i32>().and_then(|sa| {
843 if row_idx < sa.len() && sa.is_valid(row_idx) {
844 Some(sa.value(row_idx).to_string())
845 } else {
846 None
847 }
848 })
849 })
850 })
851 .collect::<Vec<_>>()
852 .join(" ")
853}
854
855#[derive(Debug, Clone)]
857pub struct ModalQuery<'a> {
858 pub column: &'a str,
860 pub query: &'a [f32],
862 pub weight: f32,
865 pub dim: u32,
868}
869
870#[derive(Debug, Clone, Copy, PartialEq, Eq)]
872pub enum FusionMethod {
873 Rrf,
877}
878
879pub async fn search_multimodal(
889 table: &TableIdent,
890 queries: &[ModalQuery<'_>],
891 config: SearchConfig,
892 catalog: Arc<dyn CatalogProvider>,
893 store: Arc<dyn Store>,
894 fusion: FusionMethod,
895) -> AilakeResult<Vec<SearchResult>> {
896 use std::collections::HashMap;
897
898 if queries.is_empty() {
899 return Err(AilakeError::InvalidArgument(
900 "search_multimodal requires at least one ModalQuery".into(),
901 ));
902 }
903 if config.top_k > ailake_core::MAX_TOP_K {
904 return Err(AilakeError::InvalidArgument(format!(
905 "top_k {} exceeds maximum supported value ({})",
906 config.top_k,
907 ailake_core::MAX_TOP_K
908 )));
909 }
910
911 let table_meta = catalog.load_table(table).await?;
913 let primary_col = table_meta
914 .properties
915 .get("ailake.vector-column")
916 .cloned()
917 .unwrap_or_default();
918 let primary_dim: u32 = table_meta
919 .properties
920 .get("ailake.vector-dim")
921 .and_then(|s| s.parse().ok())
922 .unwrap_or(0);
923
924 let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
926
927 let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
928 for mq in queries {
929 let resolved_dim = if mq.dim > 0 {
931 mq.dim
932 } else if mq.column == primary_col {
933 primary_dim
934 } else {
935 table_meta
936 .properties
937 .get(&format!("ailake.dim-{}", mq.column))
938 .and_then(|s| s.parse().ok())
939 .unwrap_or(mq.query.len() as u32)
940 };
941
942 let col_config = SearchConfig {
943 top_k: per_col_k,
944 ef_search: config.ef_search,
945 pruning_threshold: config.pruning_threshold,
946 rerank_factor: config.rerank_factor,
947 score_fn: None,
948 partition_filter: config.partition_filter.clone(),
949 hybrid: None,
950 column_filter: config.column_filter.clone(),
951 };
952 let results = search(
953 table,
954 mq.query,
955 col_config,
956 mq.column,
957 resolved_dim,
958 catalog.clone(),
959 store.clone(),
960 )
961 .await?;
962 per_col_results.push((mq.weight, results));
963 }
964
965 const K: f32 = 60.0;
967 let mut scores: HashMap<(String, u64), f32> = HashMap::new();
968
969 for (weight, results) in &per_col_results {
970 for (rank, r) in results.iter().enumerate() {
971 let key = (r.file_path.clone(), r.row_id.as_u64());
972 let rrf = weight / (K + rank as f32 + 1.0);
973 *scores.entry(key).or_insert(0.0) += rrf;
974 }
975 }
976
977 let all_files = catalog.list_files(table, None).await?;
980 let _ = all_files; let mut seen: HashMap<(String, u64), f32> = HashMap::new();
984 for (_, results) in &per_col_results {
985 for r in results {
986 let key = (r.file_path.clone(), r.row_id.as_u64());
987 let rrf_score = *scores.get(&key).unwrap_or(&0.0);
988 seen.entry(key).or_insert(rrf_score);
989 }
990 }
991
992 let mut fused: Vec<SearchResult> = seen
993 .into_iter()
994 .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
995 row_id: RowId::new(row_id_u64),
996 distance: -rrf_score,
997 file_path,
998 })
999 .collect();
1000
1001 fused.sort_by(|a, b| {
1002 a.distance
1003 .partial_cmp(&b.distance)
1004 .unwrap_or(std::cmp::Ordering::Equal)
1005 });
1006 fused.truncate(config.top_k);
1007
1008 let _ = fusion; Ok(fused)
1011}
1012
1013#[inline]
1018fn apply_score_fn(
1019 score_fn: &Option<ScoreFn>,
1020 distance: f32,
1021 row_id: RowId,
1022 batch: &RecordBatch,
1023) -> f32 {
1024 match score_fn {
1025 None => distance,
1026 Some(f) => {
1027 let idx = row_id.as_u64() as usize;
1028 if idx < batch.num_rows() {
1029 f.call(distance, &batch.slice(idx, 1))
1030 } else {
1031 distance
1032 }
1033 }
1034 }
1035}
1036
1037fn flat_search(
1039 raw: &[Vec<f32>],
1040 query: &[f32],
1041 top_k: usize,
1042 metric: VectorMetric,
1043) -> Vec<(RowId, f32)> {
1044 let mut results: Vec<(RowId, f32)> = raw
1045 .iter()
1046 .enumerate()
1047 .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
1048 .collect();
1049 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1050 results.truncate(top_k);
1051 results
1052}
1053
1054fn parse_metric(s: &str) -> VectorMetric {
1055 match s {
1056 "euclidean" => VectorMetric::Euclidean,
1057 "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
1058 "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
1059 _ => VectorMetric::Cosine,
1060 }
1061}
1062
1063pub struct SearchSession {
1074 shards: Vec<LoadedShard>,
1075 metric: VectorMetric,
1076}
1077
1078struct LoadedShard {
1079 entry: DataFileEntry,
1080 index: Option<AnyIndex>,
1082 raw_vectors: Option<Vec<Vec<f32>>>,
1085}
1086
1087impl SearchSession {
1088 pub async fn load(
1094 table: &TableIdent,
1095 vector_column: &str,
1096 dim: u32,
1097 catalog: Arc<dyn CatalogProvider>,
1098 store: Arc<dyn Store>,
1099 load_raw: bool,
1100 ) -> AilakeResult<Self> {
1101 let all_files = catalog.list_files(table, None).await?;
1102 let table_meta = catalog.load_table(table).await?;
1103 let metric = parse_metric(
1104 table_meta
1105 .properties
1106 .get("ailake.vector-metric")
1107 .map(String::as_str)
1108 .unwrap_or("cosine"),
1109 );
1110
1111 let mut shards = Vec::with_capacity(all_files.len());
1112 for entry in all_files {
1113 let file_bytes: Bytes = store.get(&entry.path).await?;
1114 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
1115
1116 if entry.index_status == IndexStatus::Indexing {
1117 let (_, raw_vecs) = reader.read_parquet()?;
1119 shards.push(LoadedShard {
1120 entry,
1121 index: None,
1122 raw_vectors: Some(raw_vecs),
1123 });
1124 } else if reader.is_ailake_file() {
1125 let mut index = reader.load_any_index_for_column(vector_column)?;
1126 let raw_vectors = if load_raw {
1127 index.quantize_to_f16();
1128 let (_, vecs) = reader.read_parquet()?;
1129 Some(vecs)
1130 } else {
1131 None
1132 };
1133 shards.push(LoadedShard {
1134 entry,
1135 index: Some(index),
1136 raw_vectors,
1137 });
1138 }
1139 }
1140
1141 Ok(Self { shards, metric })
1142 }
1143
1144 pub fn shard_count(&self) -> usize {
1146 self.shards.len()
1147 }
1148
1149 pub fn search_batch(
1158 &self,
1159 queries: &[Vec<f32>],
1160 config: &SearchConfig,
1161 ) -> Vec<Vec<SearchResult>> {
1162 if queries.is_empty() {
1163 return vec![];
1164 }
1165
1166 let n_queries = queries.len();
1167 let candidate_k = match config.rerank_factor {
1168 Some(factor) => config.top_k * factor,
1169 None => config.top_k,
1170 };
1171 let use_nvidia = ailake_index::hardware::detect_cuda();
1172 let use_amd = ailake_index::hardware::detect_rocm();
1173
1174 let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
1176
1177 for shard in &self.shards {
1178 if let Some(raw) = &shard.raw_vectors {
1179 if !raw.is_empty() {
1181 let dim = raw[0].len();
1182 let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
1183 let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
1184 let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1185
1186 let gpu_batch = if use_nvidia {
1187 ailake_index::gpu::try_nvidia_search_batch(
1188 &q_refs,
1189 &row_ids,
1190 &flat,
1191 dim,
1192 self.metric,
1193 candidate_k,
1194 )
1195 } else if use_amd {
1196 ailake_index::gpu::try_rocm_search_batch(
1197 &q_refs,
1198 &row_ids,
1199 &flat,
1200 dim,
1201 self.metric,
1202 candidate_k,
1203 )
1204 } else {
1205 None
1206 };
1207
1208 if let Some(batch) = gpu_batch {
1209 for (qi, results) in batch.into_iter().enumerate() {
1210 for (row_id, distance) in results {
1211 all_results[qi].push(SearchResult {
1212 row_id,
1213 distance,
1214 file_path: shard.entry.path.clone(),
1215 });
1216 }
1217 }
1218 continue;
1219 }
1220 }
1221
1222 for (qi, query) in queries.iter().enumerate() {
1224 for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1225 all_results[qi].push(SearchResult {
1226 row_id,
1227 distance,
1228 file_path: shard.entry.path.clone(),
1229 });
1230 }
1231 }
1232 } else if let Some(index) = &shard.index {
1233 let shard_results: Vec<Vec<SearchResult>> = queries
1235 .par_iter()
1236 .map(|query| {
1237 index
1238 .search(query, candidate_k, config.ef_search)
1239 .into_iter()
1240 .map(|(row_id, distance)| SearchResult {
1241 row_id,
1242 distance,
1243 file_path: shard.entry.path.clone(),
1244 })
1245 .collect()
1246 })
1247 .collect();
1248
1249 for (qi, results) in shard_results.into_iter().enumerate() {
1250 all_results[qi].extend(results);
1251 }
1252 }
1253 }
1254
1255 for results in &mut all_results {
1257 results.sort_by(|a, b| {
1258 a.distance
1259 .partial_cmp(&b.distance)
1260 .unwrap_or(std::cmp::Ordering::Equal)
1261 });
1262 results.truncate(config.top_k);
1263 }
1264
1265 all_results
1266 }
1267
1268 pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1270 let candidate_k = match config.rerank_factor {
1271 Some(factor) => config.top_k * factor,
1272 None => config.top_k,
1273 };
1274
1275 let mut all_results: Vec<SearchResult> = self
1276 .shards
1277 .par_iter()
1278 .flat_map(|shard| {
1279 if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1281 let dist = match self.metric {
1282 VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1283 ailake_vec::cosine_distance(query, ¢roid.values)
1284 }
1285 VectorMetric::Euclidean => {
1286 ailake_vec::euclidean_distance(query, ¢roid.values)
1287 }
1288 VectorMetric::DotProduct => {
1289 -ailake_vec::dot_product(query, ¢roid.values)
1290 }
1291 };
1292 if dist - centroid.radius > config.pruning_threshold {
1293 return vec![];
1294 }
1295 }
1296
1297 if let Some(index) = &shard.index {
1298 let local_results = index.search(query, candidate_k, config.ef_search);
1300 if config.rerank_factor.is_some() {
1301 if let Some(raw) = &shard.raw_vectors {
1302 local_results
1303 .into_iter()
1304 .map(|(row_id, _approx_dist)| {
1305 let idx = row_id.as_u64() as usize;
1306 let exact_dist = raw
1307 .get(idx)
1308 .map(|v| exact_distance(self.metric, query, v))
1309 .unwrap_or(f32::INFINITY);
1310 SearchResult {
1311 row_id,
1312 distance: exact_dist,
1313 file_path: shard.entry.path.clone(),
1314 }
1315 })
1316 .collect()
1317 } else {
1318 local_results
1319 .into_iter()
1320 .map(|(row_id, distance)| SearchResult {
1321 row_id,
1322 distance,
1323 file_path: shard.entry.path.clone(),
1324 })
1325 .collect()
1326 }
1327 } else {
1328 local_results
1329 .into_iter()
1330 .map(|(row_id, distance)| SearchResult {
1331 row_id,
1332 distance,
1333 file_path: shard.entry.path.clone(),
1334 })
1335 .collect()
1336 }
1337 } else if let Some(raw) = &shard.raw_vectors {
1338 flat_search(raw, query, candidate_k, self.metric)
1340 .into_iter()
1341 .map(|(row_id, distance)| SearchResult {
1342 row_id,
1343 distance,
1344 file_path: shard.entry.path.clone(),
1345 })
1346 .collect()
1347 } else {
1348 vec![]
1349 }
1350 })
1351 .collect();
1352
1353 all_results.sort_by(|a, b| {
1354 a.distance
1355 .partial_cmp(&b.distance)
1356 .unwrap_or(std::cmp::Ordering::Equal)
1357 });
1358 all_results.truncate(config.top_k);
1359 all_results
1360 }
1361}
1362
1363pub async fn search_text(
1376 table: &TableIdent,
1377 query_text: &str,
1378 text_columns: &[&str],
1379 top_k: usize,
1380 catalog: Arc<dyn CatalogProvider>,
1381 store: Arc<dyn Store>,
1382 partition_filter: Option<&str>,
1383) -> AilakeResult<Vec<SearchResult>> {
1384 use arrow_array::cast::AsArray;
1385
1386 if text_columns.is_empty() {
1387 return Err(AilakeError::InvalidArgument(
1388 "search_text requires at least one text column".into(),
1389 ));
1390 }
1391 if top_k > ailake_core::MAX_TOP_K {
1392 return Err(AilakeError::InvalidArgument(format!(
1393 "top_k {top_k} exceeds maximum supported value ({})",
1394 ailake_core::MAX_TOP_K
1395 )));
1396 }
1397
1398 let all_files = catalog.list_files(table, None).await?;
1399 let table_meta = catalog.load_table(table).await?;
1400
1401 let files: Vec<_> = if let Some(pv) = partition_filter {
1403 all_files
1404 .into_iter()
1405 .filter(|f| f.partition_value.as_deref() == Some(pv))
1406 .collect()
1407 } else {
1408 all_files
1409 };
1410
1411 let stats_path = table_meta
1413 .properties
1414 .get(crate::bm25::BM25_STATS_PATH_PROP)
1415 .map(String::as_str)
1416 .unwrap_or(crate::bm25::BM25_STATS_FILE);
1417 let stats = match store.get(stats_path).await {
1418 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1419 Err(_) => {
1420 debug!(
1421 "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1422 stats_path
1423 );
1424 crate::bm25::IdfStats::default()
1425 }
1426 };
1427 let scorer = crate::bm25::BM25Scorer::new(&stats);
1428
1429 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1431 Ok(edfs) if !edfs.is_empty() => {
1432 match EqualityDeleteFilter::from_files(&store, &edfs).await {
1433 Ok(f) => f,
1434 Err(e) => {
1435 warn!("ailake: equality delete filter build failed in search_text: {e}");
1436 EqualityDeleteFilter::empty()
1437 }
1438 }
1439 }
1440 _ => EqualityDeleteFilter::empty(),
1441 };
1442
1443 let mut results: Vec<SearchResult> = Vec::new();
1444
1445 for file_entry in &files {
1446 let file_bytes = store.get(&file_entry.path).await?;
1447 let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1449
1450 if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1453 match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1454 Ok(fts) => {
1455 let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1456 if !hits.is_empty() {
1457 let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1459 let (raw_batch, _) = reader2.read_parquet()?;
1460 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1461 for hit in hits {
1462 let row_idx = hit.row_id as usize;
1463 if row_idx >= batch.num_rows() {
1464 continue;
1465 }
1466 if eq_del_filter.should_delete_row(&batch, row_idx) {
1467 continue;
1468 }
1469 results.push(SearchResult {
1470 row_id: RowId::new(hit.row_id),
1471 distance: -hit.score,
1472 file_path: file_entry.path.clone(),
1473 });
1474 }
1475 }
1476 continue; }
1478 Err(e) => {
1479 warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1480 }
1482 }
1483 }
1484
1485 let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1487 let (raw_batch, _) = reader_fb.read_parquet()?;
1488 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1490
1491 for row_idx in 0..batch.num_rows() {
1492 if eq_del_filter.should_delete_row(&batch, row_idx) {
1494 continue;
1495 }
1496 let doc_text: String = text_columns
1497 .iter()
1498 .filter_map(|&col| {
1499 batch.column_by_name(col).and_then(|arr| {
1500 arr.as_string_opt::<i32>().and_then(|sa| {
1501 if sa.is_valid(row_idx) {
1502 Some(sa.value(row_idx).to_string())
1503 } else {
1504 None
1505 }
1506 })
1507 })
1508 })
1509 .collect::<Vec<_>>()
1510 .join(" ");
1511
1512 if doc_text.is_empty() {
1513 continue;
1514 }
1515
1516 let bm25 = scorer.score(query_text, &doc_text);
1517 if bm25 > 0.0 {
1518 results.push(SearchResult {
1520 row_id: RowId::new(row_idx as u64),
1521 distance: -bm25,
1522 file_path: file_entry.path.clone(),
1523 });
1524 }
1525 }
1526 }
1527
1528 results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1529 results.truncate(top_k);
1530 Ok(results)
1531}
1532
1533pub async fn fetch_rows(
1542 results: &[SearchResult],
1543 store: Arc<dyn Store>,
1544 vector_column: &str,
1545 dim: u32,
1546 schema_fields: &[SchemaField],
1547) -> AilakeResult<RecordBatch> {
1548 use std::collections::HashMap;
1549
1550 use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1551 use arrow_schema::{DataType, Field, Schema};
1552 use arrow_select::{concat::concat_batches, take::take};
1553
1554 if results.is_empty() {
1555 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1556 }
1557
1558 let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1560 for (i, r) in results.iter().enumerate() {
1561 by_file
1562 .entry(r.file_path.as_str())
1563 .or_default()
1564 .push((r.row_id.as_u64(), r.distance, i));
1565 }
1566
1567 use arrow_array::FixedSizeListArray;
1568
1569 let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1580 .iter()
1581 .filter(|sf| sf.name != vector_column)
1582 .cloned()
1583 .collect();
1584
1585 let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1587
1588 for (file_path, rows) in &by_file {
1589 let bytes = store.get(file_path).await?;
1590 let reader = AilakeFileReader::new(bytes, vector_column, dim);
1591 let (raw_batch, vectors) = reader.read_parquet()?;
1592 let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1602
1603 for &(row_id, distance, pos) in rows {
1604 let idx = row_id as usize;
1605 if idx >= batch.num_rows() {
1606 tracing::warn!(
1607 "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1608 idx,
1609 batch.num_rows(),
1610 file_path
1611 );
1612 continue;
1613 }
1614
1615 let indices = UInt32Array::from(vec![idx as u32]);
1616 let row_cols: Vec<ArrayRef> = batch
1617 .columns()
1618 .iter()
1619 .map(|col| {
1620 take(col.as_ref(), &indices, None)
1621 .map_err(|e| AilakeError::Arrow(e.to_string()))
1622 })
1623 .collect::<AilakeResult<Vec<_>>>()?;
1624
1625 let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1626 .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1627
1628 let vec = vectors
1630 .get(idx)
1631 .cloned()
1632 .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1633
1634 collected.push((pos, distance, row_batch, vec));
1635 }
1636 }
1637
1638 if collected.is_empty() {
1639 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1640 }
1641
1642 collected.sort_by_key(|(pos, _, _, _)| *pos);
1644
1645 let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1646 let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1647 let base_schema = collected[0].2.schema();
1648
1649 let combined =
1650 concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1651
1652 let flat_vecs: Vec<f32> = collected
1654 .iter()
1655 .flat_map(|(_, _, _, v)| v.iter().copied())
1656 .collect();
1657 let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1658 let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1659 let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1660 let vec_field = Arc::new(Field::new(
1661 vector_column,
1662 DataType::FixedSizeList(item_field, dim as i32),
1663 false,
1664 ));
1665
1666 let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1668 fields.push(vec_field);
1669 fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1670 let new_schema = Arc::new(Schema::new(fields));
1671
1672 let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1673 columns.push(Arc::new(vec_col));
1674 columns.push(Arc::new(Float32Array::from(distances)));
1675
1676 RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1677}
1678
1679async fn load_bloom_map(
1685 table_meta: &ailake_catalog::TableMetadata,
1686 store: &dyn Store,
1687) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1688 let stats_path = match &table_meta.current_statistics_path {
1689 Some(p) => p.clone(),
1690 None => return std::collections::HashMap::new(),
1691 };
1692 let bytes = match store.get(&stats_path).await {
1693 Ok(b) => b,
1694 Err(e) => {
1695 debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1696 return std::collections::HashMap::new();
1697 }
1698 };
1699 let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1700 let bloom_entries = match reader.read_bm25_blooms() {
1701 Ok(e) => e,
1702 Err(e) => {
1703 warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1704 return std::collections::HashMap::new();
1705 }
1706 };
1707 bloom_entries
1708 .into_iter()
1709 .filter_map(|entry| {
1710 let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1711 Some((entry.path, bf))
1712 })
1713 .collect()
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719 use crate::writer::MultiVectorBatch;
1720 use ailake_catalog::{HadoopCatalog, TableIdent};
1721 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1722 use ailake_store::LocalStore;
1723 use arrow_array::{Int32Array, RecordBatch};
1724 use arrow_schema::{DataType, Field, Schema};
1725 use std::sync::Arc;
1726 use tempfile::TempDir;
1727
1728 fn make_policy(dim: u32) -> VectorStoragePolicy {
1729 VectorStoragePolicy {
1730 column_name: "embedding".to_string(),
1731 dim,
1732 metric: VectorMetric::Cosine,
1733 precision: VectorPrecision::F16,
1734 pq: None,
1735 keep_raw_for_reranking: true,
1736 pre_normalize: false,
1737 hnsw_m: None,
1738 hnsw_ef_construction: None,
1739 ivf_residual: false,
1740 embedding_model: None,
1741 modality: None,
1742 partition_by: None,
1743 partition_value: None,
1744 partition_column_type: None,
1745 partition_fields: vec![],
1746 }
1747 }
1748
1749 async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1750 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1751 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1752 let table = TableIdent::new("default", "table");
1753
1754 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1755 let ids: Vec<i32> = (0..rows as i32).collect();
1756 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1757
1758 let embeddings: Vec<Vec<f32>> = (0..rows)
1760 .map(|i| {
1761 let mut v = vec![0.0f32; dim];
1762 v[i % dim] = 1.0;
1763 v
1764 })
1765 .collect();
1766
1767 let mut writer =
1768 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1769 .await
1770 .unwrap();
1771 writer.write_batch(&batch, &embeddings).await.unwrap();
1772 writer.commit().await.unwrap();
1773 }
1774
1775 async fn write_demo_table_with_category(dir: &TempDir, dim: usize, rows: usize) {
1780 use arrow_array::StringArray;
1781 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1782 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1783 let table = TableIdent::new("default", "table");
1784
1785 let schema = Arc::new(Schema::new(vec![
1786 Field::new("id", DataType::Int32, false),
1787 Field::new("category", DataType::Utf8, false),
1788 ]));
1789 let ids: Vec<i32> = (0..rows as i32).collect();
1790 let categories: Vec<&str> = (0..rows)
1791 .map(|i| if i % 2 == 0 { "even" } else { "odd" })
1792 .collect();
1793 let batch = RecordBatch::try_new(
1794 schema,
1795 vec![
1796 Arc::new(Int32Array::from(ids)),
1797 Arc::new(StringArray::from(categories)),
1798 ],
1799 )
1800 .unwrap();
1801
1802 let embeddings: Vec<Vec<f32>> = (0..rows)
1803 .map(|i| {
1804 let mut v = vec![0.0f32; dim];
1805 v[i % dim] = 1.0;
1806 v
1807 })
1808 .collect();
1809
1810 let mut writer =
1811 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1812 .await
1813 .unwrap();
1814 writer.write_batch(&batch, &embeddings).await.unwrap();
1815 writer.commit().await.unwrap();
1816 }
1817
1818 #[tokio::test]
1819 async fn column_filter_preserves_row_identity_on_indexed_path() {
1820 let dir = TempDir::new().unwrap();
1828 let dim = 8usize;
1829 write_demo_table_with_category(&dir, dim, 8).await;
1830
1831 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1832 let catalog: Arc<dyn CatalogProvider> =
1833 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1834 let table = TableIdent::new("default", "table");
1835
1836 let mut query = vec![0.0f32; dim];
1838 query[4] = 1.0;
1839
1840 let config = SearchConfig {
1841 top_k: 1,
1842 ef_search: 50,
1843 pruning_threshold: f32::INFINITY,
1844 rerank_factor: None,
1845 score_fn: None,
1846 partition_filter: None,
1847 hybrid: None,
1848 column_filter: Some(ailake_core::ColumnFilter::eq(
1849 "category",
1850 ailake_core::FilterValue::Str("even".to_string()),
1851 )),
1852 };
1853
1854 let results = search(
1855 &table,
1856 &query,
1857 config,
1858 "embedding",
1859 dim as u32,
1860 catalog,
1861 store,
1862 )
1863 .await
1864 .unwrap();
1865
1866 assert_eq!(results.len(), 1);
1867 assert_eq!(
1868 results[0].row_id.as_u64(),
1869 4,
1870 "row_id must be the true file-relative position, not a filtered-batch index"
1871 );
1872 }
1873
1874 #[tokio::test]
1875 async fn column_filter_excludes_non_matching_rows() {
1876 let dir = TempDir::new().unwrap();
1877 let dim = 8usize;
1878 write_demo_table_with_category(&dir, dim, 8).await;
1879
1880 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1881 let catalog: Arc<dyn CatalogProvider> =
1882 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1883 let table = TableIdent::new("default", "table");
1884
1885 let mut query = vec![0.0f32; dim];
1887 query[3] = 1.0;
1888
1889 let config = SearchConfig {
1890 top_k: 8,
1891 ef_search: 50,
1892 pruning_threshold: f32::INFINITY,
1893 rerank_factor: None,
1894 score_fn: None,
1895 partition_filter: None,
1896 hybrid: None,
1897 column_filter: Some(ailake_core::ColumnFilter::eq(
1898 "category",
1899 ailake_core::FilterValue::Str("even".to_string()),
1900 )),
1901 };
1902
1903 let results = search(
1904 &table,
1905 &query,
1906 config,
1907 "embedding",
1908 dim as u32,
1909 catalog,
1910 store,
1911 )
1912 .await
1913 .unwrap();
1914
1915 assert_eq!(results.len(), 4, "only the 4 even rows should survive");
1916 for r in &results {
1917 assert_eq!(
1918 r.row_id.as_u64() % 2,
1919 0,
1920 "row {} is not even",
1921 r.row_id.as_u64()
1922 );
1923 }
1924 }
1925
1926 #[tokio::test]
1927 async fn column_filter_no_match_returns_empty() {
1928 let dir = TempDir::new().unwrap();
1929 let dim = 8usize;
1930 write_demo_table_with_category(&dir, dim, 8).await;
1931
1932 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1933 let catalog: Arc<dyn CatalogProvider> =
1934 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1935 let table = TableIdent::new("default", "table");
1936
1937 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1938 let config = SearchConfig {
1939 top_k: 8,
1940 ef_search: 50,
1941 pruning_threshold: f32::INFINITY,
1942 rerank_factor: None,
1943 score_fn: None,
1944 partition_filter: None,
1945 hybrid: None,
1946 column_filter: Some(ailake_core::ColumnFilter::eq(
1947 "category",
1948 ailake_core::FilterValue::Str("nonexistent".to_string()),
1949 )),
1950 };
1951
1952 let results = search(
1953 &table,
1954 &query,
1955 config,
1956 "embedding",
1957 dim as u32,
1958 catalog,
1959 store,
1960 )
1961 .await
1962 .unwrap();
1963 assert!(results.is_empty());
1964 }
1965
1966 struct CountingStore {
1973 inner: Arc<dyn Store>,
1974 ranges_by_path: std::sync::Mutex<std::collections::HashMap<String, Vec<(u64, u64)>>>,
1975 }
1976
1977 impl CountingStore {
1978 fn new(inner: Arc<dyn Store>) -> Self {
1979 Self {
1980 inner,
1981 ranges_by_path: std::sync::Mutex::new(std::collections::HashMap::new()),
1982 }
1983 }
1984
1985 fn record(&self, path: &str, start: u64, end: u64) {
1986 self.ranges_by_path
1987 .lock()
1988 .unwrap()
1989 .entry(path.to_string())
1990 .or_default()
1991 .push((start, end));
1992 }
1993
1994 fn min_offset_for(&self, path: &str) -> Option<u64> {
1998 self.ranges_by_path
1999 .lock()
2000 .unwrap()
2001 .get(path)
2002 .and_then(|ranges| ranges.iter().map(|(s, _)| *s).min())
2003 }
2004 }
2005
2006 #[async_trait::async_trait]
2007 impl Store for CountingStore {
2008 async fn get(&self, path: &str) -> AilakeResult<Bytes> {
2009 let b = self.inner.get(path).await?;
2010 self.record(path, 0, b.len() as u64);
2011 Ok(b)
2012 }
2013 async fn get_range(&self, path: &str, range: std::ops::Range<u64>) -> AilakeResult<Bytes> {
2014 let b = self.inner.get_range(path, range.clone()).await?;
2015 self.record(path, range.start, range.end);
2016 Ok(b)
2017 }
2018 async fn put(&self, path: &str, data: Bytes) -> AilakeResult<()> {
2019 self.inner.put(path, data).await
2020 }
2021 async fn list(&self, prefix: &str) -> AilakeResult<Vec<String>> {
2022 self.inner.list(prefix).await
2023 }
2024 async fn file_size(&self, path: &str) -> AilakeResult<u64> {
2025 self.inner.file_size(path).await
2026 }
2027 async fn exists(&self, path: &str) -> AilakeResult<bool> {
2028 self.inner.exists(path).await
2029 }
2030 async fn delete(&self, path: &str) -> AilakeResult<()> {
2031 self.inner.delete(path).await
2032 }
2033 }
2034
2035 #[tokio::test]
2036 async fn range_get_fast_path_never_touches_tabular_data_section() {
2037 let dir = TempDir::new().unwrap();
2038 let dim = 64usize;
2039 write_demo_table(&dir, dim, 400).await;
2040
2041 let local: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2042 let data_files = local.list("data").await.unwrap();
2043 assert_eq!(
2044 data_files.len(),
2045 1,
2046 "expect a single part file for this test"
2047 );
2048 let data_path = data_files[0].clone();
2049
2050 let full_bytes = local.get(&data_path).await.unwrap();
2053 let ailk_offset = ailake_file::AilakeFileReader::new(full_bytes, "embedding", dim as u32)
2054 .ailk_offset()
2055 .unwrap();
2056
2057 let counting = Arc::new(CountingStore::new(local.clone()));
2058 let store: Arc<dyn Store> = counting.clone();
2059 let catalog: Arc<dyn CatalogProvider> =
2060 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2061 let table = TableIdent::new("default", "table");
2062
2063 let mut query = vec![0.0f32; dim];
2064 query[3] = 1.0;
2065 let config = SearchConfig {
2066 top_k: 5,
2067 ef_search: 50,
2068 pruning_threshold: f32::INFINITY,
2069 rerank_factor: None,
2070 score_fn: None,
2071 partition_filter: None,
2072 hybrid: None,
2073 column_filter: None,
2074 };
2075
2076 let results = search(
2077 &table,
2078 &query,
2079 config,
2080 "embedding",
2081 dim as u32,
2082 catalog,
2083 store,
2084 )
2085 .await
2086 .unwrap();
2087 assert!(!results.is_empty());
2088
2089 let min_offset = counting
2096 .min_offset_for(&data_path)
2097 .expect("should have fetched something from the data file");
2098 assert!(
2099 min_offset >= ailk_offset,
2100 "fast path fetched bytes from the tabular/vector data section: \
2101 min_offset={min_offset} < ailk_offset={ailk_offset}"
2102 );
2103 }
2104
2105 #[tokio::test]
2106 async fn range_get_fast_path_matches_full_file_path_results() {
2107 let dir = TempDir::new().unwrap();
2113 let dim = 16usize;
2114 write_demo_table(&dir, dim, 20).await;
2115
2116 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2117 let catalog: Arc<dyn CatalogProvider> =
2118 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2119 let table = TableIdent::new("default", "table");
2120
2121 let mut query = vec![0.0f32; dim];
2122 query[3] = 1.0;
2123
2124 let fast_config = SearchConfig {
2125 top_k: 5,
2126 ef_search: 50,
2127 pruning_threshold: f32::INFINITY,
2128 rerank_factor: None,
2129 score_fn: None,
2130 partition_filter: None,
2131 hybrid: None,
2132 column_filter: None,
2133 };
2134 let fast_results = search(
2135 &table,
2136 &query,
2137 fast_config,
2138 "embedding",
2139 dim as u32,
2140 catalog.clone(),
2141 store.clone(),
2142 )
2143 .await
2144 .unwrap();
2145
2146 let slow_config = SearchConfig {
2147 top_k: 5,
2148 ef_search: 50,
2149 pruning_threshold: f32::INFINITY,
2150 rerank_factor: None,
2151 score_fn: None,
2152 partition_filter: None,
2153 hybrid: None,
2154 column_filter: Some(ailake_core::ColumnFilter::new(
2155 "id",
2156 ailake_core::FilterOp::Gte,
2157 ailake_core::FilterValue::I64(0),
2158 )),
2159 };
2160 let slow_results = search(
2161 &table,
2162 &query,
2163 slow_config,
2164 "embedding",
2165 dim as u32,
2166 catalog,
2167 store,
2168 )
2169 .await
2170 .unwrap();
2171
2172 assert_eq!(fast_results.len(), slow_results.len());
2173 assert!(!fast_results.is_empty());
2174 for (f, s) in fast_results.iter().zip(slow_results.iter()) {
2175 assert_eq!(f.row_id, s.row_id, "fast/slow path row_id mismatch");
2176 assert!(
2177 (f.distance - s.distance).abs() < 1e-4,
2178 "fast/slow path distance mismatch: {} vs {}",
2179 f.distance,
2180 s.distance
2181 );
2182 }
2183 }
2184
2185 #[tokio::test]
2186 async fn rerank_returns_correct_top_k_count() {
2187 let dir = TempDir::new().unwrap();
2188 let dim = 8usize;
2189 write_demo_table(&dir, dim, 8).await;
2190
2191 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2192 let catalog: Arc<dyn CatalogProvider> =
2193 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2194 let table = TableIdent::new("default", "table");
2195
2196 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2197 let config = SearchConfig {
2198 top_k: 3,
2199 ef_search: 50,
2200 pruning_threshold: f32::INFINITY,
2201 rerank_factor: Some(2),
2202 score_fn: None,
2203 partition_filter: None,
2204 hybrid: None,
2205 column_filter: None,
2206 };
2207
2208 let results = search(
2209 &table,
2210 &query,
2211 config,
2212 "embedding",
2213 dim as u32,
2214 catalog,
2215 store,
2216 )
2217 .await
2218 .unwrap();
2219
2220 assert_eq!(results.len(), 3);
2221 }
2222
2223 #[tokio::test]
2224 async fn rerank_nearest_is_exact_match() {
2225 let dir = TempDir::new().unwrap();
2226 let dim = 8usize;
2227 write_demo_table(&dir, dim, 8).await;
2228
2229 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2230 let catalog: Arc<dyn CatalogProvider> =
2231 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2232 let table = TableIdent::new("default", "table");
2233
2234 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2236 let config = SearchConfig {
2237 top_k: 1,
2238 ef_search: 50,
2239 pruning_threshold: f32::INFINITY,
2240 rerank_factor: Some(4),
2241 score_fn: None,
2242 partition_filter: None,
2243 hybrid: None,
2244 column_filter: None,
2245 };
2246
2247 let results = search(
2248 &table,
2249 &query,
2250 config,
2251 "embedding",
2252 dim as u32,
2253 catalog,
2254 store,
2255 )
2256 .await
2257 .unwrap();
2258
2259 assert_eq!(results.len(), 1);
2260 assert!(
2262 results[0].distance < 1e-3,
2263 "distance was {}",
2264 results[0].distance
2265 );
2266 assert_eq!(results[0].row_id, RowId::new(0));
2267 }
2268
2269 #[tokio::test]
2270 async fn no_rerank_matches_default_behavior() {
2271 let dir = TempDir::new().unwrap();
2272 let dim = 4usize;
2273 write_demo_table(&dir, dim, 4).await;
2274
2275 let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2276 let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2277 let cat_a: Arc<dyn CatalogProvider> =
2278 Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
2279 let cat_b: Arc<dyn CatalogProvider> =
2280 Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
2281 let table = TableIdent::new("default", "table");
2282
2283 let query = vec![1.0f32, 0.0, 0.0, 0.0];
2284 let cfg_plain = SearchConfig {
2285 top_k: 2,
2286 ef_search: 50,
2287 pruning_threshold: f32::INFINITY,
2288 rerank_factor: None,
2289 score_fn: None,
2290 partition_filter: None,
2291 hybrid: None,
2292 column_filter: None,
2293 };
2294 let cfg_rerank = SearchConfig {
2295 top_k: 2,
2296 ef_search: 50,
2297 pruning_threshold: f32::INFINITY,
2298 rerank_factor: Some(2),
2299 score_fn: None,
2300 partition_filter: None,
2301 hybrid: None,
2302 column_filter: None,
2303 };
2304
2305 let plain = search(
2306 &table,
2307 &query,
2308 cfg_plain,
2309 "embedding",
2310 dim as u32,
2311 cat_a,
2312 store_a,
2313 )
2314 .await
2315 .unwrap();
2316 let reranked = search(
2317 &table,
2318 &query,
2319 cfg_rerank,
2320 "embedding",
2321 dim as u32,
2322 cat_b,
2323 store_b,
2324 )
2325 .await
2326 .unwrap();
2327
2328 assert_eq!(plain[0].row_id, reranked[0].row_id);
2330 }
2331
2332 #[tokio::test]
2333 async fn multimodal_rrf_returns_top_k() {
2334 let dir = TempDir::new().unwrap();
2335 let dim = 4usize;
2336 write_demo_table(&dir, dim, 4).await;
2337
2338 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2339 let catalog: Arc<dyn CatalogProvider> =
2340 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2341 let table = TableIdent::new("default", "table");
2342
2343 let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
2346 let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
2347
2348 let queries = vec![
2349 ModalQuery {
2350 column: "embedding",
2351 query: &q1,
2352 weight: 0.7,
2353 dim: dim as u32,
2354 },
2355 ModalQuery {
2356 column: "embedding",
2357 query: &q2,
2358 weight: 0.3,
2359 dim: dim as u32,
2360 },
2361 ];
2362
2363 let config = SearchConfig {
2364 top_k: 2,
2365 ef_search: 50,
2366 pruning_threshold: f32::INFINITY,
2367 rerank_factor: None,
2368 score_fn: None,
2369 partition_filter: None,
2370 hybrid: None,
2371 column_filter: None,
2372 };
2373
2374 let results =
2375 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2376 .await
2377 .unwrap();
2378
2379 assert_eq!(results.len(), 2);
2380 assert!(results[0].distance <= 0.0);
2382 assert!(results[0].row_id.as_u64() < 4);
2384 }
2385
2386 #[tokio::test]
2390 async fn multimodal_rrf_cross_modal_different_dims() {
2391 let dir = TempDir::new().unwrap();
2392 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2393 let catalog: Arc<dyn CatalogProvider> =
2394 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2395 let table = TableIdent::new("default", "table");
2396
2397 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2399 let rows = 4usize;
2400 let ids: Vec<i32> = (0..rows as i32).collect();
2401 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2402
2403 let text_embs: Vec<Vec<f32>> = (0..rows)
2404 .map(|i| {
2405 let mut v = vec![0.0f32; 4];
2406 v[i % 4] = 1.0;
2407 v
2408 })
2409 .collect();
2410 let img_embs: Vec<Vec<f32>> = (0..rows)
2411 .map(|i| {
2412 let mut v = vec![0.0f32; 2];
2413 v[i % 2] = 1.0;
2414 v
2415 })
2416 .collect();
2417
2418 let text_policy = make_policy(4);
2419 let img_policy = VectorStoragePolicy {
2420 column_name: "img_embedding".to_string(),
2421 dim: 2,
2422 metric: VectorMetric::Cosine,
2423 precision: VectorPrecision::F16,
2424 pq: None,
2425 keep_raw_for_reranking: true,
2426 pre_normalize: false,
2427 hnsw_m: None,
2428 hnsw_ef_construction: None,
2429 ivf_residual: false,
2430 embedding_model: None,
2431 modality: None,
2432 partition_by: None,
2433 partition_value: None,
2434 partition_column_type: None,
2435 partition_fields: vec![],
2436 };
2437
2438 let mut writer = crate::TableWriter::create_or_open(
2439 catalog.clone(),
2440 store.clone(),
2441 text_policy,
2442 table.clone(),
2443 2,
2444 )
2445 .await
2446 .unwrap();
2447
2448 let batches = [
2449 MultiVectorBatch {
2450 policy: make_policy(4),
2451 embeddings: &text_embs,
2452 },
2453 MultiVectorBatch {
2454 policy: img_policy,
2455 embeddings: &img_embs,
2456 },
2457 ];
2458 writer.write_batch_multi(&batch, &batches).await.unwrap();
2459 writer.commit().await.unwrap();
2460
2461 let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
2463 let q_img = vec![1.0f32, 0.0];
2464
2465 let queries = vec![
2466 ModalQuery {
2467 column: "embedding",
2468 query: &q_text,
2469 weight: 0.6,
2470 dim: 4,
2471 },
2472 ModalQuery {
2473 column: "img_embedding",
2474 query: &q_img,
2475 weight: 0.4,
2476 dim: 2,
2477 },
2478 ];
2479 let config = SearchConfig {
2480 top_k: 2,
2481 ef_search: 50,
2482 pruning_threshold: f32::INFINITY,
2483 rerank_factor: None,
2484 score_fn: None,
2485 partition_filter: None,
2486 hybrid: None,
2487 column_filter: None,
2488 };
2489
2490 let results =
2491 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2492 .await
2493 .unwrap();
2494
2495 assert!(!results.is_empty(), "should return results");
2496 assert!(results[0].distance <= 0.0, "distance is -rrf_score");
2497 assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
2499 }
2500
2501 #[tokio::test]
2508 async fn fetch_rows_projects_evolved_column_as_null() {
2509 use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
2510
2511 let dir = TempDir::new().unwrap();
2512 let dim = 8usize;
2513 write_demo_table(&dir, dim, 8).await;
2514
2515 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2516 let catalog: Arc<dyn CatalogProvider> =
2517 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2518 let table = TableIdent::new("default", "table");
2519
2520 catalog
2523 .evolve_schema(
2524 &table,
2525 SchemaEvolution::new().add_column(AddColumnRequest {
2526 name: "note".to_string(),
2527 iceberg_type: "string".to_string(),
2528 required: false,
2529 initial_default: None,
2530 write_default: None,
2531 doc: None,
2532 }),
2533 )
2534 .await
2535 .unwrap();
2536
2537 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2538 let config = SearchConfig {
2539 top_k: 3,
2540 ef_search: 50,
2541 pruning_threshold: f32::INFINITY,
2542 rerank_factor: None,
2543 score_fn: None,
2544 partition_filter: None,
2545 hybrid: None,
2546 column_filter: None,
2547 };
2548 let results = search(
2549 &table,
2550 &query,
2551 config,
2552 "embedding",
2553 dim as u32,
2554 Arc::clone(&catalog),
2555 Arc::clone(&store),
2556 )
2557 .await
2558 .unwrap();
2559 assert_eq!(results.len(), 3);
2560
2561 let table_meta = catalog.load_table(&table).await.unwrap();
2562 let batch = fetch_rows(
2563 &results,
2564 store,
2565 "embedding",
2566 dim as u32,
2567 &table_meta.schema_fields,
2568 )
2569 .await
2570 .unwrap();
2571
2572 let note_col = batch
2573 .column_by_name("note")
2574 .expect("evolved 'note' column must be present, not silently dropped");
2575 assert_eq!(note_col.len(), 3);
2576 assert_eq!(
2577 note_col.null_count(),
2578 3,
2579 "old files predate 'note' — every value must be null, not an error or a missing column"
2580 );
2581 }
2582
2583 #[tokio::test]
2594 async fn fetch_rows_does_not_duplicate_vector_column() {
2595 let dir = TempDir::new().unwrap();
2596 let dim = 8usize;
2597 write_demo_table(&dir, dim, 8).await;
2598
2599 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2600 let catalog: Arc<dyn CatalogProvider> =
2601 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2602 let table = TableIdent::new("default", "table");
2603
2604 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2605 let config = SearchConfig {
2606 top_k: 3,
2607 ef_search: 50,
2608 pruning_threshold: f32::INFINITY,
2609 rerank_factor: None,
2610 score_fn: None,
2611 partition_filter: None,
2612 hybrid: None,
2613 column_filter: None,
2614 };
2615 let results = search(
2616 &table,
2617 &query,
2618 config,
2619 "embedding",
2620 dim as u32,
2621 Arc::clone(&catalog),
2622 Arc::clone(&store),
2623 )
2624 .await
2625 .unwrap();
2626
2627 let table_meta = catalog.load_table(&table).await.unwrap();
2628 let batch = fetch_rows(
2629 &results,
2630 store,
2631 "embedding",
2632 dim as u32,
2633 &table_meta.schema_fields,
2634 )
2635 .await
2636 .unwrap();
2637
2638 let batch_schema = batch.schema();
2639 let embedding_fields: Vec<_> = batch_schema
2640 .fields()
2641 .iter()
2642 .filter(|f| f.name() == "embedding")
2643 .collect();
2644 assert_eq!(
2645 embedding_fields.len(),
2646 1,
2647 "exactly one 'embedding' field expected, got: {:?}",
2648 batch_schema
2649 );
2650 assert!(
2651 matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2652 "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2653 embedding_fields[0].data_type()
2654 );
2655 }
2656
2657 #[tokio::test]
2660 async fn search_dimension_mismatch_rejected() {
2661 let dir = TempDir::new().unwrap();
2662 write_demo_table(&dir, 8, 5).await;
2663
2664 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2665 let catalog: Arc<dyn CatalogProvider> =
2666 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2667 let table = TableIdent::new("default", "table");
2668
2669 let query = vec![1.0f32, 0.0, 0.0, 0.0];
2671 let config = SearchConfig::default();
2672 let result = search(
2673 &table,
2674 &query,
2675 config,
2676 "embedding",
2677 8,
2678 Arc::clone(&catalog),
2679 Arc::clone(&store),
2680 )
2681 .await;
2682 assert!(
2683 result.is_err(),
2684 "search with wrong dim should error, got Ok"
2685 );
2686 }
2687
2688 #[tokio::test]
2689 async fn search_top_k_over_cap_rejected() {
2690 let dir = TempDir::new().unwrap();
2696 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2697 let catalog: Arc<dyn CatalogProvider> =
2698 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2699 let table = TableIdent::new("default", "table");
2700
2701 let config = SearchConfig {
2702 top_k: ailake_core::MAX_TOP_K + 1,
2703 ..SearchConfig::default()
2704 };
2705 let result = search(
2706 &table,
2707 &[1.0f32, 0.0, 0.0, 0.0],
2708 config,
2709 "embedding",
2710 4,
2711 catalog,
2712 store,
2713 )
2714 .await;
2715 let err = result.expect_err("top_k over cap should error");
2716 assert!(err.to_string().contains("top_k"), "{err}");
2717 }
2718
2719 #[tokio::test]
2720 async fn search_multimodal_top_k_over_cap_rejected() {
2721 let dir = TempDir::new().unwrap();
2722 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2723 let catalog: Arc<dyn CatalogProvider> =
2724 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2725 let table = TableIdent::new("default", "table");
2726
2727 let config = SearchConfig {
2728 top_k: ailake_core::MAX_TOP_K + 1,
2729 ..SearchConfig::default()
2730 };
2731 let queries = vec![ModalQuery {
2732 column: "embedding",
2733 query: &[1.0f32, 0.0, 0.0, 0.0],
2734 weight: 1.0,
2735 dim: 4,
2736 }];
2737 let result =
2738 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf).await;
2739 let err = result.expect_err("top_k over cap should error");
2740 assert!(err.to_string().contains("top_k"), "{err}");
2741 }
2742
2743 #[tokio::test]
2744 async fn search_text_top_k_over_cap_rejected() {
2745 let dir = TempDir::new().unwrap();
2746 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2747 let catalog: Arc<dyn CatalogProvider> =
2748 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2749 let table = TableIdent::new("default", "table");
2750
2751 let result = search_text(
2752 &table,
2753 "hello",
2754 &["chunk_text"],
2755 ailake_core::MAX_TOP_K + 1,
2756 catalog,
2757 store,
2758 None,
2759 )
2760 .await;
2761 let err = result.expect_err("top_k over cap should error");
2762 assert!(err.to_string().contains("top_k"), "{err}");
2763 }
2764
2765 #[cfg(test)]
2768 mod fuzz_tests {
2769 use super::*;
2770 use crate::writer::TableWriter;
2771 use ailake_catalog::{HadoopCatalog, TableIdent, TableProperties};
2772 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
2773 use ailake_store::{fail_store::FailStore, LocalStore};
2774 use arrow_array::{Int32Array, RecordBatch};
2775 use arrow_schema::{DataType, Field, Schema};
2776 use proptest::prelude::*;
2777 use std::sync::Arc;
2778 use tempfile::TempDir;
2779
2780 fn make_fuzz_policy(dim: u32) -> VectorStoragePolicy {
2781 VectorStoragePolicy {
2782 column_name: "embedding".to_string(),
2783 dim,
2784 metric: VectorMetric::Cosine,
2785 precision: VectorPrecision::F16,
2786 pq: None,
2787 keep_raw_for_reranking: true,
2788 pre_normalize: false,
2789 hnsw_m: None,
2790 hnsw_ef_construction: None,
2791 ivf_residual: false,
2792 embedding_model: None,
2793 modality: None,
2794 partition_by: None,
2795 partition_value: None,
2796 partition_column_type: None,
2797 partition_fields: vec![],
2798 }
2799 }
2800
2801 #[derive(Debug, Clone)]
2802 struct FailProfile {
2803 fail_get: bool,
2804 fail_get_range: bool,
2805 fail_put: bool,
2806 fail_list: bool,
2807 fail_file_size: bool,
2808 }
2809
2810 fn arb_fail_profile() -> impl Strategy<Value = FailProfile> {
2811 (
2812 proptest::bool::ANY,
2813 proptest::bool::ANY,
2814 proptest::bool::ANY,
2815 proptest::bool::ANY,
2816 proptest::bool::ANY,
2817 )
2818 .prop_map(|(g, gr, p, l, fs)| FailProfile {
2819 fail_get: g,
2820 fail_get_range: gr,
2821 fail_put: p,
2822 fail_list: l,
2823 fail_file_size: fs,
2824 })
2825 }
2826
2827 #[derive(Debug, Clone, PartialEq)]
2828 enum FuzzOutcome {
2829 Ok,
2830 StoreError,
2831 OtherError,
2832 }
2833
2834 fn is_store_err(e: &ailake_core::AilakeError) -> bool {
2835 matches!(e, ailake_core::AilakeError::Store(_))
2836 }
2837
2838 async fn write_search_rt(profile: &FailProfile, rows: usize, dim: u32) -> FuzzOutcome {
2839 let dir = TempDir::new().unwrap();
2840 let inner = LocalStore::new(dir.path());
2841 let fail = FailStore::new(inner)
2842 .with_fail_get(profile.fail_get)
2843 .with_fail_get_range(profile.fail_get_range)
2844 .with_fail_put(profile.fail_put)
2845 .with_fail_list(profile.fail_list)
2846 .with_fail_file_size(profile.fail_file_size);
2847 let store: Arc<dyn Store> = Arc::new(fail);
2848 let catalog: Arc<dyn CatalogProvider> =
2849 Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2850 let table = TableIdent::new("default", "fuzz_test");
2851 let policy = make_fuzz_policy(dim);
2852
2853 if catalog
2854 .create_table(
2855 &table,
2856 &TableProperties {
2857 policy: policy.clone(),
2858 format_version: 2,
2859 extra: Default::default(),
2860 partition_column_type: None,
2861 },
2862 )
2863 .await
2864 .is_err()
2865 {
2866 return FuzzOutcome::StoreError;
2867 }
2868
2869 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2870 let ids: Vec<i32> = (0..rows as i32).collect();
2871 let batch =
2872 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2873 let embeddings: Vec<Vec<f32>> = (0..rows)
2874 .map(|i| {
2875 let mut v = vec![0.0f32; dim as usize];
2876 v[i % dim as usize] = 1.0;
2877 v
2878 })
2879 .collect();
2880
2881 let mut writer = match TableWriter::create_or_open(
2882 Arc::clone(&catalog),
2883 Arc::clone(&store),
2884 policy.clone(),
2885 table.clone(),
2886 2,
2887 )
2888 .await
2889 {
2890 Ok(w) => w,
2891 Err(e) => {
2892 return if is_store_err(&e) {
2893 FuzzOutcome::StoreError
2894 } else {
2895 FuzzOutcome::OtherError
2896 }
2897 }
2898 };
2899
2900 if let Err(e) = writer.write_batch(&batch, &embeddings).await {
2901 return if is_store_err(&e) {
2902 FuzzOutcome::StoreError
2903 } else {
2904 FuzzOutcome::OtherError
2905 };
2906 }
2907
2908 if let Err(e) = writer.commit().await {
2909 return if is_store_err(&e) {
2910 FuzzOutcome::StoreError
2911 } else {
2912 FuzzOutcome::OtherError
2913 };
2914 }
2915
2916 let query: Vec<f32> = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2917 let config = SearchConfig {
2918 top_k: 3.min(rows),
2919 ef_search: 50,
2920 pruning_threshold: f32::INFINITY,
2921 ..Default::default()
2922 };
2923
2924 match search(
2925 &table,
2926 &query[..dim as usize],
2927 config,
2928 "embedding",
2929 dim,
2930 Arc::clone(&catalog),
2931 Arc::clone(&store),
2932 )
2933 .await
2934 {
2935 Ok(results) => {
2936 assert!(results.len() <= rows.min(3), "more results than requested");
2937 for r in &results {
2938 assert!(r.distance.is_finite(), "non-finite distance");
2939 assert!(r.distance >= 0.0, "negative distance");
2940 }
2941 FuzzOutcome::Ok
2942 }
2943 Err(e) => {
2944 if is_store_err(&e) {
2945 FuzzOutcome::StoreError
2946 } else {
2947 panic!("unexpected non-store search error: {:?}", e);
2948 }
2949 }
2950 }
2951 }
2952
2953 async fn compact_rt(profile: &FailProfile, rows: usize, dim: u32) -> &'static str {
2954 let dir = TempDir::new().unwrap();
2955 let inner = LocalStore::new(dir.path());
2956 let fail = FailStore::new(inner)
2957 .with_fail_get(profile.fail_get)
2958 .with_fail_get_range(profile.fail_get_range)
2959 .with_fail_put(profile.fail_put)
2960 .with_fail_list(profile.fail_list)
2961 .with_fail_file_size(profile.fail_file_size);
2962 let store: Arc<dyn Store> = Arc::new(fail);
2963 let catalog: Arc<dyn CatalogProvider> =
2964 Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2965 let table = TableIdent::new("default", "fuzz_compact");
2966 let policy = make_fuzz_policy(dim);
2967
2968 if catalog
2969 .create_table(
2970 &table,
2971 &TableProperties {
2972 policy: policy.clone(),
2973 format_version: 2,
2974 extra: Default::default(),
2975 partition_column_type: None,
2976 },
2977 )
2978 .await
2979 .is_err()
2980 {
2981 return "store_err";
2982 }
2983
2984 for i in 0..2 {
2985 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2986 let ids: Vec<i32> = (0..rows as i32).collect();
2987 let batch =
2988 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2989 let embeddings: Vec<Vec<f32>> = (0..rows)
2990 .map(|j| {
2991 let mut v = vec![0.0f32; dim as usize];
2992 v[(i * rows + j) % dim as usize] = 1.0;
2993 v
2994 })
2995 .collect();
2996
2997 let mut writer = match TableWriter::create_or_open(
2998 Arc::clone(&catalog),
2999 Arc::clone(&store),
3000 policy.clone(),
3001 table.clone(),
3002 2,
3003 )
3004 .await
3005 {
3006 Ok(w) => w,
3007 Err(_) => return "store_err",
3008 };
3009 if writer.write_batch(&batch, &embeddings).await.is_err() {
3010 return "store_err";
3011 }
3012 if writer.commit().await.is_err() {
3013 return "store_err";
3014 }
3015 }
3016
3017 let files = match catalog.list_files(&table, None).await {
3018 Ok(f) => f,
3019 Err(_) => return "store_err",
3020 };
3021 if files.len() < 2 {
3022 return "too_few";
3023 }
3024
3025 let config = crate::compaction::CompactionConfig {
3026 min_files_to_compact: 2,
3027 target_file_size_bytes: 10 * 1024 * 1024,
3028 ..Default::default()
3029 };
3030 let planner = crate::compaction::CompactionPlanner::new(config);
3031 let executor =
3032 crate::compaction::CompactionExecutor::new(Arc::clone(&store), policy.clone());
3033
3034 match executor
3035 .run(&planner, &table, Arc::clone(&catalog), "data/merged")
3036 .await
3037 {
3038 Ok(Some(_)) => "ok",
3039 Ok(None) => "noop",
3040 Err(e) if is_store_err(&e) => "store_err",
3041 Err(_) => "unexpected_err",
3042 }
3043 }
3044
3045 proptest! {
3046 #[test]
3047 fn prop_write_search_under_failstore(
3048 profile in arb_fail_profile(),
3049 rows in 1usize..8,
3050 dim in 2u32..8,
3051 ) {
3052 let rt = tokio::runtime::Runtime::new().unwrap();
3053 let outcome = rt.block_on(write_search_rt(&profile, rows, dim));
3054 assert_ne!(outcome, FuzzOutcome::OtherError,
3055 "unexpected non-store error during write+search under FailStore");
3056 }
3057
3058 #[test]
3059 fn prop_compact_under_failstore(
3060 profile in arb_fail_profile(),
3061 rows in 2usize..6,
3062 dim in 2u32..8,
3063 ) {
3064 let rt = tokio::runtime::Runtime::new().unwrap();
3065 let outcome = rt.block_on(compact_rt(&profile, rows, dim));
3066 assert_ne!(outcome, "unexpected_err",
3067 "compaction failed with non-store error under FailStore");
3068 }
3069 }
3070 }
3071}