1use std::sync::Arc;
3
4use rayon::prelude::*;
5use tracing::{debug, error, warn};
6
7use ailake_catalog::{CatalogProvider, DataFileEntry, IndexStatus, TableIdent};
8use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, RowId, VectorMetric};
9use ailake_file::AilakeFileReader;
10use ailake_index::AnyIndex;
11use ailake_store::Store;
12use ailake_vec::exact_distance;
13use arrow_array::{Array, RecordBatch};
14use bytes::Bytes;
15
16use crate::equality_delete::EqualityDeleteFilter;
17use crate::pruner::{BloomPruner, VectorPruner};
18use crate::schema_filler::SchemaFiller;
19
20#[allow(clippy::type_complexity)]
50pub struct ScoreFn(pub std::sync::Arc<dyn Fn(f32, &RecordBatch) -> f32 + Send + Sync>);
51
52impl ScoreFn {
53 pub fn new(f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static) -> Self {
54 Self(std::sync::Arc::new(f))
55 }
56
57 #[inline]
58 pub fn call(&self, distance: f32, row: &RecordBatch) -> f32 {
59 (self.0)(distance, row)
60 }
61}
62
63impl Clone for ScoreFn {
64 fn clone(&self) -> Self {
65 Self(std::sync::Arc::clone(&self.0))
66 }
67}
68
69impl std::fmt::Debug for ScoreFn {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.write_str("ScoreFn(<fn>)")
72 }
73}
74
75#[derive(Debug, Clone)]
76pub struct SearchConfig {
77 pub top_k: usize,
78 pub ef_search: usize,
79 pub pruning_threshold: f32,
83 pub rerank_factor: Option<usize>,
88 pub hybrid: Option<crate::bm25::HybridConfig>,
99 pub score_fn: Option<ScoreFn>,
112 pub partition_filter: Option<String>,
116}
117
118impl Default for SearchConfig {
119 fn default() -> Self {
120 Self {
121 top_k: 10,
122 ef_search: 50,
123 pruning_threshold: f32::INFINITY,
124 rerank_factor: None,
125 score_fn: None,
126 partition_filter: None,
127 hybrid: None,
128 }
129 }
130}
131
132impl SearchConfig {
133 pub fn with_pruning(mut self, threshold: f32) -> Self {
134 self.pruning_threshold = threshold;
135 self
136 }
137
138 pub fn with_reranking(mut self, factor: usize) -> Self {
139 self.rerank_factor = Some(factor);
140 self
141 }
142
143 pub fn with_score_fn(
144 mut self,
145 f: impl Fn(f32, &RecordBatch) -> f32 + Send + Sync + 'static,
146 ) -> Self {
147 self.score_fn = Some(ScoreFn::new(f));
148 self
149 }
150
151 pub fn with_hybrid(mut self, cfg: crate::bm25::HybridConfig) -> Self {
152 self.hybrid = Some(cfg);
153 self
154 }
155}
156
157#[derive(Debug)]
158pub struct SearchResult {
159 pub row_id: RowId,
160 pub distance: f32,
161 pub file_path: String,
162}
163
164pub async fn search(
172 table: &TableIdent,
173 query: &[f32],
174 config: SearchConfig,
175 vector_column: &str,
176 dim: u32,
177 catalog: Arc<dyn CatalogProvider>,
178 store: Arc<dyn Store>,
179) -> AilakeResult<Vec<SearchResult>> {
180 let all_files = catalog.list_files(table, None).await?;
182
183 let table_meta = catalog.load_table(table).await?;
185
186 let primary_col = table_meta
191 .properties
192 .get("ailake.vector-column")
193 .map(String::as_str)
194 .unwrap_or("");
195 let stored_dim_key = if vector_column == primary_col {
196 "ailake.vector-dim".to_string()
197 } else {
198 format!("ailake.dim-{vector_column}")
199 };
200 if let Some(table_dim_str) = table_meta.properties.get(&stored_dim_key) {
201 if let Ok(table_dim) = table_dim_str.parse::<u32>() {
202 let query_dim = query.len() as u32;
203 if query_dim != table_dim {
204 let table_model = table_meta
205 .properties
206 .get(EmbeddingModelInfo::property_key())
207 .cloned()
208 .unwrap_or_else(|| format!("dim={}", table_dim));
209 return Err(AilakeError::ModelMismatch {
210 table_model,
211 table_dim,
212 batch_model: format!("query dim={}", query_dim),
213 batch_dim: query_dim,
214 });
215 }
216 }
217 }
218
219 let metric_key = if vector_column == primary_col {
221 "ailake.vector-metric".to_string()
222 } else {
223 format!("ailake.metric-{vector_column}")
224 };
225 let metric = parse_metric(
226 table_meta
227 .properties
228 .get(&metric_key)
229 .or_else(|| table_meta.properties.get("ailake.vector-metric"))
230 .map(String::as_str)
231 .unwrap_or("cosine"),
232 );
233
234 let all_files = if let Some(ref pv) = config.partition_filter {
236 let before = all_files.len();
237 let filtered: Vec<_> = all_files
238 .into_iter()
239 .filter(|f| f.partition_value.as_deref() == Some(pv.as_str()))
240 .collect();
241 debug!(
242 "ailake: partition pruning '{}' — {}/{} files survive",
243 pv,
244 filtered.len(),
245 before
246 );
247 filtered
248 } else {
249 all_files
250 };
251
252 let total_files = all_files.len();
254 let surviving_files = VectorPruner::prune(all_files, query, metric, config.pruning_threshold);
255 debug!(
256 "ailake: geometric pruning — {}/{} files survive (threshold={})",
257 surviving_files.len(),
258 total_files,
259 config.pruning_threshold
260 );
261
262 let surviving_files = if let Some(ref h) = config.hybrid {
265 let bloom_map = load_bloom_map(&table_meta, store.as_ref()).await;
266 if !bloom_map.is_empty() {
267 BloomPruner::prune(surviving_files, &h.query_text, &bloom_map)
268 } else {
269 surviving_files
270 }
271 } else {
272 surviving_files
273 };
274
275 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
279 Ok(edfs) if !edfs.is_empty() => {
280 match EqualityDeleteFilter::from_files(&store, &edfs).await {
281 Ok(f) => f,
282 Err(e) => {
283 warn!("ailake: equality delete filter build failed: {e} — rows may appear");
284 EqualityDeleteFilter::empty()
285 }
286 }
287 }
288 _ => EqualityDeleteFilter::empty(),
289 };
290
291 let candidate_k = match (&config.hybrid, config.rerank_factor) {
293 (Some(h), rf) => {
294 let pool = h.candidate_pool.unwrap_or(config.top_k * 10);
295 pool.max(rf.map_or(config.top_k, |f| f * config.top_k))
296 }
297 (None, Some(factor)) => config.top_k * factor,
298 (None, None) => config.top_k,
299 };
300
301 let use_hybrid = config.hybrid.is_some();
302
303 let bm25_stats: Option<crate::bm25::IdfStats> = if let Some(ref h) = config.hybrid {
305 if h.text_columns.is_empty() {
306 None
307 } else {
308 let stats_path = table_meta
309 .properties
310 .get(crate::bm25::BM25_STATS_PATH_PROP)
311 .map(String::as_str)
312 .unwrap_or(crate::bm25::BM25_STATS_FILE);
313 match store.get(stats_path).await {
314 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).ok(),
315 Err(_) => {
316 debug!(
317 "ailake: BM25 stats not found at '{}' — falling back to empty corpus IDF",
318 stats_path
319 );
320 None
321 }
322 }
323 }
324 } else {
325 None
326 };
327
328 let mut raw_candidates: Vec<(RowId, f32, String, String)> = Vec::new();
331 let mut all_results: Vec<SearchResult> = Vec::new();
332
333 for file_entry in &surviving_files {
334 let file_bytes: Bytes = store.get(&file_entry.path).await?;
335 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
336
337 let dv_bitmap: Option<roaring::RoaringBitmap> =
341 if let Some(ref dv) = file_entry.deletion_vector {
342 match crate::dv::load_deletion_vector(&store, dv).await {
343 Ok(bm) => {
344 debug!(
345 "ailake: DV loaded ({} deletions) for {}",
346 bm.len(),
347 file_entry.path
348 );
349 Some(bm)
350 }
351 Err(e) => {
352 warn!(
353 "ailake: DV fetch failed for '{}': {e} — deleted rows may appear",
354 file_entry.path
355 );
356 None
357 }
358 }
359 } else {
360 None
361 };
362
363 let need_parquet = file_entry.index_status == IndexStatus::Indexing
366 || !reader.is_ailake_file()
367 || config.rerank_factor.is_some()
368 || config.score_fn.is_some()
369 || use_hybrid
370 || !eq_del_filter.is_empty();
371
372 if file_entry.index_status == IndexStatus::Indexing || !reader.is_ailake_file() {
373 debug!(
374 "ailake: flat scan fallback for {} (index_status={:?})",
375 file_entry.path, file_entry.index_status
376 );
377 let (raw_batch, raw_vectors) = reader.read_parquet()?;
378 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
380 for (row_id, distance) in flat_search(&raw_vectors, query, candidate_k, metric) {
381 if dv_bitmap
383 .as_ref()
384 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
385 {
386 continue;
387 }
388 if eq_del_filter.should_delete_row(&batch, row_id.as_u64() as usize) {
390 continue;
391 }
392 if use_hybrid {
393 let text = extract_text_for_row(
394 &batch,
395 row_id.as_u64() as usize,
396 config.hybrid.as_ref().unwrap(),
397 );
398 raw_candidates.push((row_id, distance, file_entry.path.clone(), text));
399 } else {
400 let final_score = apply_score_fn(&config.score_fn, distance, row_id, &batch);
401 all_results.push(SearchResult {
402 row_id,
403 distance: final_score,
404 file_path: file_entry.path.clone(),
405 });
406 }
407 }
408 continue;
409 }
410
411 let index = reader.load_any_index_for_column(vector_column)?;
412 let local_results = index.search(query, candidate_k, config.ef_search);
413
414 let parquet_data = if need_parquet {
415 let (raw_batch, raw_vecs) = reader.read_parquet()?;
416 let filled = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
418 Some((filled, raw_vecs))
419 } else {
420 None
421 };
422
423 for (row_id, approx_dist) in local_results {
424 if dv_bitmap
426 .as_ref()
427 .is_some_and(|bm| bm.contains(row_id.as_u64() as u32))
428 {
429 continue;
430 }
431 let idx = row_id.as_u64() as usize;
432 if let Some((ref batch, _)) = parquet_data {
435 if eq_del_filter.should_delete_row(batch, idx) {
436 continue;
437 }
438 }
439
440 let distance = if config.rerank_factor.is_some() {
441 match parquet_data.as_ref().and_then(|(_, vecs)| vecs.get(idx)) {
442 Some(v) => exact_distance(metric, query, v),
443 None => {
444 error!(
445 "ailake: invariant violated — row_id {} out of bounds \
446 (file={}); Parquet and HNSW node count out of sync; \
447 run compaction to rebuild",
448 idx, file_entry.path
449 );
450 f32::INFINITY
451 }
452 }
453 } else {
454 approx_dist
455 };
456
457 if use_hybrid {
458 let text = parquet_data.as_ref().map_or(String::new(), |(batch, _)| {
459 extract_text_for_row(batch, idx, config.hybrid.as_ref().unwrap())
460 });
461 raw_candidates.push((row_id, distance, file_entry.path.clone(), text));
462 } else {
463 let final_score = if let Some((ref batch, _)) = parquet_data {
464 apply_score_fn(&config.score_fn, distance, row_id, batch)
465 } else {
466 distance
467 };
468 all_results.push(SearchResult {
469 row_id,
470 distance: final_score,
471 file_path: file_entry.path.clone(),
472 });
473 }
474 }
475 }
476
477 if let Some(ref h) = config.hybrid {
479 let empty_stats = crate::bm25::IdfStats::default();
480 let stats = bm25_stats.as_ref().unwrap_or(&empty_stats);
481 let scorer = crate::bm25::BM25Scorer::new(stats);
482
483 let bm25_scores_pre: Vec<f32> = raw_candidates
485 .iter()
486 .map(|(_, _, _, text)| scorer.score(&h.query_text, text))
487 .collect();
488
489 let mut candidates_with_bm25: Vec<((RowId, f32, String, String), f32)> =
492 raw_candidates.into_iter().zip(bm25_scores_pre).collect();
493 candidates_with_bm25.sort_by(|a, b| a.0 .1.total_cmp(&b.0 .1));
494 let n = candidates_with_bm25.len();
495
496 let vec_ranks: Vec<usize> = (0..n).collect();
497
498 let mut bm25_indexed: Vec<(usize, f32)> = candidates_with_bm25
500 .iter()
501 .map(|(_, b)| *b)
502 .enumerate()
503 .collect();
504 bm25_indexed.sort_by(|a, b| b.1.total_cmp(&a.1));
505 let mut bm25_rank_of = vec![0usize; n];
506 for (rank, (idx, _)) in bm25_indexed.iter().enumerate() {
507 bm25_rank_of[*idx] = rank;
508 }
509
510 use crate::bm25::{linear_score, rrf_score, HybridFusion};
511
512 let fused: Vec<f32> = match h.fusion {
513 HybridFusion::Rrf => vec_ranks
514 .iter()
515 .enumerate()
516 .map(|(i, &vr)| rrf_score(vr, bm25_rank_of[i], h.bm25_weight))
517 .collect(),
518 HybridFusion::Linear => {
519 let min_d = candidates_with_bm25
520 .iter()
521 .map(|(r, _)| r.1)
522 .fold(f32::INFINITY, f32::min);
523 let max_d = candidates_with_bm25
524 .iter()
525 .map(|(r, _)| r.1)
526 .fold(f32::NEG_INFINITY, f32::max);
527 let min_b = candidates_with_bm25
528 .iter()
529 .map(|(_, b)| *b)
530 .fold(f32::INFINITY, f32::min);
531 let max_b = candidates_with_bm25
532 .iter()
533 .map(|(_, b)| *b)
534 .fold(f32::NEG_INFINITY, f32::max);
535 candidates_with_bm25
536 .iter()
537 .map(|(r, b)| linear_score(r.1, min_d, max_d, *b, min_b, max_b, h.bm25_weight))
538 .collect()
539 }
540 };
541
542 for (i, ((row_id, _, file_path, _), _)) in candidates_with_bm25.into_iter().enumerate() {
543 all_results.push(SearchResult {
544 row_id,
545 distance: fused[i],
546 file_path,
547 });
548 }
549
550 all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
552 } else {
553 all_results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
554 }
555
556 all_results.truncate(config.top_k);
557 Ok(all_results)
558}
559
560fn extract_text_for_row(
562 batch: &RecordBatch,
563 row_idx: usize,
564 hybrid: &crate::bm25::HybridConfig,
565) -> String {
566 use arrow_array::cast::AsArray;
567 hybrid
568 .text_columns
569 .iter()
570 .filter_map(|col| {
571 batch.column_by_name(col).and_then(|arr| {
572 arr.as_string_opt::<i32>().and_then(|sa| {
573 if row_idx < sa.len() && sa.is_valid(row_idx) {
574 Some(sa.value(row_idx).to_string())
575 } else {
576 None
577 }
578 })
579 })
580 })
581 .collect::<Vec<_>>()
582 .join(" ")
583}
584
585#[derive(Debug, Clone)]
587pub struct ModalQuery<'a> {
588 pub column: &'a str,
590 pub query: &'a [f32],
592 pub weight: f32,
595 pub dim: u32,
598}
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
602pub enum FusionMethod {
603 Rrf,
607}
608
609pub async fn search_multimodal(
619 table: &TableIdent,
620 queries: &[ModalQuery<'_>],
621 config: SearchConfig,
622 catalog: Arc<dyn CatalogProvider>,
623 store: Arc<dyn Store>,
624 fusion: FusionMethod,
625) -> AilakeResult<Vec<SearchResult>> {
626 use std::collections::HashMap;
627
628 if queries.is_empty() {
629 return Err(AilakeError::InvalidArgument(
630 "search_multimodal requires at least one ModalQuery".into(),
631 ));
632 }
633
634 let table_meta = catalog.load_table(table).await?;
636 let primary_col = table_meta
637 .properties
638 .get("ailake.vector-column")
639 .cloned()
640 .unwrap_or_default();
641 let primary_dim: u32 = table_meta
642 .properties
643 .get("ailake.vector-dim")
644 .and_then(|s| s.parse().ok())
645 .unwrap_or(0);
646
647 let per_col_k = (config.top_k * queries.len().max(2)).min(1000);
649
650 let mut per_col_results: Vec<(f32, Vec<SearchResult>)> = Vec::with_capacity(queries.len());
651 for mq in queries {
652 let resolved_dim = if mq.dim > 0 {
654 mq.dim
655 } else if mq.column == primary_col {
656 primary_dim
657 } else {
658 table_meta
659 .properties
660 .get(&format!("ailake.dim-{}", mq.column))
661 .and_then(|s| s.parse().ok())
662 .unwrap_or(mq.query.len() as u32)
663 };
664
665 let col_config = SearchConfig {
666 top_k: per_col_k,
667 ef_search: config.ef_search,
668 pruning_threshold: config.pruning_threshold,
669 rerank_factor: config.rerank_factor,
670 score_fn: None,
671 partition_filter: config.partition_filter.clone(),
672 hybrid: None,
673 };
674 let results = search(
675 table,
676 mq.query,
677 col_config,
678 mq.column,
679 resolved_dim,
680 catalog.clone(),
681 store.clone(),
682 )
683 .await?;
684 per_col_results.push((mq.weight, results));
685 }
686
687 const K: f32 = 60.0;
689 let mut scores: HashMap<(String, u64), f32> = HashMap::new();
690
691 for (weight, results) in &per_col_results {
692 for (rank, r) in results.iter().enumerate() {
693 let key = (r.file_path.clone(), r.row_id.as_u64());
694 let rrf = weight / (K + rank as f32 + 1.0);
695 *scores.entry(key).or_insert(0.0) += rrf;
696 }
697 }
698
699 let all_files = catalog.list_files(table, None).await?;
702 let _ = all_files; let mut seen: HashMap<(String, u64), f32> = HashMap::new();
706 for (_, results) in &per_col_results {
707 for r in results {
708 let key = (r.file_path.clone(), r.row_id.as_u64());
709 let rrf_score = *scores.get(&key).unwrap_or(&0.0);
710 seen.entry(key).or_insert(rrf_score);
711 }
712 }
713
714 let mut fused: Vec<SearchResult> = seen
715 .into_iter()
716 .map(|((file_path, row_id_u64), rrf_score)| SearchResult {
717 row_id: RowId::new(row_id_u64),
718 distance: -rrf_score,
719 file_path,
720 })
721 .collect();
722
723 fused.sort_by(|a, b| {
724 a.distance
725 .partial_cmp(&b.distance)
726 .unwrap_or(std::cmp::Ordering::Equal)
727 });
728 fused.truncate(config.top_k);
729
730 let _ = fusion; Ok(fused)
733}
734
735#[inline]
740fn apply_score_fn(
741 score_fn: &Option<ScoreFn>,
742 distance: f32,
743 row_id: RowId,
744 batch: &RecordBatch,
745) -> f32 {
746 match score_fn {
747 None => distance,
748 Some(f) => {
749 let idx = row_id.as_u64() as usize;
750 if idx < batch.num_rows() {
751 f.call(distance, &batch.slice(idx, 1))
752 } else {
753 distance
754 }
755 }
756 }
757}
758
759fn flat_search(
761 raw: &[Vec<f32>],
762 query: &[f32],
763 top_k: usize,
764 metric: VectorMetric,
765) -> Vec<(RowId, f32)> {
766 let mut results: Vec<(RowId, f32)> = raw
767 .iter()
768 .enumerate()
769 .map(|(i, v)| (RowId::new(i as u64), exact_distance(metric, query, v)))
770 .collect();
771 results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
772 results.truncate(top_k);
773 results
774}
775
776fn parse_metric(s: &str) -> VectorMetric {
777 match s {
778 "euclidean" => VectorMetric::Euclidean,
779 "dotproduct" | "dot_product" | "dot" => VectorMetric::DotProduct,
780 "normalized_cosine" | "normalizedcosine" => VectorMetric::NormalizedCosine,
781 _ => VectorMetric::Cosine,
782 }
783}
784
785pub struct SearchSession {
790 shards: Vec<LoadedShard>,
791 metric: VectorMetric,
792}
793
794struct LoadedShard {
795 entry: DataFileEntry,
796 index: Option<AnyIndex>,
798 raw_vectors: Option<Vec<Vec<f32>>>,
801}
802
803impl SearchSession {
804 pub async fn load(
810 table: &TableIdent,
811 vector_column: &str,
812 dim: u32,
813 catalog: Arc<dyn CatalogProvider>,
814 store: Arc<dyn Store>,
815 load_raw: bool,
816 ) -> AilakeResult<Self> {
817 let all_files = catalog.list_files(table, None).await?;
818 let table_meta = catalog.load_table(table).await?;
819 let metric = parse_metric(
820 table_meta
821 .properties
822 .get("ailake.vector-metric")
823 .map(String::as_str)
824 .unwrap_or("cosine"),
825 );
826
827 let mut shards = Vec::with_capacity(all_files.len());
828 for entry in all_files {
829 let file_bytes: Bytes = store.get(&entry.path).await?;
830 let reader = AilakeFileReader::new(file_bytes, vector_column, dim);
831
832 if entry.index_status == IndexStatus::Indexing {
833 let (_, raw_vecs) = reader.read_parquet()?;
835 shards.push(LoadedShard {
836 entry,
837 index: None,
838 raw_vectors: Some(raw_vecs),
839 });
840 } else if reader.is_ailake_file() {
841 let mut index = reader.load_any_index_for_column(vector_column)?;
842 let raw_vectors = if load_raw {
843 index.quantize_to_f16();
844 let (_, vecs) = reader.read_parquet()?;
845 Some(vecs)
846 } else {
847 None
848 };
849 shards.push(LoadedShard {
850 entry,
851 index: Some(index),
852 raw_vectors,
853 });
854 }
855 }
856
857 Ok(Self { shards, metric })
858 }
859
860 pub fn shard_count(&self) -> usize {
862 self.shards.len()
863 }
864
865 pub fn search_batch(
874 &self,
875 queries: &[Vec<f32>],
876 config: &SearchConfig,
877 ) -> Vec<Vec<SearchResult>> {
878 if queries.is_empty() {
879 return vec![];
880 }
881
882 let n_queries = queries.len();
883 let candidate_k = match config.rerank_factor {
884 Some(factor) => config.top_k * factor,
885 None => config.top_k,
886 };
887 let use_nvidia = ailake_index::hardware::detect_cuda();
888 let use_amd = ailake_index::hardware::detect_rocm();
889
890 let mut all_results: Vec<Vec<SearchResult>> = (0..n_queries).map(|_| Vec::new()).collect();
892
893 for shard in &self.shards {
894 if let Some(raw) = &shard.raw_vectors {
895 if !raw.is_empty() {
897 let dim = raw[0].len();
898 let flat: Vec<f32> = raw.iter().flat_map(|v| v.iter().copied()).collect();
899 let row_ids: Vec<u64> = (0..raw.len() as u64).collect();
900 let q_refs: Vec<&[f32]> = queries.iter().map(|q| q.as_slice()).collect();
901
902 let gpu_batch = if use_nvidia {
903 ailake_index::gpu::try_nvidia_search_batch(
904 &q_refs,
905 &row_ids,
906 &flat,
907 dim,
908 self.metric,
909 candidate_k,
910 )
911 } else if use_amd {
912 ailake_index::gpu::try_rocm_search_batch(
913 &q_refs,
914 &row_ids,
915 &flat,
916 dim,
917 self.metric,
918 candidate_k,
919 )
920 } else {
921 None
922 };
923
924 if let Some(batch) = gpu_batch {
925 for (qi, results) in batch.into_iter().enumerate() {
926 for (row_id, distance) in results {
927 all_results[qi].push(SearchResult {
928 row_id,
929 distance,
930 file_path: shard.entry.path.clone(),
931 });
932 }
933 }
934 continue;
935 }
936 }
937
938 for (qi, query) in queries.iter().enumerate() {
940 for (row_id, distance) in flat_search(raw, query, candidate_k, self.metric) {
941 all_results[qi].push(SearchResult {
942 row_id,
943 distance,
944 file_path: shard.entry.path.clone(),
945 });
946 }
947 }
948 } else if let Some(index) = &shard.index {
949 let shard_results: Vec<Vec<SearchResult>> = queries
951 .par_iter()
952 .map(|query| {
953 index
954 .search(query, candidate_k, config.ef_search)
955 .into_iter()
956 .map(|(row_id, distance)| SearchResult {
957 row_id,
958 distance,
959 file_path: shard.entry.path.clone(),
960 })
961 .collect()
962 })
963 .collect();
964
965 for (qi, results) in shard_results.into_iter().enumerate() {
966 all_results[qi].extend(results);
967 }
968 }
969 }
970
971 for results in &mut all_results {
973 results.sort_by(|a, b| {
974 a.distance
975 .partial_cmp(&b.distance)
976 .unwrap_or(std::cmp::Ordering::Equal)
977 });
978 results.truncate(config.top_k);
979 }
980
981 all_results
982 }
983
984 pub fn search_query(&self, query: &[f32], config: &SearchConfig) -> Vec<SearchResult> {
986 let candidate_k = match config.rerank_factor {
987 Some(factor) => config.top_k * factor,
988 None => config.top_k,
989 };
990
991 let mut all_results: Vec<SearchResult> = self
992 .shards
993 .par_iter()
994 .flat_map(|shard| {
995 if let Some(centroid) = ailake_catalog::decode_centroid(&shard.entry, self.metric) {
997 let dist = match self.metric {
998 VectorMetric::Cosine | VectorMetric::NormalizedCosine => {
999 ailake_vec::cosine_distance(query, ¢roid.values)
1000 }
1001 VectorMetric::Euclidean => {
1002 ailake_vec::euclidean_distance(query, ¢roid.values)
1003 }
1004 VectorMetric::DotProduct => {
1005 -ailake_vec::dot_product(query, ¢roid.values)
1006 }
1007 };
1008 if dist - centroid.radius > config.pruning_threshold {
1009 return vec![];
1010 }
1011 }
1012
1013 if let Some(index) = &shard.index {
1014 let local_results = index.search(query, candidate_k, config.ef_search);
1016 if config.rerank_factor.is_some() {
1017 if let Some(raw) = &shard.raw_vectors {
1018 local_results
1019 .into_iter()
1020 .map(|(row_id, _approx_dist)| {
1021 let idx = row_id.as_u64() as usize;
1022 let exact_dist = raw
1023 .get(idx)
1024 .map(|v| exact_distance(self.metric, query, v))
1025 .unwrap_or(f32::INFINITY);
1026 SearchResult {
1027 row_id,
1028 distance: exact_dist,
1029 file_path: shard.entry.path.clone(),
1030 }
1031 })
1032 .collect()
1033 } else {
1034 local_results
1035 .into_iter()
1036 .map(|(row_id, distance)| SearchResult {
1037 row_id,
1038 distance,
1039 file_path: shard.entry.path.clone(),
1040 })
1041 .collect()
1042 }
1043 } else {
1044 local_results
1045 .into_iter()
1046 .map(|(row_id, distance)| SearchResult {
1047 row_id,
1048 distance,
1049 file_path: shard.entry.path.clone(),
1050 })
1051 .collect()
1052 }
1053 } else if let Some(raw) = &shard.raw_vectors {
1054 flat_search(raw, query, candidate_k, self.metric)
1056 .into_iter()
1057 .map(|(row_id, distance)| SearchResult {
1058 row_id,
1059 distance,
1060 file_path: shard.entry.path.clone(),
1061 })
1062 .collect()
1063 } else {
1064 vec![]
1065 }
1066 })
1067 .collect();
1068
1069 all_results.sort_by(|a, b| {
1070 a.distance
1071 .partial_cmp(&b.distance)
1072 .unwrap_or(std::cmp::Ordering::Equal)
1073 });
1074 all_results.truncate(config.top_k);
1075 all_results
1076 }
1077}
1078
1079pub async fn search_text(
1092 table: &TableIdent,
1093 query_text: &str,
1094 text_columns: &[&str],
1095 top_k: usize,
1096 catalog: Arc<dyn CatalogProvider>,
1097 store: Arc<dyn Store>,
1098 partition_filter: Option<&str>,
1099) -> AilakeResult<Vec<SearchResult>> {
1100 use arrow_array::cast::AsArray;
1101
1102 if text_columns.is_empty() {
1103 return Err(AilakeError::InvalidArgument(
1104 "search_text requires at least one text column".into(),
1105 ));
1106 }
1107
1108 let all_files = catalog.list_files(table, None).await?;
1109 let table_meta = catalog.load_table(table).await?;
1110
1111 let files: Vec<_> = if let Some(pv) = partition_filter {
1113 all_files
1114 .into_iter()
1115 .filter(|f| f.partition_value.as_deref() == Some(pv))
1116 .collect()
1117 } else {
1118 all_files
1119 };
1120
1121 let stats_path = table_meta
1123 .properties
1124 .get(crate::bm25::BM25_STATS_PATH_PROP)
1125 .map(String::as_str)
1126 .unwrap_or(crate::bm25::BM25_STATS_FILE);
1127 let stats = match store.get(stats_path).await {
1128 Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
1129 Err(_) => {
1130 debug!(
1131 "ailake: BM25 stats not found at '{}' — using empty corpus IDF",
1132 stats_path
1133 );
1134 crate::bm25::IdfStats::default()
1135 }
1136 };
1137 let scorer = crate::bm25::BM25Scorer::new(&stats);
1138
1139 let eq_del_filter = match catalog.list_equality_deletes(table, None).await {
1141 Ok(edfs) if !edfs.is_empty() => {
1142 match EqualityDeleteFilter::from_files(&store, &edfs).await {
1143 Ok(f) => f,
1144 Err(e) => {
1145 warn!("ailake: equality delete filter build failed in search_text: {e}");
1146 EqualityDeleteFilter::empty()
1147 }
1148 }
1149 }
1150 _ => EqualityDeleteFilter::empty(),
1151 };
1152
1153 let mut results: Vec<SearchResult> = Vec::new();
1154
1155 for file_entry in &files {
1156 let file_bytes = store.get(&file_entry.path).await?;
1157 let reader = AilakeFileReader::new(file_bytes.clone(), "", 0);
1159
1160 if let Ok(Some(fts_blob)) = reader.load_fts_blob() {
1163 match ailake_fts::FtsSearcher::from_blob(&fts_blob) {
1164 Ok(fts) => {
1165 let hits = fts.search(query_text, top_k * 3).unwrap_or_default();
1166 if !hits.is_empty() {
1167 let reader2 = AilakeFileReader::new(file_bytes, "", 0);
1169 let (raw_batch, _) = reader2.read_parquet()?;
1170 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1171 for hit in hits {
1172 let row_idx = hit.row_id as usize;
1173 if row_idx >= batch.num_rows() {
1174 continue;
1175 }
1176 if eq_del_filter.should_delete_row(&batch, row_idx) {
1177 continue;
1178 }
1179 results.push(SearchResult {
1180 row_id: RowId::new(hit.row_id),
1181 distance: -hit.score,
1182 file_path: file_entry.path.clone(),
1183 });
1184 }
1185 }
1186 continue; }
1188 Err(e) => {
1189 warn!("ailake: FTS blob corrupt for '{}': {e}", file_entry.path);
1190 }
1192 }
1193 }
1194
1195 let reader_fb = AilakeFileReader::new(file_bytes, "", 0);
1197 let (raw_batch, _) = reader_fb.read_parquet()?;
1198 let batch = SchemaFiller::fill(raw_batch, &table_meta.schema_fields)?;
1200
1201 for row_idx in 0..batch.num_rows() {
1202 if eq_del_filter.should_delete_row(&batch, row_idx) {
1204 continue;
1205 }
1206 let doc_text: String = text_columns
1207 .iter()
1208 .filter_map(|&col| {
1209 batch.column_by_name(col).and_then(|arr| {
1210 arr.as_string_opt::<i32>().and_then(|sa| {
1211 if sa.is_valid(row_idx) {
1212 Some(sa.value(row_idx).to_string())
1213 } else {
1214 None
1215 }
1216 })
1217 })
1218 })
1219 .collect::<Vec<_>>()
1220 .join(" ");
1221
1222 if doc_text.is_empty() {
1223 continue;
1224 }
1225
1226 let bm25 = scorer.score(query_text, &doc_text);
1227 if bm25 > 0.0 {
1228 results.push(SearchResult {
1230 row_id: RowId::new(row_idx as u64),
1231 distance: -bm25,
1232 file_path: file_entry.path.clone(),
1233 });
1234 }
1235 }
1236 }
1237
1238 results.sort_by(|a, b| a.distance.total_cmp(&b.distance));
1239 results.truncate(top_k);
1240 Ok(results)
1241}
1242
1243pub async fn fetch_rows(
1252 results: &[SearchResult],
1253 store: Arc<dyn Store>,
1254 vector_column: &str,
1255 dim: u32,
1256) -> AilakeResult<RecordBatch> {
1257 use std::collections::HashMap;
1258
1259 use arrow_array::{ArrayRef, Float32Array, UInt32Array};
1260 use arrow_schema::{DataType, Field, Schema};
1261 use arrow_select::{concat::concat_batches, take::take};
1262
1263 if results.is_empty() {
1264 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1265 }
1266
1267 let mut by_file: HashMap<&str, Vec<(u64, f32, usize)>> = HashMap::new();
1269 for (i, r) in results.iter().enumerate() {
1270 by_file
1271 .entry(r.file_path.as_str())
1272 .or_default()
1273 .push((r.row_id.as_u64(), r.distance, i));
1274 }
1275
1276 use arrow_array::FixedSizeListArray;
1277
1278 let mut collected: Vec<(usize, f32, RecordBatch, Vec<f32>)> = Vec::with_capacity(results.len());
1280
1281 for (file_path, rows) in &by_file {
1282 let bytes = store.get(file_path).await?;
1283 let reader = AilakeFileReader::new(bytes, vector_column, dim);
1284 let (batch, vectors) = reader.read_parquet()?;
1285
1286 for &(row_id, distance, pos) in rows {
1287 let idx = row_id as usize;
1288 if idx >= batch.num_rows() {
1289 tracing::warn!(
1290 "fetch_rows: row_id {} out of bounds (file_rows={}, file={}), skipping",
1291 idx,
1292 batch.num_rows(),
1293 file_path
1294 );
1295 continue;
1296 }
1297
1298 let indices = UInt32Array::from(vec![idx as u32]);
1299 let row_cols: Vec<ArrayRef> = batch
1300 .columns()
1301 .iter()
1302 .map(|col| {
1303 take(col.as_ref(), &indices, None)
1304 .map_err(|e| AilakeError::Arrow(e.to_string()))
1305 })
1306 .collect::<AilakeResult<Vec<_>>>()?;
1307
1308 let row_batch = RecordBatch::try_new(batch.schema(), row_cols)
1309 .map_err(|e| AilakeError::Arrow(e.to_string()))?;
1310
1311 let vec = vectors
1313 .get(idx)
1314 .cloned()
1315 .unwrap_or_else(|| vec![0.0f32; dim as usize]);
1316
1317 collected.push((pos, distance, row_batch, vec));
1318 }
1319 }
1320
1321 if collected.is_empty() {
1322 return Ok(RecordBatch::new_empty(Arc::new(Schema::empty())));
1323 }
1324
1325 collected.sort_by_key(|(pos, _, _, _)| *pos);
1327
1328 let distances: Vec<f32> = collected.iter().map(|(_, d, _, _)| *d).collect();
1329 let row_batches: Vec<&RecordBatch> = collected.iter().map(|(_, _, b, _)| b).collect();
1330 let base_schema = collected[0].2.schema();
1331
1332 let combined =
1333 concat_batches(&base_schema, row_batches).map_err(|e| AilakeError::Arrow(e.to_string()))?;
1334
1335 let flat_vecs: Vec<f32> = collected
1337 .iter()
1338 .flat_map(|(_, _, _, v)| v.iter().copied())
1339 .collect();
1340 let item_field = Arc::new(Field::new("item", DataType::Float32, false));
1341 let values_arr = Arc::new(Float32Array::from(flat_vecs)) as ArrayRef;
1342 let vec_col = FixedSizeListArray::new(item_field.clone(), dim as i32, values_arr, None);
1343 let vec_field = Arc::new(Field::new(
1344 vector_column,
1345 DataType::FixedSizeList(item_field, dim as i32),
1346 false,
1347 ));
1348
1349 let mut fields: Vec<Arc<Field>> = base_schema.fields().to_vec();
1351 fields.push(vec_field);
1352 fields.push(Arc::new(Field::new("_distance", DataType::Float32, false)));
1353 let new_schema = Arc::new(Schema::new(fields));
1354
1355 let mut columns: Vec<ArrayRef> = combined.columns().to_vec();
1356 columns.push(Arc::new(vec_col));
1357 columns.push(Arc::new(Float32Array::from(distances)));
1358
1359 RecordBatch::try_new(new_schema, columns).map_err(|e| AilakeError::Arrow(e.to_string()))
1360}
1361
1362async fn load_bloom_map(
1368 table_meta: &ailake_catalog::TableMetadata,
1369 store: &dyn Store,
1370) -> std::collections::HashMap<String, crate::bloom::BloomFilter> {
1371 let stats_path = match &table_meta.current_statistics_path {
1372 Some(p) => p.clone(),
1373 None => return std::collections::HashMap::new(),
1374 };
1375 let bytes = match store.get(&stats_path).await {
1376 Ok(b) => b,
1377 Err(e) => {
1378 debug!("ailake: Phase F — could not load Puffin stats ({stats_path}): {e}");
1379 return std::collections::HashMap::new();
1380 }
1381 };
1382 let reader = ailake_catalog::AilakePuffinReader::new(&bytes);
1383 let bloom_entries = match reader.read_bm25_blooms() {
1384 Ok(e) => e,
1385 Err(e) => {
1386 warn!("ailake: Phase F — Puffin bloom parse error: {e}");
1387 return std::collections::HashMap::new();
1388 }
1389 };
1390 bloom_entries
1391 .into_iter()
1392 .filter_map(|entry| {
1393 let bf = crate::bloom::BloomFilter::from_bytes(&entry.bloom_bytes)?;
1394 Some((entry.path, bf))
1395 })
1396 .collect()
1397}
1398
1399#[cfg(test)]
1400mod tests {
1401 use super::*;
1402 use crate::writer::MultiVectorBatch;
1403 use ailake_catalog::{HadoopCatalog, TableIdent};
1404 use ailake_core::{VectorMetric, VectorPrecision, VectorStoragePolicy};
1405 use ailake_store::LocalStore;
1406 use arrow_array::{Int32Array, RecordBatch};
1407 use arrow_schema::{DataType, Field, Schema};
1408 use std::sync::Arc;
1409 use tempfile::TempDir;
1410
1411 fn make_policy(dim: u32) -> VectorStoragePolicy {
1412 VectorStoragePolicy {
1413 column_name: "embedding".to_string(),
1414 dim,
1415 metric: VectorMetric::Cosine,
1416 precision: VectorPrecision::F16,
1417 pq: None,
1418 keep_raw_for_reranking: true,
1419 pre_normalize: false,
1420 hnsw_m: None,
1421 hnsw_ef_construction: None,
1422 ivf_residual: false,
1423 embedding_model: None,
1424 modality: None,
1425 partition_by: None,
1426 partition_value: None,
1427 partition_column_type: None,
1428 partition_fields: vec![],
1429 }
1430 }
1431
1432 async fn write_demo_table(dir: &TempDir, dim: usize, rows: usize) {
1433 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1434 let catalog = Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1435 let table = TableIdent::new("default", "table");
1436
1437 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1438 let ids: Vec<i32> = (0..rows as i32).collect();
1439 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1440
1441 let embeddings: Vec<Vec<f32>> = (0..rows)
1443 .map(|i| {
1444 let mut v = vec![0.0f32; dim];
1445 v[i % dim] = 1.0;
1446 v
1447 })
1448 .collect();
1449
1450 let mut writer =
1451 crate::TableWriter::create_or_open(catalog, store, make_policy(dim as u32), table, 2)
1452 .await
1453 .unwrap();
1454 writer.write_batch(&batch, &embeddings).await.unwrap();
1455 writer.commit().await.unwrap();
1456 }
1457
1458 #[tokio::test]
1459 async fn rerank_returns_correct_top_k_count() {
1460 let dir = TempDir::new().unwrap();
1461 let dim = 8usize;
1462 write_demo_table(&dir, dim, 8).await;
1463
1464 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1465 let catalog: Arc<dyn CatalogProvider> =
1466 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1467 let table = TableIdent::new("default", "table");
1468
1469 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1470 let config = SearchConfig {
1471 top_k: 3,
1472 ef_search: 50,
1473 pruning_threshold: f32::INFINITY,
1474 rerank_factor: Some(2),
1475 score_fn: None,
1476 partition_filter: None,
1477 hybrid: None,
1478 };
1479
1480 let results = search(
1481 &table,
1482 &query,
1483 config,
1484 "embedding",
1485 dim as u32,
1486 catalog,
1487 store,
1488 )
1489 .await
1490 .unwrap();
1491
1492 assert_eq!(results.len(), 3);
1493 }
1494
1495 #[tokio::test]
1496 async fn rerank_nearest_is_exact_match() {
1497 let dir = TempDir::new().unwrap();
1498 let dim = 8usize;
1499 write_demo_table(&dir, dim, 8).await;
1500
1501 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1502 let catalog: Arc<dyn CatalogProvider> =
1503 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1504 let table = TableIdent::new("default", "table");
1505
1506 let query = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
1508 let config = SearchConfig {
1509 top_k: 1,
1510 ef_search: 50,
1511 pruning_threshold: f32::INFINITY,
1512 rerank_factor: Some(4),
1513 score_fn: None,
1514 partition_filter: None,
1515 hybrid: None,
1516 };
1517
1518 let results = search(
1519 &table,
1520 &query,
1521 config,
1522 "embedding",
1523 dim as u32,
1524 catalog,
1525 store,
1526 )
1527 .await
1528 .unwrap();
1529
1530 assert_eq!(results.len(), 1);
1531 assert!(
1533 results[0].distance < 1e-3,
1534 "distance was {}",
1535 results[0].distance
1536 );
1537 assert_eq!(results[0].row_id, RowId::new(0));
1538 }
1539
1540 #[tokio::test]
1541 async fn no_rerank_matches_default_behavior() {
1542 let dir = TempDir::new().unwrap();
1543 let dim = 4usize;
1544 write_demo_table(&dir, dim, 4).await;
1545
1546 let store_a: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1547 let store_b: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1548 let cat_a: Arc<dyn CatalogProvider> =
1549 Arc::new(HadoopCatalog::new(store_a.clone(), "warehouse"));
1550 let cat_b: Arc<dyn CatalogProvider> =
1551 Arc::new(HadoopCatalog::new(store_b.clone(), "warehouse"));
1552 let table = TableIdent::new("default", "table");
1553
1554 let query = vec![1.0f32, 0.0, 0.0, 0.0];
1555 let cfg_plain = SearchConfig {
1556 top_k: 2,
1557 ef_search: 50,
1558 pruning_threshold: f32::INFINITY,
1559 rerank_factor: None,
1560 score_fn: None,
1561 partition_filter: None,
1562 hybrid: None,
1563 };
1564 let cfg_rerank = SearchConfig {
1565 top_k: 2,
1566 ef_search: 50,
1567 pruning_threshold: f32::INFINITY,
1568 rerank_factor: Some(2),
1569 score_fn: None,
1570 partition_filter: None,
1571 hybrid: None,
1572 };
1573
1574 let plain = search(
1575 &table,
1576 &query,
1577 cfg_plain,
1578 "embedding",
1579 dim as u32,
1580 cat_a,
1581 store_a,
1582 )
1583 .await
1584 .unwrap();
1585 let reranked = search(
1586 &table,
1587 &query,
1588 cfg_rerank,
1589 "embedding",
1590 dim as u32,
1591 cat_b,
1592 store_b,
1593 )
1594 .await
1595 .unwrap();
1596
1597 assert_eq!(plain[0].row_id, reranked[0].row_id);
1599 }
1600
1601 #[tokio::test]
1602 async fn multimodal_rrf_returns_top_k() {
1603 let dir = TempDir::new().unwrap();
1604 let dim = 4usize;
1605 write_demo_table(&dir, dim, 4).await;
1606
1607 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1608 let catalog: Arc<dyn CatalogProvider> =
1609 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1610 let table = TableIdent::new("default", "table");
1611
1612 let q1 = vec![1.0f32, 0.0, 0.0, 0.0];
1615 let q2 = vec![0.0f32, 1.0, 0.0, 0.0];
1616
1617 let queries = vec![
1618 ModalQuery {
1619 column: "embedding",
1620 query: &q1,
1621 weight: 0.7,
1622 dim: dim as u32,
1623 },
1624 ModalQuery {
1625 column: "embedding",
1626 query: &q2,
1627 weight: 0.3,
1628 dim: dim as u32,
1629 },
1630 ];
1631
1632 let config = SearchConfig {
1633 top_k: 2,
1634 ef_search: 50,
1635 pruning_threshold: f32::INFINITY,
1636 rerank_factor: None,
1637 score_fn: None,
1638 partition_filter: None,
1639 hybrid: None,
1640 };
1641
1642 let results =
1643 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
1644 .await
1645 .unwrap();
1646
1647 assert_eq!(results.len(), 2);
1648 assert!(results[0].distance <= 0.0);
1650 assert!(results[0].row_id.as_u64() < 4);
1652 }
1653
1654 #[tokio::test]
1658 async fn multimodal_rrf_cross_modal_different_dims() {
1659 let dir = TempDir::new().unwrap();
1660 let store: Arc<dyn Store> = Arc::new(LocalStore::new(dir.path()));
1661 let catalog: Arc<dyn CatalogProvider> =
1662 Arc::new(HadoopCatalog::new(store.clone(), "warehouse"));
1663 let table = TableIdent::new("default", "table");
1664
1665 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1667 let rows = 4usize;
1668 let ids: Vec<i32> = (0..rows as i32).collect();
1669 let batch = RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(ids))]).unwrap();
1670
1671 let text_embs: Vec<Vec<f32>> = (0..rows)
1672 .map(|i| {
1673 let mut v = vec![0.0f32; 4];
1674 v[i % 4] = 1.0;
1675 v
1676 })
1677 .collect();
1678 let img_embs: Vec<Vec<f32>> = (0..rows)
1679 .map(|i| {
1680 let mut v = vec![0.0f32; 2];
1681 v[i % 2] = 1.0;
1682 v
1683 })
1684 .collect();
1685
1686 let text_policy = make_policy(4);
1687 let img_policy = VectorStoragePolicy {
1688 column_name: "img_embedding".to_string(),
1689 dim: 2,
1690 metric: VectorMetric::Cosine,
1691 precision: VectorPrecision::F16,
1692 pq: None,
1693 keep_raw_for_reranking: true,
1694 pre_normalize: false,
1695 hnsw_m: None,
1696 hnsw_ef_construction: None,
1697 ivf_residual: false,
1698 embedding_model: None,
1699 modality: None,
1700 partition_by: None,
1701 partition_value: None,
1702 partition_column_type: None,
1703 partition_fields: vec![],
1704 };
1705
1706 let mut writer = crate::TableWriter::create_or_open(
1707 catalog.clone(),
1708 store.clone(),
1709 text_policy,
1710 table.clone(),
1711 2,
1712 )
1713 .await
1714 .unwrap();
1715
1716 let batches = [
1717 MultiVectorBatch {
1718 policy: make_policy(4),
1719 embeddings: &text_embs,
1720 },
1721 MultiVectorBatch {
1722 policy: img_policy,
1723 embeddings: &img_embs,
1724 },
1725 ];
1726 writer.write_batch_multi(&batch, &batches).await.unwrap();
1727 writer.commit().await.unwrap();
1728
1729 let q_text = vec![1.0f32, 0.0, 0.0, 0.0];
1731 let q_img = vec![1.0f32, 0.0];
1732
1733 let queries = vec![
1734 ModalQuery {
1735 column: "embedding",
1736 query: &q_text,
1737 weight: 0.6,
1738 dim: 4,
1739 },
1740 ModalQuery {
1741 column: "img_embedding",
1742 query: &q_img,
1743 weight: 0.4,
1744 dim: 2,
1745 },
1746 ];
1747 let config = SearchConfig {
1748 top_k: 2,
1749 ef_search: 50,
1750 pruning_threshold: f32::INFINITY,
1751 rerank_factor: None,
1752 score_fn: None,
1753 partition_filter: None,
1754 hybrid: None,
1755 };
1756
1757 let results =
1758 search_multimodal(&table, &queries, config, catalog, store, FusionMethod::Rrf)
1759 .await
1760 .unwrap();
1761
1762 assert!(!results.is_empty(), "should return results");
1763 assert!(results[0].distance <= 0.0, "distance is -rrf_score");
1764 assert_eq!(results[0].row_id.as_u64(), 0, "row 0 should rank first");
1766 }
1767}