use crate::lumina::reader::LuminaVectorGlobalIndexReader;
use crate::lumina::{GlobalIndexIOMeta, SearchResult, VectorSearch, LUMINA_VECTOR_ANN_IDENTIFIER};
use crate::spec::{DataField, FileKind, IndexManifest};
use crate::table::snapshot_manager::SnapshotManager;
use crate::table::{find_field_id_by_name, RowRange, Table};
use std::collections::HashMap;
use std::io::Cursor;
const INDEX_DIR: &str = "index";
pub struct VectorSearchBuilder<'a> {
table: &'a Table,
vector_column: Option<String>,
query_vector: Option<Vec<f32>>,
limit: Option<usize>,
}
impl<'a> VectorSearchBuilder<'a> {
pub(crate) fn new(table: &'a Table) -> Self {
Self {
table,
vector_column: None,
query_vector: None,
limit: None,
}
}
pub fn with_vector_column(&mut self, name: &str) -> &mut Self {
self.vector_column = Some(name.to_string());
self
}
pub fn with_query_vector(&mut self, vector: Vec<f32>) -> &mut Self {
self.query_vector = Some(vector);
self
}
pub fn with_limit(&mut self, limit: usize) -> &mut Self {
self.limit = Some(limit);
self
}
pub async fn execute(&self) -> crate::Result<Vec<RowRange>> {
let vector_column =
self.vector_column
.as_deref()
.ok_or_else(|| crate::Error::ConfigInvalid {
message: "Vector column must be set via with_vector_column()".to_string(),
})?;
let query_vector =
self.query_vector
.as_ref()
.ok_or_else(|| crate::Error::ConfigInvalid {
message: "Query vector must be set via with_query_vector()".to_string(),
})?;
let limit = self.limit.ok_or_else(|| crate::Error::ConfigInvalid {
message: "Limit must be set via with_limit()".to_string(),
})?;
let vector_search =
VectorSearch::new(query_vector.clone(), limit, vector_column.to_string())?;
let snapshot_manager = SnapshotManager::new(
self.table.file_io().clone(),
self.table.location().to_string(),
);
let snapshot = match snapshot_manager.get_latest_snapshot().await? {
Some(s) => s,
None => return Ok(Vec::new()),
};
let index_manifest_name = match snapshot.index_manifest() {
Some(name) => name.to_string(),
None => return Ok(Vec::new()),
};
let manifest_path = format!(
"{}/manifest/{}",
self.table.location().trim_end_matches('/'),
index_manifest_name
);
let index_entries = IndexManifest::read(self.table.file_io(), &manifest_path).await?;
evaluate_vector_search(
self.table.file_io(),
self.table.location(),
self.table.schema().options(),
&index_entries,
&vector_search,
self.table.schema().fields(),
)
.await
}
}
async fn evaluate_vector_search(
file_io: &crate::io::FileIO,
table_path: &str,
table_options: &HashMap<String, String>,
index_entries: &[crate::spec::IndexManifestEntry],
vector_search: &VectorSearch,
schema_fields: &[DataField],
) -> crate::Result<Vec<RowRange>> {
let table_path = table_path.trim_end_matches('/');
let field_id = match find_field_id_by_name(schema_fields, &vector_search.field_name) {
Some(id) => id,
None => return Ok(Vec::new()),
};
let lumina_entries: Vec<_> = index_entries
.iter()
.filter(|e| {
e.kind == FileKind::Add
&& e.index_file.index_type == LUMINA_VECTOR_ANN_IDENTIFIER
&& e.index_file
.global_index_meta
.as_ref()
.is_some_and(|m| m.index_field_id == field_id)
})
.collect();
if lumina_entries.is_empty() {
return Ok(Vec::new());
}
let futures: Vec<_> = lumina_entries
.into_iter()
.map(|entry| {
let global_meta = entry.index_file.global_index_meta.as_ref().unwrap();
let path = format!("{table_path}/{INDEX_DIR}/{}", entry.index_file.file_name);
let file_name = entry.index_file.file_name.clone();
let file_size = entry.index_file.file_size as u64;
let index_meta_bytes = global_meta.index_meta.clone().unwrap_or_default();
let row_range_start = global_meta.row_range_start;
let vector_search_clone = vector_search.clone();
let options = table_options.clone();
let input = file_io.new_input(&path);
async move {
let input = input?;
let bytes = input.read().await.map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to read Lumina index file '{}': {}", file_name, e),
source: None,
})?;
let io_meta =
GlobalIndexIOMeta::new(file_name.clone(), file_size, index_meta_bytes);
let mut reader = LuminaVectorGlobalIndexReader::new(io_meta, options);
let data = bytes.to_vec();
let result =
reader.visit_vector_search(&vector_search_clone, |_| Ok(Cursor::new(data)))?;
match result {
Some(scored_map) => Ok::<_, crate::Error>(
SearchResult::from_scored_map(scored_map).offset(row_range_start),
),
None => Ok(SearchResult::empty()),
}
}
})
.collect();
let results = futures::future::try_join_all(futures).await?;
let mut merged = SearchResult::empty();
for r in &results {
merged = merged.or(r);
}
merged.top_k(vector_search.limit).to_row_ranges()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{DataType, GlobalIndexMeta, IndexFileMeta, IndexManifestEntry, IntType};
fn make_field(id: i32, name: &str) -> DataField {
DataField::new(id, name.to_string(), DataType::Int(IntType::default()))
}
#[test]
fn test_find_field_id_by_name() {
let fields = vec![make_field(1, "id"), make_field(2, "embedding")];
assert_eq!(find_field_id_by_name(&fields, "embedding"), Some(2));
assert_eq!(find_field_id_by_name(&fields, "nonexistent"), None);
}
#[tokio::test]
async fn test_evaluate_no_matching_entries() {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(1, "id"), make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0, 2.0], 10, "embedding".to_string()).unwrap();
let entry = IndexManifestEntry {
kind: FileKind::Add,
partition: vec![],
bucket: 0,
index_file: IndexFileMeta {
index_type: "btree".to_string(),
file_name: "test.idx".to_string(),
file_size: 100,
row_count: 10,
deletion_vectors_ranges: None,
global_index_meta: None,
},
version: 1,
};
let result = evaluate_vector_search(
&file_io,
"memory:///test_table",
&HashMap::new(),
&[entry],
&vs,
&fields,
)
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn test_evaluate_no_matching_field() {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(1, "id")];
let vs = VectorSearch::new(vec![1.0], 10, "embedding".to_string()).unwrap();
let entry = IndexManifestEntry {
kind: FileKind::Add,
partition: vec![],
bucket: 0,
index_file: IndexFileMeta {
index_type: LUMINA_VECTOR_ANN_IDENTIFIER.to_string(),
file_name: "test.idx".to_string(),
file_size: 100,
row_count: 10,
deletion_vectors_ranges: None,
global_index_meta: Some(GlobalIndexMeta {
row_range_start: 0,
row_range_end: 9,
index_field_id: 99,
extra_field_ids: None,
index_meta: None,
}),
},
version: 1,
};
let result = evaluate_vector_search(
&file_io,
"memory:///test_table",
&HashMap::new(),
&[entry],
&vs,
&fields,
)
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn test_evaluate_skips_delete_entries() {
let file_io = crate::io::FileIOBuilder::new("memory").build().unwrap();
let fields = vec![make_field(2, "embedding")];
let vs = VectorSearch::new(vec![1.0], 10, "embedding".to_string()).unwrap();
let entry = IndexManifestEntry {
kind: FileKind::Delete,
partition: vec![],
bucket: 0,
index_file: IndexFileMeta {
index_type: LUMINA_VECTOR_ANN_IDENTIFIER.to_string(),
file_name: "test.idx".to_string(),
file_size: 100,
row_count: 10,
deletion_vectors_ranges: None,
global_index_meta: Some(GlobalIndexMeta {
row_range_start: 0,
row_range_end: 9,
index_field_id: 2,
extra_field_ids: None,
index_meta: None,
}),
},
version: 1,
};
let result = evaluate_vector_search(
&file_io,
"memory:///test_table",
&HashMap::new(),
&[entry],
&vs,
&fields,
)
.await
.unwrap();
assert!(result.is_empty());
}
}