use crate::lumina::ffi::LuminaSearcher;
use crate::lumina::{
strip_lumina_options, GlobalIndexIOMeta, LuminaIndexMeta, LuminaVectorMetric, VectorSearch,
};
use std::collections::BinaryHeap;
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::path::PathBuf;
const MIN_SEARCH_LIST_SIZE: usize = 16;
const SENTINEL: u64 = u64::MAX;
fn ensure_search_list_size(search_options: &mut HashMap<String, String>, top_k: usize) {
if !search_options.contains_key("diskann.search.list_size") {
let list_size = std::cmp::max((top_k as f64 * 1.5) as usize, MIN_SEARCH_LIST_SIZE);
search_options.insert(
"diskann.search.list_size".to_string(),
list_size.to_string(),
);
}
}
fn convert_distance_to_score(distance: f32, metric: LuminaVectorMetric) -> f32 {
match metric {
LuminaVectorMetric::L2 => 1.0 / (1.0 + distance),
LuminaVectorMetric::Cosine => 1.0 - distance,
LuminaVectorMetric::InnerProduct => distance,
}
}
fn collect_results(
labels: &[u64],
distances: &[f32],
top_k: usize,
metric: LuminaVectorMetric,
) -> HashMap<u64, f32> {
#[derive(PartialEq)]
struct ScoredRow {
row_id: u64,
score: f32,
}
impl Eq for ScoredRow {}
impl PartialOrd for ScoredRow {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ScoredRow {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.score.total_cmp(&self.score)
}
}
let mut min_heap: BinaryHeap<ScoredRow> = BinaryHeap::with_capacity(top_k + 1);
for (&row_id, &distance) in labels.iter().zip(distances.iter()) {
if row_id == SENTINEL {
continue;
}
let score = convert_distance_to_score(distance, metric);
if min_heap.len() < top_k {
min_heap.push(ScoredRow { row_id, score });
} else if let Some(peek) = min_heap.peek() {
if score > peek.score {
min_heap.pop();
min_heap.push(ScoredRow { row_id, score });
}
}
}
let mut result = HashMap::with_capacity(min_heap.len());
for entry in min_heap {
result.insert(entry.row_id, entry.score);
}
result
}
pub struct LuminaVectorGlobalIndexReader {
io_meta: GlobalIndexIOMeta,
options: HashMap<String, String>,
searcher: Option<LuminaSearcher>,
index_meta: Option<LuminaIndexMeta>,
search_options: Option<HashMap<String, String>>,
local_index_file: Option<PathBuf>,
}
impl LuminaVectorGlobalIndexReader {
pub fn new(io_meta: GlobalIndexIOMeta, options: HashMap<String, String>) -> Self {
Self {
io_meta,
options,
searcher: None,
index_meta: None,
search_options: None,
local_index_file: None,
}
}
pub fn visit_vector_search<S: Read + Seek + Send + 'static>(
&mut self,
vector_search: &VectorSearch,
stream_fn: impl FnOnce(&str) -> crate::Result<S>,
) -> crate::Result<Option<HashMap<u64, f32>>> {
self.ensure_loaded(stream_fn)?;
self.search(vector_search)
}
fn search(&self, vector_search: &VectorSearch) -> crate::Result<Option<HashMap<u64, f32>>> {
let index_meta = self
.index_meta
.as_ref()
.ok_or_else(|| crate::Error::DataInvalid {
message: "index_meta not initialized".to_string(),
source: None,
})?;
let searcher = self
.searcher
.as_ref()
.ok_or_else(|| crate::Error::DataInvalid {
message: "searcher not initialized".to_string(),
source: None,
})?;
let search_options_base =
self.search_options
.as_ref()
.ok_or_else(|| crate::Error::DataInvalid {
message: "search_options not initialized".to_string(),
source: None,
})?;
let expected_dim = index_meta.dim()? as usize;
if vector_search.vector.len() != expected_dim {
return Err(crate::Error::DataInvalid {
message: format!(
"Query vector dimension mismatch: index expects {}, but got {}",
expected_dim,
vector_search.vector.len()
),
source: None,
});
}
let limit = vector_search.limit;
let index_metric = index_meta.metric()?;
let count = searcher.get_count()? as usize;
let effective_k = std::cmp::min(limit, count);
if effective_k == 0 {
return Ok(None);
}
let include_row_ids = &vector_search.include_row_ids;
let (distances, labels) = if let Some(ref include_ids) = include_row_ids {
let filter_id_list: Vec<u64> = include_ids.iter().collect();
if filter_id_list.is_empty() {
return Ok(None);
}
let ek = std::cmp::min(effective_k, filter_id_list.len());
let mut distances = vec![0.0f32; ek];
let mut labels = vec![0u64; ek];
let mut search_opts: HashMap<String, String> = search_options_base.clone();
search_opts.insert("search.thread_safe_filter".to_string(), "true".to_string());
ensure_search_list_size(&mut search_opts, ek);
searcher.search_with_filter(
&vector_search.vector,
1,
ek as i32,
&mut distances,
&mut labels,
&filter_id_list,
&search_opts,
)?;
(distances, labels)
} else {
let mut distances = vec![0.0f32; effective_k];
let mut labels = vec![0u64; effective_k];
let mut search_opts: HashMap<String, String> = search_options_base.clone();
ensure_search_list_size(&mut search_opts, effective_k);
searcher.search(
&vector_search.vector,
1,
effective_k as i32,
&mut distances,
&mut labels,
&search_opts,
)?;
(distances, labels)
};
let id_to_scores = collect_results(&labels, &distances, effective_k, index_metric);
if id_to_scores.is_empty() {
return Ok(None);
}
Ok(Some(id_to_scores))
}
fn ensure_loaded<S: Read + Seek + Send + 'static>(
&mut self,
stream_fn: impl FnOnce(&str) -> crate::Result<S>,
) -> crate::Result<()> {
if self.searcher.is_some() {
return Ok(());
}
let index_meta = LuminaIndexMeta::deserialize(&self.io_meta.metadata)?;
let mut searcher_options = index_meta.options().clone();
for (k, v) in strip_lumina_options(&self.options) {
searcher_options.insert(k, v);
}
let mut searcher = LuminaSearcher::create(&searcher_options)?;
let mut stream = stream_fn(&self.io_meta.file_path)?;
let local_index_file = write_temp_index_file(&mut stream)?;
let local_index_path =
local_index_file
.to_str()
.ok_or_else(|| crate::Error::DataInvalid {
message: format!(
"Temporary Lumina index path is not valid UTF-8: {}",
local_index_file.display()
),
source: None,
})?;
if let Err(err) = searcher.open_file(local_index_path) {
let _ = std::fs::remove_file(&local_index_file);
return Err(err);
}
self.search_options = Some(searcher_options);
self.index_meta = Some(index_meta);
self.searcher = Some(searcher);
self.local_index_file = Some(local_index_file);
Ok(())
}
pub fn close(&mut self) {
self.searcher = None;
self.index_meta = None;
self.search_options = None;
if let Some(path) = self.local_index_file.take() {
let _ = std::fs::remove_file(path);
}
}
}
fn write_temp_index_file<S: Read + Seek>(stream: &mut S) -> crate::Result<PathBuf> {
stream
.seek(SeekFrom::Start(0))
.map_err(|e| crate::Error::UnexpectedError {
message: format!("Failed to seek Lumina index stream to start: {}", e),
source: Some(Box::new(e)),
})?;
let path = std::env::temp_dir().join(format!(
"paimon-lumina-index-{}.index",
uuid::Uuid::new_v4()
));
let mut file = std::fs::File::create(&path).map_err(|e| crate::Error::UnexpectedError {
message: format!(
"Failed to create temporary Lumina index file '{}': {}",
path.display(),
e
),
source: Some(Box::new(e)),
})?;
std::io::copy(stream, &mut file).map_err(|e| crate::Error::UnexpectedError {
message: format!(
"Failed to write temporary Lumina index file '{}': {}",
path.display(),
e
),
source: Some(Box::new(e)),
})?;
file.sync_all().map_err(|e| crate::Error::UnexpectedError {
message: format!(
"Failed to sync temporary Lumina index file '{}': {}",
path.display(),
e
),
source: Some(Box::new(e)),
})?;
Ok(path)
}
impl Drop for LuminaVectorGlobalIndexReader {
fn drop(&mut self) {
self.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lumina::GlobalIndexIOMeta;
use std::io::Cursor;
#[test]
fn test_convert_distance_to_score() {
assert_eq!(convert_distance_to_score(0.0, LuminaVectorMetric::L2), 1.0);
assert_eq!(convert_distance_to_score(1.0, LuminaVectorMetric::L2), 0.5);
assert_eq!(
convert_distance_to_score(0.0, LuminaVectorMetric::Cosine),
1.0
);
assert_eq!(
convert_distance_to_score(1.0, LuminaVectorMetric::Cosine),
0.0
);
assert_eq!(
convert_distance_to_score(0.75, LuminaVectorMetric::InnerProduct),
0.75
);
}
#[test]
fn test_ensure_search_list_size() {
let mut opts = HashMap::new();
ensure_search_list_size(&mut opts, 10);
assert_eq!(opts.get("diskann.search.list_size").unwrap(), "16");
let mut opts = HashMap::new();
ensure_search_list_size(&mut opts, 100);
assert_eq!(opts.get("diskann.search.list_size").unwrap(), "150");
let mut opts = HashMap::new();
opts.insert("diskann.search.list_size".to_string(), "999".to_string());
ensure_search_list_size(&mut opts, 100);
assert_eq!(opts.get("diskann.search.list_size").unwrap(), "999");
}
#[test]
fn test_collect_results() {
let labels = vec![0, 1, 2, SENTINEL, 3];
let distances = vec![0.5, 0.3, 0.1, 0.0, 0.9];
let result = collect_results(&labels, &distances, 2, LuminaVectorMetric::InnerProduct);
assert_eq!(result.len(), 2);
assert!(result.contains_key(&3));
assert!(result.contains_key(&0));
assert!(!result.contains_key(&2)); }
#[test]
fn test_reader_new() {
let m = GlobalIndexIOMeta::new("a".into(), 100, vec![]);
let reader = LuminaVectorGlobalIndexReader::new(m, HashMap::new());
assert!(reader.searcher.is_none());
}
#[test]
fn test_write_temp_index_file_copies_stream() {
let bytes = b"lumina-index-bytes".to_vec();
let mut stream = Cursor::new(bytes.clone());
stream.seek(SeekFrom::End(0)).unwrap();
let path = write_temp_index_file(&mut stream).unwrap();
let actual = std::fs::read(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(actual, bytes);
}
}