blob: 18a1fa70d445756fa5235d49de528fe4360ed14b [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use crate::vector_search::{GlobalIndexIOMeta, VectorSearch};
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::index::{
VectorIndexMetadata, VectorIndexReader as VIndexReader, VectorSearchParams,
};
use std::collections::BinaryHeap;
use std::collections::HashMap;
use std::io::{Cursor, Read, Seek, SeekFrom};
const DEFAULT_NPROBE: usize = 16;
const DEFAULT_EF_SEARCH: usize = 0;
const NPROBE_PARAMETER: &str = "ivf.nprobe";
const EF_SEARCH_PARAMETER: &str = "hnsw.ef_search";
pub struct VindexVectorGlobalIndexReader {
io_meta: GlobalIndexIOMeta,
options: HashMap<String, String>,
reader: Option<VIndexReader<Cursor<Vec<u8>>>>,
metadata: Option<VectorIndexMetadata>,
}
impl VindexVectorGlobalIndexReader {
pub fn new(io_meta: GlobalIndexIOMeta, options: HashMap<String, String>) -> Self {
Self {
io_meta,
options,
reader: None,
metadata: 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(&mut self, vector_search: &VectorSearch) -> crate::Result<Option<HashMap<u64, f32>>> {
let reader = self
.reader
.as_mut()
.ok_or_else(|| crate::Error::DataInvalid {
message: "vindex reader not initialized".to_string(),
source: None,
})?;
let metadata = self
.metadata
.as_ref()
.ok_or_else(|| crate::Error::DataInvalid {
message: "vindex metadata not initialized".to_string(),
source: None,
})?;
search_vindex(reader, metadata, &self.options, vector_search)
}
fn ensure_loaded<S: Read + Seek + Send + 'static>(
&mut self,
stream_fn: impl FnOnce(&str) -> crate::Result<S>,
) -> crate::Result<()> {
if self.reader.is_some() {
return Ok(());
}
let mut stream = stream_fn(&self.io_meta.file_path)?;
stream
.seek(SeekFrom::Start(0))
.map_err(|e| crate::Error::UnexpectedError {
message: format!("Failed to seek vindex stream to start: {}", e),
source: Some(Box::new(e)),
})?;
let mut bytes = Vec::with_capacity(self.io_meta.file_size as usize);
stream
.read_to_end(&mut bytes)
.map_err(|e| crate::Error::UnexpectedError {
message: format!("Failed to read vindex stream: {}", e),
source: Some(Box::new(e)),
})?;
let mut reader =
VIndexReader::open(Cursor::new(bytes)).map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to open paimon-vindex-core reader: {}", e),
source: Some(Box::new(e)),
})?;
let metadata = reader.metadata();
reader
.optimize_for_search()
.map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to optimize paimon-vindex-core reader: {}", e),
source: Some(Box::new(e)),
})?;
self.reader = Some(reader);
self.metadata = Some(metadata);
Ok(())
}
}
fn search_vindex(
reader: &mut VIndexReader<Cursor<Vec<u8>>>,
metadata: &VectorIndexMetadata,
options: &HashMap<String, String>,
vector_search: &VectorSearch,
) -> crate::Result<Option<HashMap<u64, f32>>> {
let expected_dim = metadata.dimension;
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 count = usize::try_from(metadata.total_vectors).unwrap_or(0);
let effective_k = std::cmp::min(vector_search.limit, count);
if effective_k == 0 {
return Ok(None);
}
let params = VectorSearchParams::with_ef_search(
effective_k,
int_parameter(options, NPROBE_PARAMETER, DEFAULT_NPROBE)?,
int_parameter(options, EF_SEARCH_PARAMETER, DEFAULT_EF_SEARCH)?,
);
let (labels, distances) = if let Some(include_ids) = &vector_search.include_row_ids {
if include_ids.is_empty() {
return Ok(None);
}
let ek = std::cmp::min(effective_k, include_ids.len() as usize);
let params = VectorSearchParams::with_ef_search(
params.top_k.min(ek),
params.nprobe,
params.ef_search,
);
let mut filter_bytes = Vec::new();
include_ids
.serialize_into(&mut filter_bytes)
.map_err(|e| crate::Error::DataInvalid {
message: format!("Failed to serialize vector search row-id filter: {}", e),
source: Some(Box::new(e)),
})?;
reader
.search_with_roaring_filter(&vector_search.vector, params, &filter_bytes)
.map_err(|e| crate::Error::DataInvalid {
message: format!("paimon-vindex-core filtered search failed: {}", e),
source: Some(Box::new(e)),
})?
} else {
reader
.search(&vector_search.vector, params)
.map_err(|e| crate::Error::DataInvalid {
message: format!("paimon-vindex-core search failed: {}", e),
source: Some(Box::new(e)),
})?
};
let id_to_scores = collect_results(&labels, &distances, effective_k, metadata.metric);
if id_to_scores.is_empty() {
return Ok(None);
}
Ok(Some(id_to_scores))
}
fn collect_results(
labels: &[i64],
distances: &[f32],
top_k: usize,
metric: MetricType,
) -> 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 < 0 {
continue;
}
let score = convert_distance_to_score(distance, metric);
let row_id = row_id as u64;
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
}
fn convert_distance_to_score(distance: f32, metric: MetricType) -> f32 {
match metric {
MetricType::L2 => 1.0 / (1.0 + distance),
MetricType::Cosine => 1.0 - distance,
MetricType::InnerProduct => -distance,
}
}
fn int_parameter(
options: &HashMap<String, String>,
key: &str,
default_value: usize,
) -> crate::Result<usize> {
match options.get(key) {
Some(value) => value
.parse::<usize>()
.map_err(|_| crate::Error::DataInvalid {
message: format!(
"Invalid value for '{}': {}. Must be a non-negative integer.",
key, value
),
source: None,
}),
None => Ok(default_value),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_distance_to_score() {
assert_eq!(convert_distance_to_score(0.0, MetricType::L2), 1.0);
assert_eq!(convert_distance_to_score(1.0, MetricType::L2), 0.5);
assert_eq!(convert_distance_to_score(0.0, MetricType::Cosine), 1.0);
assert_eq!(convert_distance_to_score(1.0, MetricType::Cosine), 0.0);
assert_eq!(
convert_distance_to_score(-0.75, MetricType::InnerProduct),
0.75
);
}
#[test]
fn test_collect_results_converts_inner_product_distance_to_similarity() {
let labels = vec![9, 5, 1];
let distances = vec![-0.9, -0.5, -0.1];
let result = collect_results(&labels, &distances, 2, MetricType::InnerProduct);
assert_eq!(result.len(), 2);
assert!(result.contains_key(&9), "0.9 similarity should be retained");
assert!(result.contains_key(&5), "0.5 similarity should be retained");
assert!(!result.contains_key(&1), "0.1 similarity should be trimmed");
assert_eq!(result.get(&9), Some(&0.9));
assert_eq!(result.get(&5), Some(&0.5));
}
#[test]
fn test_collect_results_skips_negative_labels() {
let labels = vec![0, -1, 2, 3];
let distances = vec![0.5, 0.0, 0.1, 0.9];
let result = collect_results(&labels, &distances, 2, MetricType::L2);
assert_eq!(result.len(), 2);
assert!(result.contains_key(&2));
assert!(result.contains_key(&0));
assert!(!result.contains_key(&3));
}
#[test]
fn test_int_parameter() {
let mut options = HashMap::new();
options.insert(NPROBE_PARAMETER.to_string(), "32".to_string());
assert_eq!(
int_parameter(&options, NPROBE_PARAMETER, DEFAULT_NPROBE).unwrap(),
32
);
assert_eq!(
int_parameter(&options, EF_SEARCH_PARAMETER, DEFAULT_EF_SEARCH).unwrap(),
DEFAULT_EF_SEARCH
);
options.insert(EF_SEARCH_PARAMETER.to_string(), "abc".to_string());
assert!(int_parameter(&options, EF_SEARCH_PARAMETER, DEFAULT_EF_SEARCH).is_err());
}
}