blob: 694dd0b8f6e73cdb78f969e705547075009e430d [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.
#![allow(clippy::useless_conversion)]
use numpy::{
PyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2, PyUntypedArrayMethods,
};
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::io::{write_index, IVFPQIndexReader, SeekRead};
use paimon_vindex_core::ivfpq::{
search_batch_reader, search_batch_reader_roaring_filter, IVFPQIndex,
};
use pyo3::exceptions::{PyIOError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyBytes};
use std::io;
/// Python file object wrapper implementing SeekRead.
struct PyFileStream {
file: PyObject,
}
impl SeekRead for PyFileStream {
fn seek(&mut self, pos: u64) -> io::Result<()> {
Python::with_gil(|py| {
self.file
.call_method1(py, "seek", (pos as i64,))
.map_err(|e| io::Error::other(format!("seek: {}", e)))?;
Ok(())
})
}
fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
Python::with_gil(|py| {
let result = self
.file
.call_method1(py, "read", (buf.len(),))
.map_err(|e| io::Error::other(format!("read: {}", e)))?;
let bytes: &Bound<PyBytes> = result
.downcast_bound(py)
.map_err(|e| io::Error::other(format!("downcast: {}", e)))?;
let data = bytes.as_bytes();
if data.len() != buf.len() {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!("read {} of {} bytes", data.len(), buf.len()),
));
}
buf.copy_from_slice(data);
Ok(())
})
}
}
/// Python file object wrapper implementing SeekWrite.
struct PyOutputStream {
file: PyObject,
pos: u64,
}
impl paimon_vindex_core::io::SeekWrite for PyOutputStream {
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
Python::with_gil(|py| {
let bytes = PyBytes::new_bound(py, buf);
let written = self
.file
.call_method1(py, "write", (bytes,))
.map_err(|e| io::Error::other(format!("write: {}", e)))?
.extract::<usize>(py)
.map_err(|e| io::Error::other(format!("write return value: {}", e)))?;
if written != buf.len() {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
format!("write accepted {} of {} bytes", written, buf.len()),
));
}
self.pos += buf.len() as u64;
Ok(())
})
}
fn pos(&self) -> u64 {
self.pos
}
}
fn parse_metric(metric: &str) -> PyResult<MetricType> {
match metric.to_ascii_lowercase().as_str() {
"l2" => Ok(MetricType::L2),
"inner_product" | "ip" => Ok(MetricType::InnerProduct),
"cosine" => Ok(MetricType::Cosine),
_ => Err(PyValueError::new_err(format!(
"unknown metric '{}'; expected 'l2', 'inner_product', or 'cosine'",
metric
))),
}
}
fn validate_positive(value: usize, name: &str) -> PyResult<()> {
if value == 0 {
Err(PyValueError::new_err(format!("{} must be > 0", name)))
} else {
Ok(())
}
}
fn decode_filter_bytes<'a>(
filter_bytes: Option<&'a Bound<'_, PyAny>>,
) -> PyResult<Option<&'a [u8]>> {
if let Some(filter_obj) = filter_bytes {
let bytes: &Bound<PyBytes> = filter_obj
.downcast()
.map_err(|_| PyValueError::new_err("filter_bytes must be bytes"))?;
Ok(Some(bytes.as_bytes()))
} else {
Ok(None)
}
}
fn pyarray2_from_flat<'py, T: numpy::Element + Clone>(
py: Python<'py>,
data: Vec<T>,
rows: usize,
cols: usize,
) -> PyResult<Bound<'py, PyArray2<T>>> {
let matrix = data
.chunks(cols)
.map(|chunk| chunk.to_vec())
.collect::<Vec<_>>();
debug_assert_eq!(matrix.len(), rows);
PyArray::from_vec2_bound(py, &matrix)
.map_err(|e| PyValueError::new_err(format!("reshape batch result: {}", e)))
}
fn validate_matrix_shape(
shape: &[usize],
dimension: usize,
value_name: &str,
dimension_name: &str,
) -> PyResult<usize> {
let row_count = shape[0];
let actual_dimension = shape[1];
if actual_dimension != dimension {
return Err(PyValueError::new_err(format!(
"{} dimension {} != {} {}",
value_name, actual_dimension, dimension_name, dimension
)));
}
if row_count == 0 {
return Err(PyValueError::new_err(format!(
"{} must contain at least one row",
value_name
)));
}
Ok(row_count)
}
#[pyclass]
struct IVFPQReader {
inner: IVFPQIndexReader<PyFileStream>,
}
#[pyclass]
struct IVFPQWriter {
index: Option<IVFPQIndex>,
dimension: usize,
}
#[pymethods]
impl IVFPQReader {
#[new]
fn new(file: PyObject) -> PyResult<Self> {
let stream = PyFileStream { file };
let reader = IVFPQIndexReader::open(stream)
.map_err(|e| PyIOError::new_err(format!("Failed to open index: {}", e)))?;
Ok(IVFPQReader { inner: reader })
}
#[getter]
fn dimension(&self) -> usize {
self.inner.d
}
#[getter]
fn nlist(&self) -> usize {
self.inner.nlist
}
#[getter]
fn m(&self) -> usize {
self.inner.m
}
#[getter]
fn total_vectors(&self) -> i64 {
self.inner.total_vectors
}
#[allow(clippy::type_complexity)]
#[pyo3(signature = (query, top_k, nprobe, filter_bytes=None))]
fn search<'py>(
&mut self,
py: Python<'py>,
query: PyReadonlyArray1<f32>,
top_k: usize,
nprobe: usize,
filter_bytes: Option<&Bound<'_, PyAny>>,
) -> PyResult<(Bound<'py, PyArray1<i64>>, Bound<'py, PyArray1<f32>>)> {
let query_slice = query.as_slice()?;
if query_slice.len() != self.inner.d {
return Err(PyValueError::new_err(format!(
"query length {} != index dimension {}",
query_slice.len(),
self.inner.d
)));
}
validate_positive(top_k, "top_k")?;
validate_positive(nprobe, "nprobe")?;
let (ids, dists) = if let Some(bytes) = decode_filter_bytes(filter_bytes)? {
self.inner
.search_with_roaring_filter(query_slice, top_k, nprobe, bytes)
.map_err(|e| PyIOError::new_err(format!("Search failed: {}", e)))?
} else {
self.inner
.search(query_slice, top_k, nprobe)
.map_err(|e| PyIOError::new_err(format!("Search failed: {}", e)))?
};
let id_array = PyArray1::from_vec_bound(py, ids);
let dist_array = PyArray1::from_vec_bound(py, dists);
Ok((id_array, dist_array))
}
#[allow(clippy::type_complexity)]
#[pyo3(signature = (queries, top_k, nprobe, filter_bytes=None))]
fn search_batch<'py>(
&mut self,
py: Python<'py>,
queries: PyReadonlyArray2<f32>,
top_k: usize,
nprobe: usize,
filter_bytes: Option<&Bound<'_, PyAny>>,
) -> PyResult<(Bound<'py, PyArray2<i64>>, Bound<'py, PyArray2<f32>>)> {
let shape = queries.shape();
let query_count = validate_matrix_shape(shape, self.inner.d, "query", "index dimension")?;
validate_positive(top_k, "top_k")?;
validate_positive(nprobe, "nprobe")?;
let query_slice = queries.as_slice().map_err(|_| {
PyValueError::new_err("queries must be a contiguous two-dimensional float32 array")
})?;
let (ids, dists) = if let Some(bytes) = decode_filter_bytes(filter_bytes)? {
search_batch_reader_roaring_filter(
&mut self.inner,
query_slice,
query_count,
top_k,
nprobe,
bytes,
)
.map_err(|e| PyIOError::new_err(format!("Batch search failed: {}", e)))?
} else {
search_batch_reader(&mut self.inner, query_slice, query_count, top_k, nprobe)
.map_err(|e| PyIOError::new_err(format!("Batch search failed: {}", e)))?
};
Ok((
pyarray2_from_flat(py, ids, query_count, top_k)?,
pyarray2_from_flat(py, dists, query_count, top_k)?,
))
}
fn close(&mut self) -> PyResult<()> {
Ok(())
}
fn __enter__(slf: Py<Self>) -> Py<Self> {
slf
}
#[pyo3(signature = (_exc_type=None, _exc_val=None, _exc_tb=None))]
fn __exit__(
&mut self,
_exc_type: Option<&Bound<'_, pyo3::types::PyType>>,
_exc_val: Option<&Bound<'_, pyo3::types::PyAny>>,
_exc_tb: Option<&Bound<'_, pyo3::types::PyAny>>,
) -> PyResult<bool> {
self.close()?;
Ok(false)
}
}
#[pymethods]
impl IVFPQWriter {
#[new]
#[pyo3(signature = (dimension, nlist, m, metric="l2", use_opq=false))]
fn new(
dimension: usize,
nlist: usize,
m: usize,
metric: &str,
use_opq: bool,
) -> PyResult<Self> {
validate_positive(dimension, "dimension")?;
validate_positive(nlist, "nlist")?;
validate_positive(m, "m")?;
if !dimension.is_multiple_of(m) {
return Err(PyValueError::new_err(format!(
"dimension {} must be divisible by m {}",
dimension, m
)));
}
let metric = parse_metric(metric)?;
Ok(IVFPQWriter {
index: Some(IVFPQIndex::new(dimension, nlist, m, metric, use_opq)),
dimension,
})
}
#[getter]
fn dimension(&self) -> usize {
self.dimension
}
fn train(&mut self, data: PyReadonlyArray2<f32>) -> PyResult<()> {
let shape = data.shape();
let row_count = validate_matrix_shape(shape, self.dimension, "data", "writer dimension")?;
let data_slice = data.as_slice().map_err(|_| {
PyValueError::new_err("data must be a contiguous two-dimensional float32 array")
})?;
self.index_mut()?.train(data_slice, row_count);
Ok(())
}
fn add_vectors(
&mut self,
ids: PyReadonlyArray1<i64>,
data: PyReadonlyArray2<f32>,
) -> PyResult<()> {
let shape = data.shape();
let row_count = validate_matrix_shape(shape, self.dimension, "data", "writer dimension")?;
let id_slice = ids.as_slice()?;
if id_slice.len() != row_count {
return Err(PyValueError::new_err(format!(
"ids length {} != vector count {}",
id_slice.len(),
row_count
)));
}
let data_slice = data.as_slice().map_err(|_| {
PyValueError::new_err("data must be a contiguous two-dimensional float32 array")
})?;
self.index_mut()?.add(data_slice, id_slice, row_count);
Ok(())
}
fn write(&mut self, file: PyObject) -> PyResult<()> {
let mut stream = PyOutputStream { file, pos: 0 };
write_index(self.index_ref()?, &mut stream)
.map_err(|e| PyIOError::new_err(format!("Failed to write index: {}", e)))?;
Ok(())
}
fn close(&mut self) -> PyResult<()> {
self.index = None;
Ok(())
}
fn __enter__(slf: Py<Self>) -> Py<Self> {
slf
}
#[pyo3(signature = (_exc_type=None, _exc_val=None, _exc_tb=None))]
fn __exit__(
&mut self,
_exc_type: Option<&Bound<'_, pyo3::types::PyType>>,
_exc_val: Option<&Bound<'_, pyo3::types::PyAny>>,
_exc_tb: Option<&Bound<'_, pyo3::types::PyAny>>,
) -> PyResult<bool> {
self.close()?;
Ok(false)
}
}
impl IVFPQWriter {
fn index_ref(&self) -> PyResult<&IVFPQIndex> {
self.index
.as_ref()
.ok_or_else(|| PyValueError::new_err("IVFPQWriter is closed"))
}
fn index_mut(&mut self) -> PyResult<&mut IVFPQIndex> {
self.index
.as_mut()
.ok_or_else(|| PyValueError::new_err("IVFPQWriter is closed"))
}
}
#[pymodule]
fn paimon_vindex(m: &Bound<'_, pyo3::types::PyModule>) -> PyResult<()> {
m.add_class::<IVFPQReader>()?;
m.add_class::<IVFPQWriter>()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use numpy::{PyArray, PyArrayMethods};
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::io::{write_index, PosWriter};
use paimon_vindex_core::ivfpq::IVFPQIndex;
use pyo3::types::PyBytes;
use roaring::RoaringTreemap;
fn generate_clustered_data(n: usize, d: usize, clusters: usize) -> Vec<f32> {
let mut data = vec![0.0; n * d];
for i in 0..n {
let cluster = i % clusters;
for j in 0..d {
data[i * d + j] = cluster as f32 * 10.0 + j as f32 * 0.01 + i as f32 * 0.0001;
}
}
data
}
fn build_test_index_bytes() -> Vec<u8> {
let d = 16;
let nlist = 4;
let m = 4;
let n = 500;
let data = generate_clustered_data(n, d, 4);
let ids: Vec<i64> = (0..n as i64).collect();
let mut index = IVFPQIndex::new(d, nlist, m, MetricType::L2, false);
index.train(&data, n);
index.add(&data, &ids, n);
let mut buf = Vec::new();
let mut writer = PosWriter::new(&mut buf);
write_index(&index, &mut writer).unwrap();
buf
}
#[test]
fn python_batch_search_returns_2d_numpy_arrays() {
Python::with_gil(|py| {
let io = py.import_bound("io").unwrap();
let file = io
.getattr("BytesIO")
.unwrap()
.call1((PyBytes::new_bound(py, &build_test_index_bytes()),))
.unwrap();
let mut reader = IVFPQReader::new(file.unbind()).unwrap();
let queries = generate_clustered_data(3, reader.dimension(), 4);
let query_array = PyArray::from_vec2_bound(
py,
&queries
.chunks(reader.dimension())
.map(|chunk| chunk.to_vec())
.collect::<Vec<_>>(),
)
.unwrap();
let (ids, dists) = reader
.search_batch(py, query_array.readonly(), 5, 2, None)
.unwrap();
assert_eq!(ids.shape(), &[3, 5]);
assert_eq!(dists.shape(), &[3, 5]);
assert_eq!(ids.readonly().as_slice().unwrap()[0], 0);
});
}
#[test]
fn python_batch_search_accepts_roaring_filter_bytes() {
Python::with_gil(|py| {
let io = py.import_bound("io").unwrap();
let file = io
.getattr("BytesIO")
.unwrap()
.call1((PyBytes::new_bound(py, &build_test_index_bytes()),))
.unwrap();
let mut reader = IVFPQReader::new(file.unbind()).unwrap();
let queries = generate_clustered_data(3, reader.dimension(), 4);
let query_array = PyArray::from_vec2_bound(
py,
&queries
.chunks(reader.dimension())
.map(|chunk| chunk.to_vec())
.collect::<Vec<_>>(),
)
.unwrap();
let mut allowed = RoaringTreemap::new();
for id in (0..500u64).filter(|id| id % 7 == 0) {
allowed.insert(id);
}
let mut filter_bytes = Vec::new();
allowed.serialize_into(&mut filter_bytes).unwrap();
let filter = PyBytes::new_bound(py, &filter_bytes);
let (ids, _) = reader
.search_batch(py, query_array.readonly(), 5, 2, Some(filter.as_any()))
.unwrap();
assert_eq!(ids.shape(), &[3, 5]);
for &id in ids.readonly().as_slice().unwrap() {
if id >= 0 {
assert_eq!(id % 7, 0);
}
}
});
}
#[test]
fn python_writer_can_build_an_index_for_reader() {
Python::with_gil(|py| {
let io = py.import_bound("io").unwrap();
let output = io.getattr("BytesIO").unwrap().call0().unwrap();
let mut writer = IVFPQWriter::new(16, 4, 4, "l2", false).unwrap();
let data = generate_clustered_data(500, 16, 4);
let ids: Vec<i64> = (0..500).collect();
let train = PyArray::from_vec2_bound(
py,
&data
.chunks(16)
.map(|chunk| chunk.to_vec())
.collect::<Vec<_>>(),
)
.unwrap();
let id_array = PyArray1::from_vec_bound(py, ids);
writer.train(train.readonly()).unwrap();
writer
.add_vectors(id_array.readonly(), train.readonly())
.unwrap();
writer.write(output.as_any().clone().unbind()).unwrap();
output.call_method1("seek", (0,)).unwrap();
let mut reader = IVFPQReader::new(output.unbind()).unwrap();
let query = PyArray1::from_vec_bound(py, data[0..16].to_vec());
let (result_ids, _) = reader.search(py, query.readonly(), 5, 2, None).unwrap();
assert_eq!(result_ids.len(), 5);
assert_eq!(result_ids.readonly().as_slice().unwrap()[0], 0);
});
}
#[test]
fn python_batch_search_validates_query_shape() {
Python::with_gil(|py| {
let io = py.import_bound("io").unwrap();
let file = io
.getattr("BytesIO")
.unwrap()
.call1((PyBytes::new_bound(py, &build_test_index_bytes()),))
.unwrap();
let mut reader = IVFPQReader::new(file.unbind()).unwrap();
let wrong_dim = PyArray::from_vec2_bound(py, &[vec![0.0f32; 15]]).unwrap();
let err = reader
.search_batch(py, wrong_dim.readonly(), 5, 2, None)
.unwrap_err();
assert!(err
.to_string()
.contains("query dimension 15 != index dimension 16"));
});
}
#[test]
fn python_writer_rejects_short_writes() {
Python::with_gil(|py| {
let locals = pyo3::types::PyDict::new_bound(py);
py.run_bound(
r#"
class ShortWriter:
def write(self, data):
return max(0, len(data) - 1)
"#,
None,
Some(&locals),
)
.unwrap();
let output = locals
.get_item("ShortWriter")
.unwrap()
.unwrap()
.call0()
.unwrap();
let mut writer = IVFPQWriter::new(16, 4, 4, "l2", false).unwrap();
let data = generate_clustered_data(500, 16, 4);
let train = PyArray::from_vec2_bound(
py,
&data
.chunks(16)
.map(|chunk| chunk.to_vec())
.collect::<Vec<_>>(),
)
.unwrap();
writer.train(train.readonly()).unwrap();
let err = writer.write(output.unbind()).unwrap_err();
assert!(err.to_string().contains("write accepted"));
});
}
}