1use std::collections::HashMap;
4use std::ops::Range;
5use std::sync::Arc;
6
7use arrow_array::{Array as _, RecordBatch, UInt32Array, UInt64Array};
8use arrow_schema::{DataType, Schema, SchemaRef};
9use arrow_select::concat::concat_batches;
10use arrow_select::take::take_record_batch;
11use futures::{Stream, TryStreamExt as _};
12use uuid::Uuid;
13
14use super::VectorIndex;
15use crate::api::tables::StorageBinding as IssuedBinding;
16use crate::api::vector_indexes::{
17 DistanceMetric, ReadVersionResponse, StorageAccessRequest, StorageMode,
18};
19use crate::scandal::api::{VectorIndexArtifact, VectorIndexOrigin, VectorMetric, VectorSearchInfo};
20use crate::scandal::vector::{DISTANCE_COLUMN, INFO_KEY, RANK_COLUMN, ROW_ID_COLUMN};
21use crate::scandal::{Evaluation, StorageBinding};
22use crate::{Error, Expression, ScanOptions, Table};
23
24const MAX_PAGE_BYTES: usize = 64 << 20;
25
26#[derive(Clone, Debug)]
28pub struct VectorSearchOptions {
29 pub limit: u32,
31 pub probes: Option<u32>,
33 pub rerank: Option<u32>,
35 pub filter: Option<Expression>,
37 pub exclude_row_ids: Vec<u64>,
39}
40
41impl Default for VectorSearchOptions {
42 fn default() -> Self {
43 Self {
44 limit: 20,
45 probes: None,
46 rerank: None,
47 filter: None,
48 exclude_row_ids: Vec::new(),
49 }
50 }
51}
52
53#[derive(Clone)]
56pub struct VectorSearch {
57 source: Table,
58 query: Arc<[f64]>,
59 options: VectorSearchOptions,
60 info: VectorSearchInfo,
61 neighbors: RecordBatch,
62}
63
64impl VectorIndex {
65 pub async fn search(
68 &self,
69 source: &Table,
70 query: Vec<f64>,
71 options: VectorSearchOptions,
72 ) -> Result<VectorSearch, Error> {
73 if !Arc::ptr_eq(&source.connection, &self.project.client.connection) {
74 return Err(Error::invalid(
75 "The index and source must use the same client",
76 ));
77 }
78 let client = self.project.client.vector_indexes();
79 let read = client
80 .read_version(self.resource_id, self.version_id)
81 .await?;
82 let artifact = self.artifact(read, source)?;
83 let binding = client
84 .storage_binding(
85 self.resource_id,
86 StorageAccessRequest {
87 version_id: self.version_id,
88 mode: StorageMode::ReadOnly,
89 },
90 )
91 .await?;
92 if binding.mode != StorageMode::ReadOnly {
93 return Err(protocol(
94 "Vector Index storage grant has the wrong access mode",
95 ));
96 }
97 let issued: IssuedBinding = serde_json::to_value(binding)
98 .and_then(serde_json::from_value)
99 .map_err(|error| protocol(format!("Invalid Vector Index storage binding: {error}")))?;
100 self.project
101 .client
102 .connection
103 .tables()?
104 .search_vectors(
105 source.clone(),
106 artifact,
107 StorageBinding::for_resource(issued)?,
108 query,
109 options,
110 )
111 .await
112 }
113
114 fn artifact(
115 &self,
116 read: ReadVersionResponse,
117 source: &Table,
118 ) -> Result<VectorIndexArtifact, Error> {
119 let definition = read.definition;
120 let version = read.version;
121 if version.vector_index_id != self.resource_id || version.version_id != self.version_id {
122 return Err(protocol("Vector Index response changed its exact identity"));
123 }
124 let source_id = definition
125 .source_resource_id
126 .parse::<Uuid>()
127 .map_err(|_| protocol("Vector Index source is not a resource ID"))?;
128 let (Some(source_version), Some(physical)) = (version.source_version_id, version.physical)
129 else {
130 return Err(Error::invalid("Refresh the Vector Index before search"));
131 };
132 let source_version = source_version
133 .parse::<Uuid>()
134 .map_err(|_| protocol("Vector Index source version is not a UUID"))?;
135 if source_id != source.identity().resource_id
136 || definition.source_table_id != source.identity().resource_id
137 || source_version != source.identity().version_id
138 {
139 return Err(Error::invalid(
140 "The source does not match the exact Vector Index version",
141 ));
142 }
143 let metric = match definition.metric {
144 DistanceMetric::Cosine => VectorMetric::Cosine,
145 DistanceMetric::Euclidean => VectorMetric::Euclidean,
146 DistanceMetric::Dot => VectorMetric::Dot,
147 DistanceMetric::Unknown(_) => {
148 return Err(Error::invalid(
149 "The Vector Index uses an unsupported metric",
150 ));
151 }
152 };
153 Ok(VectorIndexArtifact {
154 origin: VectorIndexOrigin {
155 vector_index_id: self.resource_id,
156 version_id: self.version_id,
157 source_resource_id: source_id,
158 source_version_id: source_version,
159 source_field_id: definition.source_field_id,
160 },
161 dimensions: definition.dimensions,
162 metric,
163 codec: physical.codec,
164 codec_version: physical.codec_version,
165 manifest_path: physical.manifest_path,
166 indexed_rows: physical.indexed_rows,
167 partitions: physical.partitions,
168 metadata: physical.metadata.0,
169 })
170 }
171}
172
173impl VectorSearch {
174 pub(crate) async fn read(
175 source: Table,
176 query: Vec<f64>,
177 options: VectorSearchOptions,
178 artifact: &VectorIndexArtifact,
179 evaluation: Evaluation,
180 ) -> Result<Self, Error> {
181 let schema = evaluation.schema;
182 let expected = [
183 (ROW_ID_COLUMN, DataType::UInt64),
184 (DISTANCE_COLUMN, DataType::Float64),
185 (RANK_COLUMN, DataType::UInt32),
186 ];
187 if schema.fields().len() != expected.len()
188 || schema
189 .fields()
190 .iter()
191 .zip(&expected)
192 .any(|(field, (name, dtype))| {
193 field.name() != name || field.data_type() != dtype || field.is_nullable()
194 })
195 {
196 return Err(protocol(
197 "Vector search returned an invalid neighbor schema",
198 ));
199 }
200 let info: VectorSearchInfo = serde_json::from_str(
201 schema
202 .metadata()
203 .get(INFO_KEY)
204 .ok_or_else(|| protocol("Vector search omitted its provenance"))?,
205 )
206 .map_err(|error| protocol(format!("Invalid vector search provenance: {error}")))?;
207 if info.origin != artifact.origin
208 || info.limit != options.limit
209 || info.metric != artifact.metric
210 {
211 return Err(protocol("Vector search changed its exact query provenance"));
212 }
213 let neighbors = collect(schema, evaluation.batches, options.limit as usize).await?;
214 Ok(Self {
215 source,
216 query: query.into(),
217 options,
218 info,
219 neighbors,
220 })
221 }
222
223 pub fn source(&self) -> &Table {
225 &self.source
226 }
227
228 pub fn query(&self) -> &[f64] {
230 &self.query
231 }
232
233 pub fn options(&self) -> &VectorSearchOptions {
235 &self.options
236 }
237
238 pub fn info(&self) -> &VectorSearchInfo {
240 &self.info
241 }
242
243 pub fn neighbors(&self) -> &RecordBatch {
245 &self.neighbors
246 }
247
248 pub fn len(&self) -> usize {
250 self.neighbors.num_rows()
251 }
252
253 pub fn is_empty(&self) -> bool {
255 self.len() == 0
256 }
257
258 pub async fn page(
261 &self,
262 rows: Range<usize>,
263 columns: Vec<String>,
264 ) -> Result<RecordBatch, Error> {
265 if rows.start > rows.end || rows.end > self.len() {
266 return Err(Error::invalid(
267 "The page range exceeds the fixed vector results",
268 ));
269 }
270 let page = self.neighbors.slice(rows.start, rows.end - rows.start);
271 if columns.is_empty() {
272 return Ok(page);
273 }
274 if columns
275 .iter()
276 .any(|name| page.schema().column_with_name(name).is_some())
277 {
278 return Err(Error::invalid(
279 "Page columns use a reserved vector result name",
280 ));
281 }
282 let ids = row_ids(&page)?;
283 let scan = self
284 .source
285 .scan(ScanOptions {
286 columns: Some(columns),
287 row_id_column: Some(ROW_ID_COLUMN.into()),
288 row_ids: Some(row_ranges(ids.values())?),
289 limit: Some(page.num_rows() as u64),
290 batch_rows: Some(256),
291 ..Default::default()
292 })
293 .await?;
294 let metadata = collect(scan.schema(), scan.into_stream(), page.num_rows()).await?;
295 let positions = row_ids(&metadata)?
296 .values()
297 .iter()
298 .enumerate()
299 .map(|(position, id)| (*id, position as u32))
300 .collect::<HashMap<_, _>>();
301 let indices = ids
302 .values()
303 .iter()
304 .map(|id| {
305 positions
306 .get(id)
307 .copied()
308 .ok_or_else(|| protocol("The source page omitted a neighbor RowID"))
309 })
310 .collect::<Result<Vec<_>, _>>()?;
311 let metadata =
312 take_record_batch(&metadata, &UInt32Array::from(indices)).map_err(Error::arrow)?;
313 let mut fields = page.schema().fields().iter().cloned().collect::<Vec<_>>();
314 let mut arrays = page.columns().to_vec();
315 for (field, array) in metadata.schema().fields().iter().zip(metadata.columns()) {
316 if field.name() != ROW_ID_COLUMN {
317 fields.push(Arc::clone(field));
318 arrays.push(Arc::clone(array));
319 }
320 }
321 RecordBatch::try_new(
322 Arc::new(Schema::new_with_metadata(
323 fields,
324 page.schema().metadata().clone(),
325 )),
326 arrays,
327 )
328 .map_err(Error::arrow)
329 }
330}
331
332fn row_ids(batch: &RecordBatch) -> Result<&UInt64Array, Error> {
333 batch
334 .column_by_name(ROW_ID_COLUMN)
335 .and_then(|array| array.as_any().downcast_ref())
336 .ok_or_else(|| protocol("Vector results omitted their stable RowIDs"))
337}
338
339fn row_ranges(ids: &[u64]) -> Result<Vec<Range<u64>>, Error> {
340 let mut ids = ids.to_vec();
341 ids.sort_unstable();
342 let mut ranges: Vec<Range<u64>> = Vec::new();
343 for id in ids {
344 let end = id
345 .checked_add(1)
346 .ok_or_else(|| protocol("A neighbor RowID exceeds the supported range"))?;
347 match ranges.last_mut() {
348 Some(range) if range.end == id => range.end = end,
349 _ => ranges.push(id..end),
350 }
351 }
352 Ok(ranges)
353}
354
355async fn collect<S, E>(
356 schema: SchemaRef,
357 mut batches: S,
358 limit: usize,
359) -> Result<RecordBatch, Error>
360where
361 S: Stream<Item = Result<RecordBatch, E>> + Unpin,
362 E: Into<Error>,
363{
364 let mut rows = 0usize;
365 let mut bytes = 0usize;
366 let mut collected = Vec::new();
367 while let Some(batch) = batches.try_next().await.map_err(Into::into)? {
368 rows = rows.saturating_add(batch.num_rows());
369 bytes = bytes.saturating_add(batch.get_array_memory_size());
370 if rows > limit {
371 return Err(protocol("Vector results exceeded their row limit"));
372 }
373 if bytes > MAX_PAGE_BYTES {
374 return Err(Error::invalid("The selected page exceeds its byte limit"));
375 }
376 collected.push(batch);
377 }
378 concat_batches(&schema, &collected).map_err(Error::arrow)
379}
380
381fn protocol(message: impl Into<String>) -> Error {
382 Error::Protocol(message.into())
383}
384
385#[cfg(test)]
386mod tests;