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(
721 &batch,
722 row_id.as_u64() as usize,
723 file_entry.sequence_number,
724 ) {
725 continue;
726 }
727 if matching_row_ids
730 .as_ref()
731 .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
732 {
733 continue;
734 }
735 if use_hybrid {
736 let text = extract_text_for_row(
737 &batch,
738 row_id.as_u64() as usize,
739 config.hybrid.as_ref().unwrap(),
740 );
741 outcome
742 .candidates
743 .push((row_id, distance, file_entry.path.clone(), text));
744 } else {
745 let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
746 outcome.results.push(SearchResult {
747 row_id,
748 distance: final_score,
749 file_path: file_entry.path.clone(),
750 });
751 }
752 }
753 return Ok(outcome);
754 }
755
756 let index = reader.load_any_index_for_column(vector_column)?;
757 let local_results = index.search(query, candidate_k, config.ef_search);
758
759 let parquet_data = if need_parquet {
760 let (raw_batch, raw_vecs) = reader.read_parquet()?;
761 let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
763 Some((filled, raw_vecs))
764 } else {
765 None
766 };
767
768 for (row_id, approx_dist) in local_results {
769 if dv_bitmap
771 .as_ref()
772 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
773 {
774 continue;
775 }
776 let idx = row_id.as_u64() as usize;
777 if let Some((ref batch, _)) = parquet_data {
780 if eq_del_filter.should_delete_row(batch, idx, file_entry.sequence_number) {
781 continue;
782 }
783 }
784 if matching_row_ids
787 .as_ref()
788 .is_some_and(|ids| !ids.contains(&row_id.as_u64()))
789 {
790 continue;
791 }
792
793 let distance = if config.rerank_factor.is_some() {
794 match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
795 Some(v) => exact_distance(metric, query, v),
796 None => {
797 error!(
798 "ailake: invariant violated — row_id {} out of bounds \
799 (file={}); Parquet and HNSW node count out of sync; \
800 run compaction to rebuild",
801 idx, file_entry.path
802 );
803 f32::INFINITY
804 }
805 }
806 } else {
807 approx_dist
808 };
809
810 if use_hybrid {
811 let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
812 extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
813 });
814 outcome
815 .candidates
816 .push((row_id, distance, file_entry.path.clone(), text));
817 } else {
818 let final_score = if let Some((ref batch, _)) = parquet_data {
819 apply_score_fn(&config.score_fn, distance, row_id, batch)
820 } else {
821 distance
822 };
823 outcome.results.push(SearchResult {
824 row_id,
825 distance: final_score,
826 file_path: file_entry.path.clone(),
827 });
828 }
829 }
830
831 Ok(outcome)
832}
833
834fn extract_text_for_row(
836 batch: &RecordBatch,
837 row_idx: usize,
838 hybrid: &crate::bm25::HybridConfig,
839) -> String {
840 use arrow_array::cast::AsArray;
841 hybrid
842 .text_columns
843 .iter()
844 .filter_map(|col| {
845 batch.column_by_name(col).and_then(|arr| {
846 arr.as_string_opt::<i32>().and_then(|sa| {
847 if row_idx < sa.len() && sa.is_valid(row_idx) {
848 Some(sa.value(row_idx).to_string())
849 } else {
850 None
851 }
852 })
853 })
854 })
855 .collect::<Vec<_>>()
856 .join(" ")
857}
858
859#[derive(Debug, Clone)]
861pub struct ModalQuery<'a> {
862 pub column: &'a str,
864 pub query: &'a [f32],
866 pub weight: f32,
869 pub dim: u32,
872}
873
874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
876pub enum FusionMethod {
877 Rrf,
881}
882
883pub async fn search_multimodal(
893 table: &TableIdent,
894 queries: &[ModalQuery<'_>],
895 config: SearchConfig,
896 catalog: Arc<dyn CatalogProvider>,
897 store: Arc<dyn Store>,
898 fusion: FusionMethod,
899) -> AilakeResult<Vec<SearchResult>> {
900 use std::collections::HashMap;
901
902 if queries.is_empty() {
903 return Err(AilakeError::InvalidArgument(
904 "search_multimodal requires at least one ModalQuery".into(),
905 ));
906 }
907 if config.top_k > ailake_core::MAX_TOP_K {
908 return Err(AilakeError::InvalidArgument(format!(
909 "top_k {} exceeds maximum supported value ({})",
910 config.top_k,
911 ailake_core::MAX_TOP_K
912 )));
913 }
914
915 let table_meta = catalog.load_table(table).await?;
917 let primary_col = table_meta
918 .properties
919 .get("ailake.vector-column")
920 .cloned()
921 .unwrap_or_default();
922 let primary_dim: u32 = table_meta
923 .properties
924 .get("ailake.vector-dim")
925 .and_then(|s| s.parse().ok())
926 .unwrap_or(0);
927
928 let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
930
931 let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
932 for mq in queries {
933 let resolved_dim = if mq.dim > 0 {
935 mq.dim
936 } else if mq.column == primary_col {
937 primary_dim
938 } else {
939 table_meta
940 .properties
941 .get(&format!("ailake.dim-{}", mq.column))
942 .and_then(|s| s.parse().ok())
943 .unwrap_or(mq.query.len() as u32)
944 };
945
946 let col_config = SearchConfig {
947 top_k: per_col_k,
948 ef_search: config.ef_search,
949 pruning_threshold: config.pruning_threshold,
950 rerank_factor: config.rerank_factor,
951 score_fn: None,
952 partition_filter: config.partition_filter.clone(),
953 hybrid: None,
954 column_filter: config.column_filter.clone(),
955 };
956 let results = search(
957 table,
958 mq.query,
959 col_config,
960 mq.column,
961 resolved_dim,
962 catalog.clone(),
963 store.clone(),
964 )
965 .await?;
966 per_col_results.push((mq.weight, results));
967 }
968
969 const K: f32 = 60.0;
971 let mut scores: HashMap<(String, u64), f32> = HashMap::new();
972
973 for (weight, results) in &per_col_results {
974 for (rank, r) in results.iter().enumerate() {
975 let key = (r.file_path.clone(), r.row_id.as_u64());
976 let rrf = weight / (K + rank as f32 + 1.0);
977 *scores.entry(key).or_insert(0.0) += rrf;
978 }
979 }
980
981 let all_files = catalog.list_files(table, None).await?;
984 let _ = all_files; let mut seen: HashMap<(String, u64), f32> = HashMap::new();
988 for (_, results) in &per_col_results {
989 for r in results {
990 let key = (r.file_path.clone(), r.row_id.as_u64());
991 let rrf_score = *scores.get(&key).unwrap_or(&0.0);
992 seen.entry(key).or_insert(rrf_score);
993 }
994 }
995
996 let mut fused: Vec<SearchResult> = seen
997 .into_iter()
998 .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
999 row_id: RowId::new(row_id_u64),
1000 distance: -rrf_score,
1001 file_path,
1002 })
1003 .collect();
1004
1005 fused.sort_by(|a, b| {
1006 a.distance
1007 .partial_cmp(&b.distance)
1008 .unwrap_or(std::cmp::Ordering::Equal)
1009 });
1010 fused.truncate(config.top_k);
1011
1012 let _ = fusion; Ok(fused)
1015}
1016
1017#[inline]
1022fn apply_score_fn(
1023 score_fn: &Option<ScoreFn>,
1024 distance: f32,
1025 row_id: RowId,
1026 batch: &RecordBatch,
1027) -> f32 {
1028 match score_fn {
1029 None => distance,
1030 Some(f) => {
1031 let idx = row_id.as_u64() as usize;
1032 if idx < batch.num_rows() {
1033 f.call(distance, &batch.slice(idx, 1))
1034 } else {
1035 distance
1036 }
1037 }
1038 }
1039}
1040
1041fn flat_search(
1043 raw: &[Vec<f32>],
1044 query: &[f32],
1045 top_k: usize,
1046 metric: VectorMetric,
1047) -> Vec<(RowId, f32)> {
1048 let mut results: Vec<(RowId, f32)> = raw
1049 .iter()
1050 .enumerate()
1051 .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
1052 .collect();
1053 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
1054 results.truncate(top_k);
1055 results
1056}
1057
1058fn parse_metric(s: &str) -> VectorMetric {
1059 match s {
1060 "euclidean" => VectorMetric::Euclidean,
1061 "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
1062 "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
1063 _ => VectorMetric::Cosine,
1064 }
1065}
1066
1067pub struct SearchSession {
1078 shards: Vec<LoadedShard>,
1079 metric: VectorMetric,
1080}
1081
1082struct LoadedShard {
1083 entry: DataFileEntry,
1084 index: Option<AnyIndex>,
1086 raw_vectors: Option<Vec<Vec<f32>>>,
1089}
1090
1091impl SearchSession {
1092 pub async fn load(
1098 table: &TableIdent,
1099 vector_column: &str,
1100 dim: u32,
1101 catalog: Arc<dyn CatalogProvider>,
1102 store: Arc<dyn Store>,
1103 load_raw: bool,
1104 ) -> AilakeResult<Self> {
1105 let all_files = catalog.list_files(table, None).await?;
1106 let table_meta = catalog.load_table(table).await?;
1107 let metric = parse_metric(
1108 table_meta
1109 .properties
1110 .get("ailake.vector-metric")
1111 .map(String::as_str)
1112 .unwrap_or("cosine"),
1113 );
1114
1115 let mut shards = Vec::with_capacity(all_files.len());
1116 for entry in all_files {
1117 let file_bytes: Bytes = store.get(&entry.path).await?;
1118 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
1119
1120 if entry.index_status == IndexStatus::Indexing {
1121 let (_, raw_vecs) = reader.read_parquet()?;
1123 shards.push(LoadedShard {
1124 entry,
1125 index: None,
1126 raw_vectors: Some(raw_vecs),
1127 });
1128 } else if reader.is_ailake_file() {
1129 let mut index = reader.load_any_index_for_column(vector_column)?;
1130 let raw_vectors = if load_raw {
1131 index.quantize_to_f16();
1132 let (_, vecs) = reader.read_parquet()?;
1133 Some(vecs)
1134 } else {
1135 None
1136 };
1137 shards.push(LoadedShard {
1138 entry,
1139 index: Some(index),
1140 raw_vectors,
1141 });
1142 }
1143 }
1144
1145 Ok(Self { shards, metric })
1146 }
1147
1148 pub fn shard_count(&self) -> usize {
1150 self.shards.len()
1151 }
1152
1153 pub fn search_batch(
1162 &self,
1163 queries: &[Vec<f32>],
1164 config: &SearchConfig,
1165 ) -> Vec<Vec<SearchResult>> {
1166 if queries.is_empty() {
1167 return vec![];
1168 }
1169
1170 let n_queries = queries.len();
1171 let candidate_k = match config.rerank_factor {
1172 Some(factor) => config.top_k * factor,
1173 None => config.top_k,
1174 };
1175 let use_nvidia = ailake_index::hardware::detect_cuda();
1176 let use_amd = ailake_index::hardware::detect_rocm();
1177
1178 let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
1180
1181 for shard in &self.shards {
1182 if let Some(raw) = &shard.raw_vectors {
1183 if !raw.is_empty() {
1185 let dim = raw[0].len();
1186 let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
1187 let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
1188 let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
1189
1190 let gpu_batch = if use_nvidia {
1191 ailake_index::gpu::try_nvidia_search_batch(
1192 &q_refs,
1193 &row_ids,
1194 &flat,
1195 dim,
1196 self.metric,
1197 candidate_k,
1198 )
1199 } else if use_amd {
1200 ailake_index::gpu::try_rocm_search_batch(
1201 &q_refs,
1202 &row_ids,
1203 &flat,
1204 dim,
1205 self.metric,
1206 candidate_k,
1207 )
1208 } else {
1209 None
1210 };
1211
1212 if let Some(batch) = gpu_batch {
1213 for (qi, results) in batch.into_iter().enumerate() {
1214 for (row_id, distance) in results {
1215 all_results[qi].push(SearchResult {
1216 row_id,
1217 distance,
1218 file_path: shard.entry.path.clone(),
1219 });
1220 }
1221 }
1222 continue;
1223 }
1224 }
1225
1226 for (qi, query) in queries.iter().enumerate() {
1228 for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
1229 all_results[qi].push(SearchResult {
1230 row_id,
1231 distance,
1232 file_path: shard.entry.path.clone(),
1233 });
1234 }
1235 }
1236 } else if let Some(index) = &shard.index {
1237 let shard_results: Vec<Vec<SearchResult>> = queries
1239 .par_iter()
1240 .map(|query| {
1241 index
1242 .search(query, candidate_k, config.ef_search)
1243 .into_iter()
1244 .map(|(row_id, distance)| SearchResult {
1245 row_id,
1246 distance,
1247 file_path: shard.entry.path.clone(),
1248 })
1249 .collect()
1250 })
1251 .collect();
1252
1253 for (qi, results) in shard_results.into_iter().enumerate() {
1254 all_results[qi].extend(results);
1255 }
1256 }
1257 }
1258
1259 for results in &mut all_results {
1261 results.sort_by(|a, b| {
1262 a.distance
1263 .partial_cmp(&b.distance)
1264 .unwrap_or(std::cmp::Ordering::Equal)
1265 });
1266 results.truncate(config.top_k);
1267 }
1268
1269 all_results
1270 }
1271
1272 pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
1274 let candidate_k = match config.rerank_factor {
1275 Some(factor) => config.top_k * factor,
1276 None => config.top_k,
1277 };
1278
1279 let mut all_results: Vec<SearchResult> = self
1280 .shards
1281 .par_iter()
1282 .flat_map(|shard| {
1283 if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
1285 let dist = match self.metric {
1286 VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
1287 ailake_vec::cosine_distance(query, ¢roid.values)
1288 }
1289 VectorMetric::Euclidean => {
1290 ailake_vec::euclidean_distance(query, ¢roid.values)
1291 }
1292 VectorMetric::DotProduct => {
1293 -ailake_vec::dot_product(query, ¢roid.values)
1294 }
1295 };
1296 if dist - centroid.radius > config.pruning_threshold {
1297 return vec![];
1298 }
1299 }
1300
1301 if let Some(index) = &shard.index {
1302 let local_results = index.search(query, candidate_k, config.ef_search);
1304 if config.rerank_factor.is_some() {
1305 if let Some(raw) = &shard.raw_vectors {
1306 local_results
1307 .into_iter()
1308 .map(|(row_id, _approx_dist)| {
1309 let idx = row_id.as_u64() as usize;
1310 let exact_dist = raw
1311 .get(idx)
1312 .map(|v| exact_distance(self.metric, query, v))
1313 .unwrap_or(f32::INFINITY);
1314 SearchResult {
1315 row_id,
1316 distance: exact_dist,
1317 file_path: shard.entry.path.clone(),
1318 }
1319 })
1320 .collect()
1321 } else {
1322 local_results
1323 .into_iter()
1324 .map(|(row_id, distance)| SearchResult {
1325 row_id,
1326 distance,
1327 file_path: shard.entry.path.clone(),
1328 })
1329 .collect()
1330 }
1331 } else {
1332 local_results
1333 .into_iter()
1334 .map(|(row_id, distance)| SearchResult {
1335 row_id,
1336 distance,
1337 file_path: shard.entry.path.clone(),
1338 })
1339 .collect()
1340 }
1341 } else if let Some(raw) = &shard.raw_vectors {
1342 flat_search(raw, query, candidate_k, self.metric)
1344 .into_iter()
1345 .map(|(row_id, distance)| SearchResult {
1346 row_id,
1347 distance,
1348 file_path: shard.entry.path.clone(),
1349 })
1350 .collect()
1351 } else {
1352 vec![]
1353 }
1354 })
1355 .collect();
1356
1357 all_results.sort_by(|a, b| {
1358 a.distance
1359 .partial_cmp(&b.distance)
1360 .unwrap_or(std::cmp::Ordering::Equal)
1361 });
1362 all_results.truncate(config.top_k);
1363 all_results
1364 }
1365}
1366
1367pub async fn search_text(
1380 table: &TableIdent,
1381 query_text: &str,
1382 text_columns: &[&str],
1383 top_k: usize,
1384 catalog: Arc<dyn CatalogProvider>,
1385 store: Arc<dyn Store>,
1386 partition_filter: Option<&str>,
1387) -> AilakeResult<Vec<SearchResult>> {
1388 use arrow_array::cast::AsArray;
1389
1390 if text_columns.is_empty() {
1391 return Err(AilakeError::InvalidArgument(
1392 "search_text requires at least one text column".into(),
1393 ));
1394 }
1395 if top_k > ailake_core::MAX_TOP_K {
1396 return Err(AilakeError::InvalidArgument(format!(
1397 "top_k {top_k} exceeds maximum supported value ({})",
1398 ailake_core::MAX_TOP_K
1399 )));
1400 }
1401
1402 let all_files = catalog.list_files(table, None).await?;
1403 let table_meta = catalog.load_table(table).await?;
1404
1405 let files: Vec<_> = if let Some(pv) = partition_filter {
1407 all_files
1408 .into_iter()
1409 .filter(|f| f.partition_value.as_deref() == Some(pv))
1410 .collect()
1411 } else {
1412 all_files
1413 };
1414
1415 let stats_path = table_meta
1417 .properties
1418 .get(crate::bm25::BM25_STATS_PATH_PROP)
1419 .map(String::as_str)
1420 .unwrap_or(crate::bm25::BM25_STATS_FILE);
1421 let stats = match store.get(stats_path).await {
1422 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1423 Err(_) => {
1424 debug!(
1425 "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1426 stats_path
1427 );
1428 crate::bm25::IdfStats::default()
1429 }
1430 };
1431 let scorer = crate::bm25::BM25Scorer::new(&stats);
1432
1433 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1435 Ok(edfs) if !edfs.is_empty() => {
1436 match EqualityDeleteFilter::from_files(&store, &edfs).await {
1437 Ok(f) => f,
1438 Err(e) => {
1439 warn!("ailake: equality delete filter build failed in search_text: {e}");
1440 EqualityDeleteFilter::empty()
1441 }
1442 }
1443 }
1444 _ => EqualityDeleteFilter::empty(),
1445 };
1446
1447 let mut results: Vec<SearchResult> = Vec::new();
1448
1449 for file_entry in &files {
1450 let file_bytes = store.get(&file_entry.path).await?;
1451 let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1453
1454 if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1457 match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1458 Ok(fts) => {
1459 let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1460 if !hits.is_empty() {
1461 let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1463 let (raw_batch, _) = reader2.read_parquet()?;
1464 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1465 for hit in hits {
1466 let row_idx = hit.row_id as usize;
1467 if row_idx >= batch.num_rows() {
1468 continue;
1469 }
1470 if eq_del_filter.should_delete_row(
1471 &batch,
1472 row_idx,
1473 file_entry.sequence_number,
1474 ) {
1475 continue;
1476 }
1477 results.push(SearchResult {
1478 row_id: RowId::new(hit.row_id),
1479 distance: -hit.score,
1480 file_path: file_entry.path.clone(),
1481 });
1482 }
1483 }
1484 continue; }
1486 Err(e) => {
1487 warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1488 }
1490 }
1491 }
1492
1493 let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1495 let (raw_batch, _) = reader_fb.read_parquet()?;
1496 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1498
1499 for row_idx in 0..batch.num_rows() {
1500 if eq_del_filter.should_delete_row(&batch, row_idx, file_entry.sequence_number) {
1502 continue;
1503 }
1504 let doc_text: String = text_columns
1505 .iter()
1506 .filter_map(|&col| {
1507 batch.column_by_name(col).and_then(|arr| {
1508 arr.as_string_opt::<i32>().and_then(|sa| {
1509 if sa.is_valid(row_idx) {
1510 Some(sa.value(row_idx).to_string())
1511 } else {
1512 None
1513 }
1514 })
1515 })
1516 })
1517 .collect::<Vec<_>>()
1518 .join(" ");
1519
1520 if doc_text.is_empty() {
1521 continue;
1522 }
1523
1524 let bm25 = scorer.score(query_text, &doc_text);
1525 if bm25 > 0.0 {
1526 results.push(SearchResult {
1528 row_id: RowId::new(row_idx as u64),
1529 distance: -bm25,
1530 file_path: file_entry.path.clone(),
1531 });
1532 }
1533 }
1534 }
1535
1536 results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1537 results.truncate(top_k);
1538 Ok(results)
1539}
1540
1541pub async fn fetch_rows(
1550 results: &[SearchResult],
1551 store: Arc<dyn Store>,
1552 vector_column: &str,
1553 dim: u32,
1554 schema_fields: &[SchemaField],
1555) -> AilakeResult<RecordBatch> {
1556 use std::collections::HashMap;
1557
1558 use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1559 use arrow_schema::{DataType, Field, Schema};
1560 use arrow_select::{concat::concat_batches, take::take};
1561
1562 if results.is_empty() {
1563 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1564 }
1565
1566 let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1568 for (i, r) in results.iter().enumerate() {
1569 by_file
1570 .entry(r.file_path.as_str())
1571 .or_default()
1572 .push((r.row_id.as_u64(), r.distance, i));
1573 }
1574
1575 use arrow_array::FixedSizeListArray;
1576
1577 let schema_fields_for_fill: Vec<SchemaField> = schema_fields
1588 .iter()
1589 .filter(|sf| sf.name != vector_column)
1590 .cloned()
1591 .collect();
1592
1593 let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1595
1596 for (file_path, rows) in &by_file {
1597 let bytes = store.get(file_path).await?;
1598 let reader = AilakeFileReader::new(bytes, vector_column, dim);
1599 let (raw_batch, vectors) = reader.read_parquet()?;
1600 let batch = SchemaFiller::fill(raw_batch, &schema_fields_for_fill)?;
1610
1611 for &(row_id, distance, pos) in rows {
1612 let idx = row_id as usize;
1613 if idx >= batch.num_rows() {
1614 tracing::warn!(
1615 "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1616 idx,
1617 batch.num_rows(),
1618 file_path
1619 );
1620 continue;
1621 }
1622
1623 let indices = UInt32Array::from(vec![idx as u32]);
1624 let row_cols: Vec<ArrayRef> = batch
1625 .columns()
1626 .iter()
1627 .map(|col| {
1628 take(col.as_ref(), &indices, None)
1629 .map_err(|e| AilakeError::Arrow(e.to_string()))
1630 })
1631 .collect::<AilakeResult<Vec<_>>>()?;
1632
1633 let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1634 .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1635
1636 let vec = vectors
1638 .get(idx)
1639 .cloned()
1640 .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1641
1642 collected.push((pos, distance, row_batch, vec));
1643 }
1644 }
1645
1646 if collected.is_empty() {
1647 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1648 }
1649
1650 collected.sort_by_key(|(pos, _, _, _)| *pos);
1652
1653 let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1654 let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1655 let base_schema = collected[0].2.schema();
1656
1657 let combined =
1658 concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1659
1660 let flat_vecs: Vec<f32> = collected
1662 .iter()
1663 .flat_map(|(_, _, _, v)| v.iter().copied())
1664 .collect();
1665 let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1666 let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1667 let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1668 let vec_field = Arc::new(Field::new(
1669 vector_column,
1670 DataType::FixedSizeList(item_field, dim as i32),
1671 false,
1672 ));
1673
1674 let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1676 fields.push(vec_field);
1677 fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1678 let new_schema = Arc::new(Schema::new(fields));
1679
1680 let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1681 columns.push(Arc::new(vec_col));
1682 columns.push(Arc::new(Float32Array::from(distances)));
1683
1684 RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1685}
1686
1687async fn load_bloom_map(
1693 table_meta: &ailake_catalog::TableMetadata,
1694 store: &dyn Store,
1695) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1696 let stats_path = match &table_meta.current_statistics_path {
1697 Some(p) => p.clone(),
1698 None => return std::collections::HashMap::new(),
1699 };
1700 let bytes = match store.get(&stats_path).await {
1701 Ok(b) => b,
1702 Err(e) => {
1703 debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1704 return std::collections::HashMap::new();
1705 }
1706 };
1707 let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1708 let bloom_entries = match reader.read_bm25_blooms() {
1709 Ok(e) => e,
1710 Err(e) => {
1711 warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1712 return std::collections::HashMap::new();
1713 }
1714 };
1715 bloom_entries
1716 .into_iter()
1717 .filter_map(|entry| {
1718 let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1719 Some((entry.path, bf))
1720 })
1721 .collect()
1722}
1723
1724#[cfg(test)]
1725mod tests {
1726 use super::*;
1727 use crate::writer::MultiVectorBatch;
1728 use ailake_catalog::{HadoopCatalog, TableIdent};
1729 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1730 use ailake_store::LocalStore;
1731 use arrow_array::{Int32Array, RecordBatch};
1732 use arrow_schema::{DataType, Field, Schema};
1733 use std::sync::Arc;
1734 use tempfile::TempDir;
1735
1736 fn make_policy(dim: u32) -> VectorStoragePolicy {
1737 VectorStoragePolicy {
1738 column_name: "embedding".to_string(),
1739 dim,
1740 metric: VectorMetric::Cosine,
1741 precision: VectorPrecision::F16,
1742 pq: None,
1743 keep_raw_for_reranking: true,
1744 pre_normalize: false,
1745 hnsw_m: None,
1746 hnsw_ef_construction: None,
1747 ivf_residual: false,
1748 embedding_model: None,
1749 modality: None,
1750 partition_by: None,
1751 partition_value: None,
1752 partition_column_type: None,
1753 partition_fields: vec![],
1754 }
1755 }
1756
1757 async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1758 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1759 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1760 let table = TableIdent::new("default", "table");
1761
1762 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1763 let ids: Vec<i32> = (0..rows as i32).collect();
1764 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1765
1766 let embeddings: Vec<Vec<f32>> = (0..rows)
1768 .map(|i| {
1769 let mut v = vec![0.0f32; dim];
1770 v[i % dim] = 1.0;
1771 v
1772 })
1773 .collect();
1774
1775 let mut writer =
1776 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1777 .await
1778 .unwrap();
1779 writer.write_batch(&batch, &embeddings).await.unwrap();
1780 writer.commit().await.unwrap();
1781 }
1782
1783 async fn write_demo_table_with_category(dir: &TempDir, dim: usize, rows: usize) {
1788 use arrow_array::StringArray;
1789 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1790 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1791 let table = TableIdent::new("default", "table");
1792
1793 let schema = Arc::new(Schema::new(vec![
1794 Field::new("id", DataType::Int32, false),
1795 Field::new("category", DataType::Utf8, false),
1796 ]));
1797 let ids: Vec<i32> = (0..rows as i32).collect();
1798 let categories: Vec<&str> = (0..rows)
1799 .map(|i| if i % 2 == 0 { "even" } else { "odd" })
1800 .collect();
1801 let batch = RecordBatch::try_new(
1802 schema,
1803 vec![
1804 Arc::new(Int32Array::from(ids)),
1805 Arc::new(StringArray::from(categories)),
1806 ],
1807 )
1808 .unwrap();
1809
1810 let embeddings: Vec<Vec<f32>> = (0..rows)
1811 .map(|i| {
1812 let mut v = vec![0.0f32; dim];
1813 v[i % dim] = 1.0;
1814 v
1815 })
1816 .collect();
1817
1818 let mut writer =
1819 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1820 .await
1821 .unwrap();
1822 writer.write_batch(&batch, &embeddings).await.unwrap();
1823 writer.commit().await.unwrap();
1824 }
1825
1826 #[tokio::test]
1827 async fn column_filter_preserves_row_identity_on_indexed_path() {
1828 let dir = TempDir::new().unwrap();
1836 let dim = 8usize;
1837 write_demo_table_with_category(&dir, dim, 8).await;
1838
1839 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1840 let catalog: Arc<dyn CatalogProvider> =
1841 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1842 let table = TableIdent::new("default", "table");
1843
1844 let mut query = vec![0.0f32; dim];
1846 query[4] = 1.0;
1847
1848 let config = SearchConfig {
1849 top_k: 1,
1850 ef_search: 50,
1851 pruning_threshold: f32::INFINITY,
1852 rerank_factor: None,
1853 score_fn: None,
1854 partition_filter: None,
1855 hybrid: None,
1856 column_filter: Some(ailake_core::ColumnFilter::eq(
1857 "category",
1858 ailake_core::FilterValue::Str("even".to_string()),
1859 )),
1860 };
1861
1862 let results = search(
1863 &table,
1864 &query,
1865 config,
1866 "embedding",
1867 dim as u32,
1868 catalog,
1869 store,
1870 )
1871 .await
1872 .unwrap();
1873
1874 assert_eq!(results.len(), 1);
1875 assert_eq!(
1876 results[0].row_id.as_u64(),
1877 4,
1878 "row_id must be the true file-relative position, not a filtered-batch index"
1879 );
1880 }
1881
1882 #[tokio::test]
1883 async fn column_filter_excludes_non_matching_rows() {
1884 let dir = TempDir::new().unwrap();
1885 let dim = 8usize;
1886 write_demo_table_with_category(&dir, dim, 8).await;
1887
1888 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1889 let catalog: Arc<dyn CatalogProvider> =
1890 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1891 let table = TableIdent::new("default", "table");
1892
1893 let mut query = vec![0.0f32; dim];
1895 query[3] = 1.0;
1896
1897 let config = SearchConfig {
1898 top_k: 8,
1899 ef_search: 50,
1900 pruning_threshold: f32::INFINITY,
1901 rerank_factor: None,
1902 score_fn: None,
1903 partition_filter: None,
1904 hybrid: None,
1905 column_filter: Some(ailake_core::ColumnFilter::eq(
1906 "category",
1907 ailake_core::FilterValue::Str("even".to_string()),
1908 )),
1909 };
1910
1911 let results = search(
1912 &table,
1913 &query,
1914 config,
1915 "embedding",
1916 dim as u32,
1917 catalog,
1918 store,
1919 )
1920 .await
1921 .unwrap();
1922
1923 assert_eq!(results.len(), 4, "only the 4 even rows should survive");
1924 for r in &results {
1925 assert_eq!(
1926 r.row_id.as_u64() % 2,
1927 0,
1928 "row {} is not even",
1929 r.row_id.as_u64()
1930 );
1931 }
1932 }
1933
1934 #[tokio::test]
1935 async fn column_filter_no_match_returns_empty() {
1936 let dir = TempDir::new().unwrap();
1937 let dim = 8usize;
1938 write_demo_table_with_category(&dir, dim, 8).await;
1939
1940 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1941 let catalog: Arc<dyn CatalogProvider> =
1942 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1943 let table = TableIdent::new("default", "table");
1944
1945 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1946 let config = SearchConfig {
1947 top_k: 8,
1948 ef_search: 50,
1949 pruning_threshold: f32::INFINITY,
1950 rerank_factor: None,
1951 score_fn: None,
1952 partition_filter: None,
1953 hybrid: None,
1954 column_filter: Some(ailake_core::ColumnFilter::eq(
1955 "category",
1956 ailake_core::FilterValue::Str("nonexistent".to_string()),
1957 )),
1958 };
1959
1960 let results = search(
1961 &table,
1962 &query,
1963 config,
1964 "embedding",
1965 dim as u32,
1966 catalog,
1967 store,
1968 )
1969 .await
1970 .unwrap();
1971 assert!(results.is_empty());
1972 }
1973
1974 struct CountingStore {
1981 inner: Arc<dyn Store>,
1982 ranges_by_path: std::sync::Mutex<std::collections::HashMap<String, Vec<(u64, u64)>>>,
1983 }
1984
1985 impl CountingStore {
1986 fn new(inner: Arc<dyn Store>) -> Self {
1987 Self {
1988 inner,
1989 ranges_by_path: std::sync::Mutex::new(std::collections::HashMap::new()),
1990 }
1991 }
1992
1993 fn record(&self, path: &str, start: u64, end: u64) {
1994 self.ranges_by_path
1995 .lock()
1996 .unwrap()
1997 .entry(path.to_string())
1998 .or_default()
1999 .push((start, end));
2000 }
2001
2002 fn min_offset_for(&self, path: &str) -> Option<u64> {
2006 self.ranges_by_path
2007 .lock()
2008 .unwrap()
2009 .get(path)
2010 .and_then(|ranges| ranges.iter().map(|(s, _)| *s).min())
2011 }
2012 }
2013
2014 #[async_trait::async_trait]
2015 impl Store for CountingStore {
2016 async fn get(&self, path: &str) -> AilakeResult<Bytes> {
2017 let b = self.inner.get(path).await?;
2018 self.record(path, 0, b.len() as u64);
2019 Ok(b)
2020 }
2021 async fn get_range(&self, path: &str, range: std::ops::Range<u64>) -> AilakeResult<Bytes> {
2022 let b = self.inner.get_range(path, range.clone()).await?;
2023 self.record(path, range.start, range.end);
2024 Ok(b)
2025 }
2026 async fn put(&self, path: &str, data: Bytes) -> AilakeResult<()> {
2027 self.inner.put(path, data).await
2028 }
2029 async fn list(&self, prefix: &str) -> AilakeResult<Vec<String>> {
2030 self.inner.list(prefix).await
2031 }
2032 async fn file_size(&self, path: &str) -> AilakeResult<u64> {
2033 self.inner.file_size(path).await
2034 }
2035 async fn exists(&self, path: &str) -> AilakeResult<bool> {
2036 self.inner.exists(path).await
2037 }
2038 async fn delete(&self, path: &str) -> AilakeResult<()> {
2039 self.inner.delete(path).await
2040 }
2041 }
2042
2043 #[tokio::test]
2044 async fn range_get_fast_path_never_touches_tabular_data_section() {
2045 let dir = TempDir::new().unwrap();
2046 let dim = 64usize;
2047 write_demo_table(&dir, dim, 400).await;
2048
2049 let local: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2050 let data_files = local.list("data").await.unwrap();
2051 assert_eq!(
2052 data_files.len(),
2053 1,
2054 "expect a single part file for this test"
2055 );
2056 let data_path = data_files[0].clone();
2057
2058 let full_bytes = local.get(&data_path).await.unwrap();
2061 let ailk_offset = ailake_file::AilakeFileReader::new(full_bytes, "embedding", dim as u32)
2062 .ailk_offset()
2063 .unwrap();
2064
2065 let counting = Arc::new(CountingStore::new(local.clone()));
2066 let store: Arc<dyn Store> = counting.clone();
2067 let catalog: Arc<dyn CatalogProvider> =
2068 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2069 let table = TableIdent::new("default", "table");
2070
2071 let mut query = vec![0.0f32; dim];
2072 query[3] = 1.0;
2073 let config = SearchConfig {
2074 top_k: 5,
2075 ef_search: 50,
2076 pruning_threshold: f32::INFINITY,
2077 rerank_factor: None,
2078 score_fn: None,
2079 partition_filter: None,
2080 hybrid: None,
2081 column_filter: None,
2082 };
2083
2084 let results = search(
2085 &table,
2086 &query,
2087 config,
2088 "embedding",
2089 dim as u32,
2090 catalog,
2091 store,
2092 )
2093 .await
2094 .unwrap();
2095 assert!(!results.is_empty());
2096
2097 let min_offset = counting
2104 .min_offset_for(&data_path)
2105 .expect("should have fetched something from the data file");
2106 assert!(
2107 min_offset >= ailk_offset,
2108 "fast path fetched bytes from the tabular/vector data section: \
2109 min_offset={min_offset} < ailk_offset={ailk_offset}"
2110 );
2111 }
2112
2113 #[tokio::test]
2114 async fn range_get_fast_path_matches_full_file_path_results() {
2115 let dir = TempDir::new().unwrap();
2121 let dim = 16usize;
2122 write_demo_table(&dir, dim, 20).await;
2123
2124 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2125 let catalog: Arc<dyn CatalogProvider> =
2126 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2127 let table = TableIdent::new("default", "table");
2128
2129 let mut query = vec![0.0f32; dim];
2130 query[3] = 1.0;
2131
2132 let fast_config = SearchConfig {
2133 top_k: 5,
2134 ef_search: 50,
2135 pruning_threshold: f32::INFINITY,
2136 rerank_factor: None,
2137 score_fn: None,
2138 partition_filter: None,
2139 hybrid: None,
2140 column_filter: None,
2141 };
2142 let fast_results = search(
2143 &table,
2144 &query,
2145 fast_config,
2146 "embedding",
2147 dim as u32,
2148 catalog.clone(),
2149 store.clone(),
2150 )
2151 .await
2152 .unwrap();
2153
2154 let slow_config = SearchConfig {
2155 top_k: 5,
2156 ef_search: 50,
2157 pruning_threshold: f32::INFINITY,
2158 rerank_factor: None,
2159 score_fn: None,
2160 partition_filter: None,
2161 hybrid: None,
2162 column_filter: Some(ailake_core::ColumnFilter::new(
2163 "id",
2164 ailake_core::FilterOp::Gte,
2165 ailake_core::FilterValue::I64(0),
2166 )),
2167 };
2168 let slow_results = search(
2169 &table,
2170 &query,
2171 slow_config,
2172 "embedding",
2173 dim as u32,
2174 catalog,
2175 store,
2176 )
2177 .await
2178 .unwrap();
2179
2180 assert_eq!(fast_results.len(), slow_results.len());
2181 assert!(!fast_results.is_empty());
2182 for (f, s) in fast_results.iter().zip(slow_results.iter()) {
2183 assert_eq!(f.row_id, s.row_id, "fast/slow path row_id mismatch");
2184 assert!(
2185 (f.distance - s.distance).abs() < 1e-4,
2186 "fast/slow path distance mismatch: {} vs {}",
2187 f.distance,
2188 s.distance
2189 );
2190 }
2191 }
2192
2193 #[tokio::test]
2194 async fn rerank_returns_correct_top_k_count() {
2195 let dir = TempDir::new().unwrap();
2196 let dim = 8usize;
2197 write_demo_table(&dir, dim, 8).await;
2198
2199 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2200 let catalog: Arc<dyn CatalogProvider> =
2201 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2202 let table = TableIdent::new("default", "table");
2203
2204 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2205 let config = SearchConfig {
2206 top_k: 3,
2207 ef_search: 50,
2208 pruning_threshold: f32::INFINITY,
2209 rerank_factor: Some(2),
2210 score_fn: None,
2211 partition_filter: None,
2212 hybrid: None,
2213 column_filter: None,
2214 };
2215
2216 let results = search(
2217 &table,
2218 &query,
2219 config,
2220 "embedding",
2221 dim as u32,
2222 catalog,
2223 store,
2224 )
2225 .await
2226 .unwrap();
2227
2228 assert_eq!(results.len(), 3);
2229 }
2230
2231 #[tokio::test]
2232 async fn rerank_nearest_is_exact_match() {
2233 let dir = TempDir::new().unwrap();
2234 let dim = 8usize;
2235 write_demo_table(&dir, dim, 8).await;
2236
2237 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2238 let catalog: Arc<dyn CatalogProvider> =
2239 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2240 let table = TableIdent::new("default", "table");
2241
2242 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2244 let config = SearchConfig {
2245 top_k: 1,
2246 ef_search: 50,
2247 pruning_threshold: f32::INFINITY,
2248 rerank_factor: Some(4),
2249 score_fn: None,
2250 partition_filter: None,
2251 hybrid: None,
2252 column_filter: None,
2253 };
2254
2255 let results = search(
2256 &table,
2257 &query,
2258 config,
2259 "embedding",
2260 dim as u32,
2261 catalog,
2262 store,
2263 )
2264 .await
2265 .unwrap();
2266
2267 assert_eq!(results.len(), 1);
2268 assert!(
2270 results[0].distance < 1e-3,
2271 "distance was {}",
2272 results[0].distance
2273 );
2274 assert_eq!(results[0].row_id, RowId::new(0));
2275 }
2276
2277 #[tokio::test]
2278 async fn no_rerank_matches_default_behavior() {
2279 let dir = TempDir::new().unwrap();
2280 let dim = 4usize;
2281 write_demo_table(&dir, dim, 4).await;
2282
2283 let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2284 let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2285 let cat_a: Arc<dyn CatalogProvider> =
2286 Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
2287 let cat_b: Arc<dyn CatalogProvider> =
2288 Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
2289 let table = TableIdent::new("default", "table");
2290
2291 let query = vec![1.0f32, 0.0, 0.0, 0.0];
2292 let cfg_plain = SearchConfig {
2293 top_k: 2,
2294 ef_search: 50,
2295 pruning_threshold: f32::INFINITY,
2296 rerank_factor: None,
2297 score_fn: None,
2298 partition_filter: None,
2299 hybrid: None,
2300 column_filter: None,
2301 };
2302 let cfg_rerank = SearchConfig {
2303 top_k: 2,
2304 ef_search: 50,
2305 pruning_threshold: f32::INFINITY,
2306 rerank_factor: Some(2),
2307 score_fn: None,
2308 partition_filter: None,
2309 hybrid: None,
2310 column_filter: None,
2311 };
2312
2313 let plain = search(
2314 &table,
2315 &query,
2316 cfg_plain,
2317 "embedding",
2318 dim as u32,
2319 cat_a,
2320 store_a,
2321 )
2322 .await
2323 .unwrap();
2324 let reranked = search(
2325 &table,
2326 &query,
2327 cfg_rerank,
2328 "embedding",
2329 dim as u32,
2330 cat_b,
2331 store_b,
2332 )
2333 .await
2334 .unwrap();
2335
2336 assert_eq!(plain[0].row_id, reranked[0].row_id);
2338 }
2339
2340 #[tokio::test]
2341 async fn multimodal_rrf_returns_top_k() {
2342 let dir = TempDir::new().unwrap();
2343 let dim = 4usize;
2344 write_demo_table(&dir, dim, 4).await;
2345
2346 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2347 let catalog: Arc<dyn CatalogProvider> =
2348 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2349 let table = TableIdent::new("default", "table");
2350
2351 let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
2354 let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
2355
2356 let queries = vec![
2357 ModalQuery {
2358 column: "embedding",
2359 query: &q1,
2360 weight: 0.7,
2361 dim: dim as u32,
2362 },
2363 ModalQuery {
2364 column: "embedding",
2365 query: &q2,
2366 weight: 0.3,
2367 dim: dim as u32,
2368 },
2369 ];
2370
2371 let config = SearchConfig {
2372 top_k: 2,
2373 ef_search: 50,
2374 pruning_threshold: f32::INFINITY,
2375 rerank_factor: None,
2376 score_fn: None,
2377 partition_filter: None,
2378 hybrid: None,
2379 column_filter: None,
2380 };
2381
2382 let results =
2383 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2384 .await
2385 .unwrap();
2386
2387 assert_eq!(results.len(), 2);
2388 assert!(results[0].distance <= 0.0);
2390 assert!(results[0].row_id.as_u64() < 4);
2392 }
2393
2394 #[tokio::test]
2398 async fn multimodal_rrf_cross_modal_different_dims() {
2399 let dir = TempDir::new().unwrap();
2400 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2401 let catalog: Arc<dyn CatalogProvider> =
2402 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2403 let table = TableIdent::new("default", "table");
2404
2405 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2407 let rows = 4usize;
2408 let ids: Vec<i32> = (0..rows as i32).collect();
2409 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2410
2411 let text_embs: Vec<Vec<f32>> = (0..rows)
2412 .map(|i| {
2413 let mut v = vec![0.0f32; 4];
2414 v[i % 4] = 1.0;
2415 v
2416 })
2417 .collect();
2418 let img_embs: Vec<Vec<f32>> = (0..rows)
2419 .map(|i| {
2420 let mut v = vec![0.0f32; 2];
2421 v[i % 2] = 1.0;
2422 v
2423 })
2424 .collect();
2425
2426 let text_policy = make_policy(4);
2427 let img_policy = VectorStoragePolicy {
2428 column_name: "img_embedding".to_string(),
2429 dim: 2,
2430 metric: VectorMetric::Cosine,
2431 precision: VectorPrecision::F16,
2432 pq: None,
2433 keep_raw_for_reranking: true,
2434 pre_normalize: false,
2435 hnsw_m: None,
2436 hnsw_ef_construction: None,
2437 ivf_residual: false,
2438 embedding_model: None,
2439 modality: None,
2440 partition_by: None,
2441 partition_value: None,
2442 partition_column_type: None,
2443 partition_fields: vec![],
2444 };
2445
2446 let mut writer = crate::TableWriter::create_or_open(
2447 catalog.clone(),
2448 store.clone(),
2449 text_policy,
2450 table.clone(),
2451 2,
2452 )
2453 .await
2454 .unwrap();
2455
2456 let batches = [
2457 MultiVectorBatch {
2458 policy: make_policy(4),
2459 embeddings: &text_embs,
2460 },
2461 MultiVectorBatch {
2462 policy: img_policy,
2463 embeddings: &img_embs,
2464 },
2465 ];
2466 writer.write_batch_multi(&batch, &batches).await.unwrap();
2467 writer.commit().await.unwrap();
2468
2469 let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
2471 let q_img = vec![1.0f32, 0.0];
2472
2473 let queries = vec![
2474 ModalQuery {
2475 column: "embedding",
2476 query: &q_text,
2477 weight: 0.6,
2478 dim: 4,
2479 },
2480 ModalQuery {
2481 column: "img_embedding",
2482 query: &q_img,
2483 weight: 0.4,
2484 dim: 2,
2485 },
2486 ];
2487 let config = SearchConfig {
2488 top_k: 2,
2489 ef_search: 50,
2490 pruning_threshold: f32::INFINITY,
2491 rerank_factor: None,
2492 score_fn: None,
2493 partition_filter: None,
2494 hybrid: None,
2495 column_filter: None,
2496 };
2497
2498 let results =
2499 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
2500 .await
2501 .unwrap();
2502
2503 assert!(!results.is_empty(), "should return results");
2504 assert!(results[0].distance <= 0.0, "distance is -rrf_score");
2505 assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
2507 }
2508
2509 #[tokio::test]
2516 async fn fetch_rows_projects_evolved_column_as_null() {
2517 use ailake_catalog::schema_evolution::{AddColumnRequest, SchemaEvolution};
2518
2519 let dir = TempDir::new().unwrap();
2520 let dim = 8usize;
2521 write_demo_table(&dir, dim, 8).await;
2522
2523 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2524 let catalog: Arc<dyn CatalogProvider> =
2525 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2526 let table = TableIdent::new("default", "table");
2527
2528 catalog
2531 .evolve_schema(
2532 &table,
2533 SchemaEvolution::new().add_column(AddColumnRequest {
2534 name: "note".to_string(),
2535 iceberg_type: "string".to_string(),
2536 required: false,
2537 initial_default: None,
2538 write_default: None,
2539 doc: None,
2540 }),
2541 )
2542 .await
2543 .unwrap();
2544
2545 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2546 let config = SearchConfig {
2547 top_k: 3,
2548 ef_search: 50,
2549 pruning_threshold: f32::INFINITY,
2550 rerank_factor: None,
2551 score_fn: None,
2552 partition_filter: None,
2553 hybrid: None,
2554 column_filter: None,
2555 };
2556 let results = search(
2557 &table,
2558 &query,
2559 config,
2560 "embedding",
2561 dim as u32,
2562 Arc::clone(&catalog),
2563 Arc::clone(&store),
2564 )
2565 .await
2566 .unwrap();
2567 assert_eq!(results.len(), 3);
2568
2569 let table_meta = catalog.load_table(&table).await.unwrap();
2570 let batch = fetch_rows(
2571 &results,
2572 store,
2573 "embedding",
2574 dim as u32,
2575 &table_meta.schema_fields,
2576 )
2577 .await
2578 .unwrap();
2579
2580 let note_col = batch
2581 .column_by_name("note")
2582 .expect("evolved 'note' column must be present, not silently dropped");
2583 assert_eq!(note_col.len(), 3);
2584 assert_eq!(
2585 note_col.null_count(),
2586 3,
2587 "old files predate 'note' — every value must be null, not an error or a missing column"
2588 );
2589 }
2590
2591 #[tokio::test]
2602 async fn fetch_rows_does_not_duplicate_vector_column() {
2603 let dir = TempDir::new().unwrap();
2604 let dim = 8usize;
2605 write_demo_table(&dir, dim, 8).await;
2606
2607 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2608 let catalog: Arc<dyn CatalogProvider> =
2609 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2610 let table = TableIdent::new("default", "table");
2611
2612 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2613 let config = SearchConfig {
2614 top_k: 3,
2615 ef_search: 50,
2616 pruning_threshold: f32::INFINITY,
2617 rerank_factor: None,
2618 score_fn: None,
2619 partition_filter: None,
2620 hybrid: None,
2621 column_filter: None,
2622 };
2623 let results = search(
2624 &table,
2625 &query,
2626 config,
2627 "embedding",
2628 dim as u32,
2629 Arc::clone(&catalog),
2630 Arc::clone(&store),
2631 )
2632 .await
2633 .unwrap();
2634
2635 let table_meta = catalog.load_table(&table).await.unwrap();
2636 let batch = fetch_rows(
2637 &results,
2638 store,
2639 "embedding",
2640 dim as u32,
2641 &table_meta.schema_fields,
2642 )
2643 .await
2644 .unwrap();
2645
2646 let batch_schema = batch.schema();
2647 let embedding_fields: Vec<_> = batch_schema
2648 .fields()
2649 .iter()
2650 .filter(|f| f.name() == "embedding")
2651 .collect();
2652 assert_eq!(
2653 embedding_fields.len(),
2654 1,
2655 "exactly one 'embedding' field expected, got: {:?}",
2656 batch_schema
2657 );
2658 assert!(
2659 matches!(embedding_fields[0].data_type(), arrow_schema::DataType::FixedSizeList(_, d) if *d == dim as i32),
2660 "embedding field must be the decoded FixedSizeList<Float32>, got {:?}",
2661 embedding_fields[0].data_type()
2662 );
2663 }
2664
2665 #[tokio::test]
2668 async fn search_dimension_mismatch_rejected() {
2669 let dir = TempDir::new().unwrap();
2670 write_demo_table(&dir, 8, 5).await;
2671
2672 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2673 let catalog: Arc<dyn CatalogProvider> =
2674 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2675 let table = TableIdent::new("default", "table");
2676
2677 let query = vec![1.0f32, 0.0, 0.0, 0.0];
2679 let config = SearchConfig::default();
2680 let result = search(
2681 &table,
2682 &query,
2683 config,
2684 "embedding",
2685 8,
2686 Arc::clone(&catalog),
2687 Arc::clone(&store),
2688 )
2689 .await;
2690 assert!(
2691 result.is_err(),
2692 "search with wrong dim should error, got Ok"
2693 );
2694 }
2695
2696 #[tokio::test]
2697 async fn search_top_k_over_cap_rejected() {
2698 let dir = TempDir::new().unwrap();
2704 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2705 let catalog: Arc<dyn CatalogProvider> =
2706 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2707 let table = TableIdent::new("default", "table");
2708
2709 let config = SearchConfig {
2710 top_k: ailake_core::MAX_TOP_K + 1,
2711 ..SearchConfig::default()
2712 };
2713 let result = search(
2714 &table,
2715 &[1.0f32, 0.0, 0.0, 0.0],
2716 config,
2717 "embedding",
2718 4,
2719 catalog,
2720 store,
2721 )
2722 .await;
2723 let err = result.expect_err("top_k over cap should error");
2724 assert!(err.to_string().contains("top_k"), "{err}");
2725 }
2726
2727 #[tokio::test]
2728 async fn search_multimodal_top_k_over_cap_rejected() {
2729 let dir = TempDir::new().unwrap();
2730 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2731 let catalog: Arc<dyn CatalogProvider> =
2732 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2733 let table = TableIdent::new("default", "table");
2734
2735 let config = SearchConfig {
2736 top_k: ailake_core::MAX_TOP_K + 1,
2737 ..SearchConfig::default()
2738 };
2739 let queries = vec![ModalQuery {
2740 column: "embedding",
2741 query: &[1.0f32, 0.0, 0.0, 0.0],
2742 weight: 1.0,
2743 dim: 4,
2744 }];
2745 let result =
2746 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf).await;
2747 let err = result.expect_err("top_k over cap should error");
2748 assert!(err.to_string().contains("top_k"), "{err}");
2749 }
2750
2751 #[tokio::test]
2752 async fn search_text_top_k_over_cap_rejected() {
2753 let dir = TempDir::new().unwrap();
2754 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
2755 let catalog: Arc<dyn CatalogProvider> =
2756 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
2757 let table = TableIdent::new("default", "table");
2758
2759 let result = search_text(
2760 &table,
2761 "hello",
2762 &["chunk_text"],
2763 ailake_core::MAX_TOP_K + 1,
2764 catalog,
2765 store,
2766 None,
2767 )
2768 .await;
2769 let err = result.expect_err("top_k over cap should error");
2770 assert!(err.to_string().contains("top_k"), "{err}");
2771 }
2772
2773 #[cfg(test)]
2776 mod fuzz_tests {
2777 use super::*;
2778 use crate::writer::TableWriter;
2779 use ailake_catalog::{HadoopCatalog, TableIdent, TableProperties};
2780 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
2781 use ailake_store::{fail_store::FailStore, LocalStore};
2782 use arrow_array::{Int32Array, RecordBatch};
2783 use arrow_schema::{DataType, Field, Schema};
2784 use proptest::prelude::*;
2785 use std::sync::Arc;
2786 use tempfile::TempDir;
2787
2788 fn make_fuzz_policy(dim: u32) -> VectorStoragePolicy {
2789 VectorStoragePolicy {
2790 column_name: "embedding".to_string(),
2791 dim,
2792 metric: VectorMetric::Cosine,
2793 precision: VectorPrecision::F16,
2794 pq: None,
2795 keep_raw_for_reranking: true,
2796 pre_normalize: false,
2797 hnsw_m: None,
2798 hnsw_ef_construction: None,
2799 ivf_residual: false,
2800 embedding_model: None,
2801 modality: None,
2802 partition_by: None,
2803 partition_value: None,
2804 partition_column_type: None,
2805 partition_fields: vec![],
2806 }
2807 }
2808
2809 #[derive(Debug, Clone)]
2810 struct FailProfile {
2811 fail_get: bool,
2812 fail_get_range: bool,
2813 fail_put: bool,
2814 fail_list: bool,
2815 fail_file_size: bool,
2816 }
2817
2818 fn arb_fail_profile() -> impl Strategy<Value = FailProfile> {
2819 (
2820 proptest::bool::ANY,
2821 proptest::bool::ANY,
2822 proptest::bool::ANY,
2823 proptest::bool::ANY,
2824 proptest::bool::ANY,
2825 )
2826 .prop_map(|(g, gr, p, l, fs)| FailProfile {
2827 fail_get: g,
2828 fail_get_range: gr,
2829 fail_put: p,
2830 fail_list: l,
2831 fail_file_size: fs,
2832 })
2833 }
2834
2835 #[derive(Debug, Clone, PartialEq)]
2836 enum FuzzOutcome {
2837 Ok,
2838 StoreError,
2839 OtherError,
2840 }
2841
2842 fn is_store_err(e: &ailake_core::AilakeError) -> bool {
2843 matches!(e, ailake_core::AilakeError::Store(_))
2844 }
2845
2846 async fn write_search_rt(profile: &FailProfile, rows: usize, dim: u32) -> FuzzOutcome {
2847 let dir = TempDir::new().unwrap();
2848 let inner = LocalStore::new(dir.path());
2849 let fail = FailStore::new(inner)
2850 .with_fail_get(profile.fail_get)
2851 .with_fail_get_range(profile.fail_get_range)
2852 .with_fail_put(profile.fail_put)
2853 .with_fail_list(profile.fail_list)
2854 .with_fail_file_size(profile.fail_file_size);
2855 let store: Arc<dyn Store> = Arc::new(fail);
2856 let catalog: Arc<dyn CatalogProvider> =
2857 Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2858 let table = TableIdent::new("default", "fuzz_test");
2859 let policy = make_fuzz_policy(dim);
2860
2861 if catalog
2862 .create_table(
2863 &table,
2864 &TableProperties {
2865 policy: policy.clone(),
2866 format_version: 2,
2867 extra: Default::default(),
2868 partition_column_type: None,
2869 },
2870 )
2871 .await
2872 .is_err()
2873 {
2874 return FuzzOutcome::StoreError;
2875 }
2876
2877 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2878 let ids: Vec<i32> = (0..rows as i32).collect();
2879 let batch =
2880 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2881 let embeddings: Vec<Vec<f32>> = (0..rows)
2882 .map(|i| {
2883 let mut v = vec![0.0f32; dim as usize];
2884 v[i % dim as usize] = 1.0;
2885 v
2886 })
2887 .collect();
2888
2889 let mut writer = match TableWriter::create_or_open(
2890 Arc::clone(&catalog),
2891 Arc::clone(&store),
2892 policy.clone(),
2893 table.clone(),
2894 2,
2895 )
2896 .await
2897 {
2898 Ok(w) => w,
2899 Err(e) => {
2900 return if is_store_err(&e) {
2901 FuzzOutcome::StoreError
2902 } else {
2903 FuzzOutcome::OtherError
2904 }
2905 }
2906 };
2907
2908 if let Err(e) = writer.write_batch(&batch, &embeddings).await {
2909 return if is_store_err(&e) {
2910 FuzzOutcome::StoreError
2911 } else {
2912 FuzzOutcome::OtherError
2913 };
2914 }
2915
2916 if let Err(e) = writer.commit().await {
2917 return if is_store_err(&e) {
2918 FuzzOutcome::StoreError
2919 } else {
2920 FuzzOutcome::OtherError
2921 };
2922 }
2923
2924 let query: Vec<f32> = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
2925 let config = SearchConfig {
2926 top_k: 3.min(rows),
2927 ef_search: 50,
2928 pruning_threshold: f32::INFINITY,
2929 ..Default::default()
2930 };
2931
2932 match search(
2933 &table,
2934 &query[..dim as usize],
2935 config,
2936 "embedding",
2937 dim,
2938 Arc::clone(&catalog),
2939 Arc::clone(&store),
2940 )
2941 .await
2942 {
2943 Ok(results) => {
2944 assert!(results.len() <= rows.min(3), "more results than requested");
2945 for r in &results {
2946 assert!(r.distance.is_finite(), "non-finite distance");
2947 assert!(r.distance >= 0.0, "negative distance");
2948 }
2949 FuzzOutcome::Ok
2950 }
2951 Err(e) => {
2952 if is_store_err(&e) {
2953 FuzzOutcome::StoreError
2954 } else {
2955 panic!("unexpected non-store search error: {:?}", e);
2956 }
2957 }
2958 }
2959 }
2960
2961 async fn compact_rt(profile: &FailProfile, rows: usize, dim: u32) -> &'static str {
2962 let dir = TempDir::new().unwrap();
2963 let inner = LocalStore::new(dir.path());
2964 let fail = FailStore::new(inner)
2965 .with_fail_get(profile.fail_get)
2966 .with_fail_get_range(profile.fail_get_range)
2967 .with_fail_put(profile.fail_put)
2968 .with_fail_list(profile.fail_list)
2969 .with_fail_file_size(profile.fail_file_size);
2970 let store: Arc<dyn Store> = Arc::new(fail);
2971 let catalog: Arc<dyn CatalogProvider> =
2972 Arc::new(HadoopCatalog::new(Arc::clone(&store), "warehouse"));
2973 let table = TableIdent::new("default", "fuzz_compact");
2974 let policy = make_fuzz_policy(dim);
2975
2976 if catalog
2977 .create_table(
2978 &table,
2979 &TableProperties {
2980 policy: policy.clone(),
2981 format_version: 2,
2982 extra: Default::default(),
2983 partition_column_type: None,
2984 },
2985 )
2986 .await
2987 .is_err()
2988 {
2989 return "store_err";
2990 }
2991
2992 for i in 0..2 {
2993 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
2994 let ids: Vec<i32> = (0..rows as i32).collect();
2995 let batch =
2996 RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
2997 let embeddings: Vec<Vec<f32>> = (0..rows)
2998 .map(|j| {
2999 let mut v = vec![0.0f32; dim as usize];
3000 v[(i * rows + j) % dim as usize] = 1.0;
3001 v
3002 })
3003 .collect();
3004
3005 let mut writer = match TableWriter::create_or_open(
3006 Arc::clone(&catalog),
3007 Arc::clone(&store),
3008 policy.clone(),
3009 table.clone(),
3010 2,
3011 )
3012 .await
3013 {
3014 Ok(w) => w,
3015 Err(_) => return "store_err",
3016 };
3017 if writer.write_batch(&batch, &embeddings).await.is_err() {
3018 return "store_err";
3019 }
3020 if writer.commit().await.is_err() {
3021 return "store_err";
3022 }
3023 }
3024
3025 let files = match catalog.list_files(&table, None).await {
3026 Ok(f) => f,
3027 Err(_) => return "store_err",
3028 };
3029 if files.len() < 2 {
3030 return "too_few";
3031 }
3032
3033 let config = crate::compaction::CompactionConfig {
3034 min_files_to_compact: 2,
3035 target_file_size_bytes: 10 * 1024 * 1024,
3036 ..Default::default()
3037 };
3038 let planner = crate::compaction::CompactionPlanner::new(config);
3039 let executor =
3040 crate::compaction::CompactionExecutor::new(Arc::clone(&store), policy.clone());
3041
3042 match executor
3043 .run(&planner, &table, Arc::clone(&catalog), "data/merged")
3044 .await
3045 {
3046 Ok(Some(_)) => "ok",
3047 Ok(None) => "noop",
3048 Err(e) if is_store_err(&e) => "store_err",
3049 Err(_) => "unexpected_err",
3050 }
3051 }
3052
3053 proptest! {
3054 #[test]
3055 fn prop_write_search_under_failstore(
3056 profile in arb_fail_profile(),
3057 rows in 1usize..8,
3058 dim in 2u32..8,
3059 ) {
3060 let rt = tokio::runtime::Runtime::new().unwrap();
3061 let outcome = rt.block_on(write_search_rt(&profile, rows, dim));
3062 assert_ne!(outcome, FuzzOutcome::OtherError,
3063 "unexpected non-store error during write+search under FailStore");
3064 }
3065
3066 #[test]
3067 fn prop_compact_under_failstore(
3068 profile in arb_fail_profile(),
3069 rows in 2usize..6,
3070 dim in 2u32..8,
3071 ) {
3072 let rt = tokio::runtime::Runtime::new().unwrap();
3073 let outcome = rt.block_on(compact_rt(&profile, rows, dim));
3074 assert_ne!(outcome, "unexpected_err",
3075 "compaction failed with non-store error under FailStore");
3076 }
3077 }
3078 }
3079}