use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use arrow_array::{Array as _, RecordBatch, UInt32Array, UInt64Array};
use arrow_schema::{DataType, Schema, SchemaRef};
use arrow_select::concat::concat_batches;
use arrow_select::take::take_record_batch;
use futures::{Stream, TryStreamExt as _};
use uuid::Uuid;
use super::VectorIndex;
use crate::api::tables::StorageBinding as IssuedBinding;
use crate::api::vector_indexes::{
DistanceMetric, ReadVersionResponse, StorageAccessRequest, StorageMode,
};
use crate::scandal::api::{VectorIndexArtifact, VectorIndexOrigin, VectorMetric, VectorSearchInfo};
use crate::scandal::vector::{DISTANCE_COLUMN, INFO_KEY, RANK_COLUMN, ROW_ID_COLUMN};
use crate::scandal::{Evaluation, StorageBinding};
use crate::{Error, Expression, ScanOptions, Table};
const MAX_PAGE_BYTES: usize = 64 << 20;
#[derive(Clone, Debug)]
pub struct VectorSearchOptions {
pub limit: u32,
pub probes: Option<u32>,
pub rerank: Option<u32>,
pub filter: Option<Expression>,
pub exclude_row_ids: Vec<u64>,
}
impl Default for VectorSearchOptions {
fn default() -> Self {
Self {
limit: 20,
probes: None,
rerank: None,
filter: None,
exclude_row_ids: Vec::new(),
}
}
}
#[derive(Clone)]
pub struct VectorSearch {
source: Table,
query: Arc<[f64]>,
options: VectorSearchOptions,
info: VectorSearchInfo,
neighbors: RecordBatch,
}
impl VectorIndex {
pub async fn search(
&self,
source: &Table,
query: Vec<f64>,
options: VectorSearchOptions,
) -> Result<VectorSearch, Error> {
if !Arc::ptr_eq(&source.connection, &self.project.client.connection) {
return Err(Error::invalid(
"The index and source must use the same client",
));
}
let client = self.project.client.vector_indexes();
let read = client
.read_version(self.resource_id, self.version_id)
.await?;
let artifact = self.artifact(read, source)?;
let binding = client
.storage_binding(
self.resource_id,
StorageAccessRequest {
version_id: self.version_id,
mode: StorageMode::ReadOnly,
},
)
.await?;
if binding.mode != StorageMode::ReadOnly {
return Err(protocol(
"Vector Index storage grant has the wrong access mode",
));
}
let issued: IssuedBinding = serde_json::to_value(binding)
.and_then(serde_json::from_value)
.map_err(|error| protocol(format!("Invalid Vector Index storage binding: {error}")))?;
self.project
.client
.connection
.tables()?
.search_vectors(
source.clone(),
artifact,
StorageBinding::for_resource(issued)?,
query,
options,
)
.await
}
fn artifact(
&self,
read: ReadVersionResponse,
source: &Table,
) -> Result<VectorIndexArtifact, Error> {
let definition = read.definition;
let version = read.version;
if version.vector_index_id != self.resource_id || version.version_id != self.version_id {
return Err(protocol("Vector Index response changed its exact identity"));
}
let source_id = definition
.source_resource_id
.parse::<Uuid>()
.map_err(|_| protocol("Vector Index source is not a resource ID"))?;
let (Some(source_version), Some(physical)) = (version.source_version_id, version.physical)
else {
return Err(Error::invalid("Refresh the Vector Index before search"));
};
let source_version = source_version
.parse::<Uuid>()
.map_err(|_| protocol("Vector Index source version is not a UUID"))?;
if source_id != source.identity().resource_id
|| definition.source_table_id != source.identity().resource_id
|| source_version != source.identity().version_id
{
return Err(Error::invalid(
"The source does not match the exact Vector Index version",
));
}
let metric = match definition.metric {
DistanceMetric::Cosine => VectorMetric::Cosine,
DistanceMetric::Euclidean => VectorMetric::Euclidean,
DistanceMetric::Dot => VectorMetric::Dot,
DistanceMetric::Unknown(_) => {
return Err(Error::invalid(
"The Vector Index uses an unsupported metric",
));
}
};
Ok(VectorIndexArtifact {
origin: VectorIndexOrigin {
vector_index_id: self.resource_id,
version_id: self.version_id,
source_resource_id: source_id,
source_version_id: source_version,
source_field_id: definition.source_field_id,
},
dimensions: definition.dimensions,
metric,
codec: physical.codec,
codec_version: physical.codec_version,
manifest_path: physical.manifest_path,
indexed_rows: physical.indexed_rows,
partitions: physical.partitions,
metadata: physical.metadata.0,
})
}
}
impl VectorSearch {
pub(crate) async fn read(
source: Table,
query: Vec<f64>,
options: VectorSearchOptions,
artifact: &VectorIndexArtifact,
evaluation: Evaluation,
) -> Result<Self, Error> {
let schema = evaluation.schema;
let expected = [
(ROW_ID_COLUMN, DataType::UInt64),
(DISTANCE_COLUMN, DataType::Float64),
(RANK_COLUMN, DataType::UInt32),
];
if schema.fields().len() != expected.len()
|| schema
.fields()
.iter()
.zip(&expected)
.any(|(field, (name, dtype))| {
field.name() != name || field.data_type() != dtype || field.is_nullable()
})
{
return Err(protocol(
"Vector search returned an invalid neighbor schema",
));
}
let info: VectorSearchInfo = serde_json::from_str(
schema
.metadata()
.get(INFO_KEY)
.ok_or_else(|| protocol("Vector search omitted its provenance"))?,
)
.map_err(|error| protocol(format!("Invalid vector search provenance: {error}")))?;
if info.origin != artifact.origin
|| info.limit != options.limit
|| info.metric != artifact.metric
{
return Err(protocol("Vector search changed its exact query provenance"));
}
let neighbors = collect(schema, evaluation.batches, options.limit as usize).await?;
Ok(Self {
source,
query: query.into(),
options,
info,
neighbors,
})
}
pub fn source(&self) -> &Table {
&self.source
}
pub fn query(&self) -> &[f64] {
&self.query
}
pub fn options(&self) -> &VectorSearchOptions {
&self.options
}
pub fn info(&self) -> &VectorSearchInfo {
&self.info
}
pub fn neighbors(&self) -> &RecordBatch {
&self.neighbors
}
pub fn len(&self) -> usize {
self.neighbors.num_rows()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub async fn page(
&self,
rows: Range<usize>,
columns: Vec<String>,
) -> Result<RecordBatch, Error> {
if rows.start > rows.end || rows.end > self.len() {
return Err(Error::invalid(
"The page range exceeds the fixed vector results",
));
}
let page = self.neighbors.slice(rows.start, rows.end - rows.start);
if columns.is_empty() {
return Ok(page);
}
if columns
.iter()
.any(|name| page.schema().column_with_name(name).is_some())
{
return Err(Error::invalid(
"Page columns use a reserved vector result name",
));
}
let ids = row_ids(&page)?;
let scan = self
.source
.scan(ScanOptions {
columns: Some(columns),
row_id_column: Some(ROW_ID_COLUMN.into()),
row_ids: Some(row_ranges(ids.values())?),
limit: Some(page.num_rows() as u64),
batch_rows: Some(256),
..Default::default()
})
.await?;
let metadata = collect(scan.schema(), scan.into_stream(), page.num_rows()).await?;
let positions = row_ids(&metadata)?
.values()
.iter()
.enumerate()
.map(|(position, id)| (*id, position as u32))
.collect::<HashMap<_, _>>();
let indices = ids
.values()
.iter()
.map(|id| {
positions
.get(id)
.copied()
.ok_or_else(|| protocol("The source page omitted a neighbor RowID"))
})
.collect::<Result<Vec<_>, _>>()?;
let metadata =
take_record_batch(&metadata, &UInt32Array::from(indices)).map_err(Error::arrow)?;
let mut fields = page.schema().fields().iter().cloned().collect::<Vec<_>>();
let mut arrays = page.columns().to_vec();
for (field, array) in metadata.schema().fields().iter().zip(metadata.columns()) {
if field.name() != ROW_ID_COLUMN {
fields.push(Arc::clone(field));
arrays.push(Arc::clone(array));
}
}
RecordBatch::try_new(
Arc::new(Schema::new_with_metadata(
fields,
page.schema().metadata().clone(),
)),
arrays,
)
.map_err(Error::arrow)
}
}
fn row_ids(batch: &RecordBatch) -> Result<&UInt64Array, Error> {
batch
.column_by_name(ROW_ID_COLUMN)
.and_then(|array| array.as_any().downcast_ref())
.ok_or_else(|| protocol("Vector results omitted their stable RowIDs"))
}
fn row_ranges(ids: &[u64]) -> Result<Vec<Range<u64>>, Error> {
let mut ids = ids.to_vec();
ids.sort_unstable();
let mut ranges: Vec<Range<u64>> = Vec::new();
for id in ids {
let end = id
.checked_add(1)
.ok_or_else(|| protocol("A neighbor RowID exceeds the supported range"))?;
match ranges.last_mut() {
Some(range) if range.end == id => range.end = end,
_ => ranges.push(id..end),
}
}
Ok(ranges)
}
async fn collect<S, E>(
schema: SchemaRef,
mut batches: S,
limit: usize,
) -> Result<RecordBatch, Error>
where
S: Stream<Item = Result<RecordBatch, E>> + Unpin,
E: Into<Error>,
{
let mut rows = 0usize;
let mut bytes = 0usize;
let mut collected = Vec::new();
while let Some(batch) = batches.try_next().await.map_err(Into::into)? {
rows = rows.saturating_add(batch.num_rows());
bytes = bytes.saturating_add(batch.get_array_memory_size());
if rows > limit {
return Err(protocol("Vector results exceeded their row limit"));
}
if bytes > MAX_PAGE_BYTES {
return Err(Error::invalid("The selected page exceeds its byte limit"));
}
collected.push(batch);
}
concat_batches(&schema, &collected).map_err(Error::arrow)
}
fn protocol(message: impl Into<String>) -> Error {
Error::Protocol(message.into())
}
#[cfg(test)]
mod tests;