This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new f89181c2dfe [improvement](build) Add merged lance-c multi-vector API
patch (#68056)
f89181c2dfe is described below
commit f89181c2dfe60d00d68fe03b47b35217f1bd68c8
Author: Gabriel <[email protected]>
AuthorDate: Wed Sep 16 21:44:55 2026 +0800
[improvement](build) Add merged lance-c multi-vector API patch (#68056)
### What problem does this PR solve?
Related PR: #67741; consumer integration: #68028
Add merged
[lance-format/lance-c#83](https://github.com/lance-format/lance-c/pull/83)
to the Lance-C third-party patch chain on master. This provides the
C/C++ multi-vector query API and its upstream scoring, validation and
regression tests as a prerequisite for Doris multi-vector search
integration.
The patch records upstream commit
`0a30ee6c5a9d1455ceb36f4745acb79e53a00461`. Overlapping context is
adapted to the existing patch chain while retaining PR #79's
scalar-segment execution path and tests. The multi-vector implementation
and integration tests are byte-identical to the merged upstream commit.
The release archive and dependency versions remain unchanged from
#67741.
Apply PR #83 with a separate completion marker so source trees already
carrying the existing patches also receive the new API, and repeat runs
do not reapply it. Rebuild Lance-C before consuming the new API. This PR
changes only third-party patch integration and does not expose new Doris
SQL syntax.
### Validation
- Applied the actual Lance-C patch-driver block to clean v0.1.9 sources
and to sources already patched through #67741; both produce identical
source trees. Repeat execution leaves the sources unchanged. All patches
apply with `--fuzz=0`.
- Compared the resulting source tree with the previously validated
integrated chain and checked the multi-vector implementation/tests
against the merged upstream commit.
- `cargo test --locked`: 410 tests passed, including 14 multi-vector
integration tests; three opt-in C/C++ compile/run and static-transport
tests were ignored in this run.
- `cargo fmt --check` and `bash -n thirdparty/download-thirdparty.sh`
passed.
- Completed local self-review of patch provenance, conflict adaptation,
existing API behavior, ownership/resource bounds and cached-source
application before committing.
A complete Doris build and SQL regression run are not part of this local
dependency-only validation.
### Release note
None
### Check List (For Author)
- Test
- [x] Unit Test
- [x] Manual test: patch application, cached-source upgrade, repeat
execution and source equivalence
- Behavior changed:
- [x] Yes: the third-party library gains the upstream multi-vector API;
existing API signatures remain unchanged.
- Does this need documentation?
- [x] No: upstream API documentation is included in the patch.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
thirdparty/download-thirdparty.sh | 6 +
thirdparty/patches/lance-c-0.1.9-pr-83.patch | 1914 ++++++++++++++++++++++++++
2 files changed, 1920 insertions(+)
diff --git a/thirdparty/download-thirdparty.sh
b/thirdparty/download-thirdparty.sh
index 324320fa751..f55df80e340 100755
--- a/thirdparty/download-thirdparty.sh
+++ b/thirdparty/download-thirdparty.sh
@@ -764,6 +764,12 @@ if [[ " ${TP_ARCHIVES[*]} " =~ " LANCE_C " ]]; then
done
touch "${PATCHED_MARK}"
fi
+ # The base marker may already exist in cached sources; apply PR #83
independently.
+ if [[ ! -f "${PATCHED_MARK}_pr83" ]]; then
+ patch --batch --forward --reject-file=- --fuzz=0
--no-backup-if-mismatch -s \
+ -p1 <"${TP_PATCH_DIR}/${LANCE_C_SOURCE}-pr-83.patch"
+ touch "${PATCHED_MARK}_pr83"
+ fi
cd -
echo "Finished patching ${LANCE_C_SOURCE}"
fi
diff --git a/thirdparty/patches/lance-c-0.1.9-pr-83.patch
b/thirdparty/patches/lance-c-0.1.9-pr-83.patch
new file mode 100644
index 00000000000..86c0c23e227
--- /dev/null
+++ b/thirdparty/patches/lance-c-0.1.9-pr-83.patch
@@ -0,0 +1,1914 @@
+From 0a30ee6c5a9d1455ceb36f4745acb79e53a00461 Mon Sep 17 00:00:00 2001
+Subject: [PATCH] feat: support multi-vector queries through C and C++ APIs
(#83)
+
+Upstream: https://github.com/lance-format/lance-c/pull/83
+Commit: 0a30ee6c5a9d1455ceb36f4745acb79e53a00461
+
+Adapted to the v0.1.9 community patch chain used by Doris. Resolve context
+conflicts with PR #73 and retain PR #79 scalar_segment fields, execution
+branch, and tests alongside the upstream multi-vector additions. The
+multi-vector implementation and its integration tests are unchanged.
+
+diff --git a/README.md b/README.md
+index d9a5f2b..29c89c4 100644
+--- a/README.md
++++ b/README.md
+@@ -70,6 +70,40 @@ Based on the [liblance
RFC](https://github.com/lance-format/lance/discussions/60
+ | [x] | Filter pushdown | `lance_scanner_set_substrait_filter()` accepts a
serialized Substrait `ExtendedExpression`;
`lance_scanner_additional_sql_filter()` adds SQL predicates with AND before
scanning starts |
+ | [x] | Data-file cache | Optional Foyer memory/disk cache for immutable
`data/*.lance` reads |
+
++## Multi-vector search
++
++Use `lance_scanner_nearest_multivector` or the C++
`Scanner::nearest_multivector`
++method for a `List<FixedSizeList<float16|float32|float64, D>>` column:
++
++```cpp
++const float query[] = {1.0f, 0.0f, 0.0f, 1.0f};
++auto scanner = dataset.scan();
++scanner.nearest_multivector("embeddings", query, 2, 2, LANCE_DTYPE_FLOAT32,
10)
++ .metric(LANCE_METRIC_COSINE)
++ .prefilter(true);
++```
++
++The copied, row-major matrix is **one query** containing two subvectors.
Results
++rank logical rows by the sum of each query subvector's minimum distance to a
++stored subvector. Empty or null outer rows do not rank. Inner vectors must be
++non-nullable; actual stored null or non-finite elements encountered during
++scoring fail the stream. Float types and dimensions must match the column.
++Cosine pairs with zero norm have undefined distance and are ignored. A row is
++excluded if any query subvector has no defined match; a zero-norm query
subvector
++therefore produces no results. Column names use Lance field-path syntax,
++including nested paths such as `payload.embeddings` and backtick-quoted names.
++
++L2 is the default on every fragment. Cosine multi-vector indexes are supported
++by the pinned Lance version; incompatible metrics use exact search. Indexed
++candidates are refined against stored values (`refine_factor` defaults to 1).
++ANN candidate selection remains approximate. Limit and offset apply after
++restoring distance order, including fragment-scoped searches. Strict row
batching
++is applied after that final result window, preserving full batches except the
last.
++
++Queries accept at most 128 subvectors. Both `num_vectors * k` and
++`refine_factor * k` must be at most 100,000 to bound plan expansion and
candidate
++allocation. The existing single-vector API and its defaults are unchanged.
++
+ ## Building
+
+ There are four supported entry points; pick whichever matches your toolchain.
+diff --git a/include/lance/lance.h b/include/lance/lance.h
+index a201bfb..e404612 100644
+--- a/include/lance/lance.h
++++ b/include/lance/lance.h
+@@ -1847,6 +1847,21 @@ int32_t lance_scanner_nearest(
+ uint32_t k
+ );
+
++/**
++ * Set one multi-vector query on a
List<FixedSizeList<float16|float32|float64>> column.
++ * Inner vectors must be non-nullable and contain no null elements; the outer
list may be nullable.
++ * query_data contains dimension * num_vectors aligned elements in row-major
order.
++ * Both sizes and k must be positive. At most 128 query subvectors are
accepted;
++ * num_vectors * k and refine_factor * k must each be at most 100000.
++ * Values are copied before returning. The default metric is L2 on every
fragment.
++ * Scores sum each query vector's minimum distance; refinement defaults to 1.
++ * Returns 0 on success, -1 on error. Stored invalid elements fail during
execution.
++ */
++int32_t lance_scanner_nearest_multivector(
++ LanceScanner* scanner, const char* column, const void* query_data,
++ size_t dimension, size_t num_vectors, LanceDataType element_type,
uint32_t k
++);
++
+ /**
+ * Set both the minimum and maximum vector-index partition-search bounds.
+ *
+diff --git a/include/lance/lance.hpp b/include/lance/lance.hpp
+index a231a00..146c6b1 100644
+--- a/include/lance/lance.hpp
++++ b/include/lance/lance.hpp
+@@ -1465,6 +1465,16 @@ public:
+ return *this;
+ }
+
++ /// One multi-vector query, copied from dimension * num_vectors row-major
elements.
++ Scanner& nearest_multivector(const std::string& column, const void*
query_data,
++ size_t dimension, size_t num_vectors,
++ LanceDataType element_type, uint32_t k) {
++ if (lance_scanner_nearest_multivector(handle_.get(), column.c_str(),
query_data,
++ dimension, num_vectors,
element_type, k) != 0)
++ check_error();
++ return *this;
++ }
++
+ /// Replace both minimum and maximum partition-search bounds.
+ Scanner& nprobes(uint32_t nprobes) {
+ if (lance_scanner_set_nprobes(handle_.get(), nprobes) != 0)
check_error();
+diff --git a/src/lib.rs b/src/lib.rs
+index 197da5f..55f6ecf 100644
+--- a/src/lib.rs
++++ b/src/lib.rs
+@@ -39,6 +39,7 @@ mod index;
+ mod index_model;
+ mod index_segment;
+ mod merge_insert;
++mod multivector;
+ mod restore;
+ pub mod runtime;
+ mod scalar_segment;
+diff --git a/src/multivector.rs b/src/multivector.rs
+new file mode 100644
+index 0000000..c08df28
+--- /dev/null
++++ b/src/multivector.rs
+@@ -0,0 +1,514 @@
++// SPDX-License-Identifier: Apache-2.0
++// SPDX-FileCopyrightText: Copyright The Lance Authors
++
++//! Correct multi-vector scoring before the pinned Lance plan's candidate
limits.
++
++use std::collections::HashMap;
++use std::sync::Arc;
++
++use arrow_array::types::{Float16Type, Float32Type, Float64Type};
++use arrow_array::{
++ Array, ArrayRef, ArrowPrimitiveType, BooleanArray, FixedSizeListArray,
Float32Array, ListArray,
++ RecordBatch, UInt64Array,
++};
++use arrow_schema::{DataType, SchemaRef};
++use datafusion::error::{DataFusionError, Result};
++use datafusion::execution::context::TaskContext;
++use datafusion::physical_plan::{
++ DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties,
SendableRecordBatchStream,
++ stream::RecordBatchStreamAdapter,
++};
++use futures::{StreamExt, TryStreamExt, stream};
++use lance::io::exec::KNNVectorDistanceExec;
++use lance_linalg::distance::{Cosine, DistanceType, Dot, L2};
++
++// Lance creates one ANN branch per query vector,
++// each overfetching 10 * k candidates before scoring; wire bytes alone
cannot bound this work.
++pub(crate) const MAX_QUERY_VECTORS: usize = 128;
++pub(crate) const MAX_QUERY_VECTOR_CANDIDATES: usize = 100_000;
++
++fn invalid(message: impl Into<String>) -> DataFusionError {
++ DataFusionError::Execution(message.into())
++}
++
++/// Rewrite inside TopK/refinement, before any score can discard a candidate.
++pub(crate) fn rewrite(plan: Arc<dyn ExecutionPlan>) -> Result<Arc<dyn
ExecutionPlan>> {
++ let children = plan
++ .children()
++ .into_iter()
++ .map(|child| rewrite(child.clone()))
++ .collect::<Result<Vec<_>>>()?;
++ let plan = if children.is_empty() {
++ plan
++ } else {
++ plan.with_new_children(children)?
++ };
++ let mode = if let Some(exact) =
plan.downcast_ref::<KNNVectorDistanceExec>() {
++ if exact.is_batch {
++ return Err(invalid(
++ "expected one logical multi-vector query, not batch queries",
++ ));
++ }
++ Some(Scoring::Exact {
++ query: exact.query.clone(),
++ column: exact.column.clone(),
++ metric: exact.distance_type,
++ })
++ // This pinned Lance node is not publicly re-exported, so match its
stable plan name.
++ } else if plan.name() == "MultivectorScoringExec" {
++ Some(Scoring::Indexed)
++ } else {
++ None
++ };
++ Ok(match mode {
++ Some(mode) => Arc::new(MultiVectorScoreExec {
++ original: plan,
++ mode,
++ }),
++ None => plan,
++ })
++}
++
++/// Apply the final distance-ordered window without invalidating output
batching.
++pub(crate) fn apply_result_window(
++ plan: Arc<dyn ExecutionPlan>,
++ offset: usize,
++ limit: Option<usize>,
++) -> Result<Arc<dyn ExecutionPlan>> {
++ use datafusion::physical_expr::{PhysicalSortExpr, expressions};
++ use datafusion::physical_plan::{
++ coalesce_partitions::CoalescePartitionsExec, limit::GlobalLimitExec,
sorts::sort::SortExec,
++ };
++ if plan
++ .downcast_ref::<lance_datafusion::exec::StrictBatchSizeExec>()
++ .is_some()
++ {
++ // Offset can split a previously strict batch. Keep Lance's final
rechunker
++ // outside the window, preserving its resolved batch size, including
defaults.
++ let input = apply_result_window(plan.children()[0].clone(), offset,
limit)?;
++ return plan.with_new_children(vec![input]);
++ }
++ let sort = PhysicalSortExpr {
++ expr: expressions::col("_distance", plan.schema().as_ref())?,
++ options: arrow::compute::SortOptions {
++ descending: false,
++ nulls_first: false,
++ },
++ };
++ // Fragment-scoped payload takes can reorder batches. Restore distance
order
++ // before the window; the nearest plan already bounds candidate rows by k.
++ let sorted = Arc::new(SortExec::new(
++ [sort].into(),
++ Arc::new(CoalescePartitionsExec::new(plan)),
++ ));
++ Ok(Arc::new(GlobalLimitExec::new(sorted, offset, limit)))
++}
++
++#[derive(Clone, Debug)]
++enum Scoring {
++ Exact {
++ query: ArrayRef,
++ column: String,
++ metric: DistanceType,
++ },
++ Indexed,
++}
++
++#[derive(Debug)]
++struct MultiVectorScoreExec {
++ original: Arc<dyn ExecutionPlan>,
++ mode: Scoring,
++}
++
++impl DisplayAs for MultiVectorScoreExec {
++ fn fmt_as(&self, _: DisplayFormatType, f: &mut std::fmt::Formatter) ->
std::fmt::Result {
++ write!(f, "MultiVectorScore: {}", self.original.name())
++ }
++}
++
++impl ExecutionPlan for MultiVectorScoreExec {
++ fn name(&self) -> &str {
++ "MultiVectorScoreExec"
++ }
++ fn properties(&self) -> &Arc<PlanProperties> {
++ self.original.properties()
++ }
++ fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
++ self.original.children()
++ }
++ fn required_input_distribution(&self) ->
Vec<datafusion::physical_expr::Distribution> {
++ self.original.required_input_distribution()
++ }
++ fn with_new_children(
++ self: Arc<Self>,
++ children: Vec<Arc<dyn ExecutionPlan>>,
++ ) -> Result<Arc<dyn ExecutionPlan>> {
++ Ok(Arc::new(Self {
++ original: self.original.clone().with_new_children(children)?,
++ mode: self.mode.clone(),
++ }))
++ }
++ fn execute(
++ &self,
++ partition: usize,
++ context: Arc<TaskContext>,
++ ) -> Result<SendableRecordBatchStream> {
++ let schema = self.schema();
++ match &self.mode {
++ Scoring::Exact {
++ query,
++ column,
++ metric,
++ } => {
++ let input = self.children()[0].execute(partition, context)?;
++ let query = query.clone();
++ let column = column.clone();
++ let metric = *metric;
++ let output_schema = schema.clone();
++ let output = input
++ .map(move |batch| {
++ let query = query.clone();
++ let column = column.clone();
++ let schema = output_schema.clone();
++ async move {
++ let batch = batch?;
++ tokio::task::spawn_blocking(move || {
++ exact_batch(batch, query, &column, metric,
schema)
++ })
++ .await
++ .map_err(|e|
DataFusionError::External(Box::new(e)))?
++ }
++ })
++
.buffered(lance_core::utils::tokio::get_num_compute_intensive_cpus());
++ Ok(Box::pin(RecordBatchStreamAdapter::new(schema, output)))
++ }
++ Scoring::Indexed => {
++ let inputs = self
++ .children()
++ .into_iter()
++ .map(|child| child.execute(partition, context.clone()))
++ .collect::<Result<Vec<_>>>()?;
++ let output_schema = schema.clone();
++ let output =
++ stream::once(async move { indexed_batch(inputs,
output_schema).await });
++ Ok(Box::pin(RecordBatchStreamAdapter::new(schema, output)))
++ }
++ }
++ }
++}
++
++fn row_distance<T: ArrowPrimitiveType>(
++ query: &dyn Array,
++ vectors: &FixedSizeListArray,
++ metric: DistanceType,
++) -> Result<Option<f32>>
++where
++ T::Native: L2 + Cosine + Dot + Into<f64>,
++{
++ let q = query
++ .as_any()
++ .downcast_ref::<arrow_array::PrimitiveArray<T>>()
++ .ok_or_else(|| invalid("multi-vector query element type mismatch"))?;
++ let values = vectors
++ .values()
++ .as_any()
++ .downcast_ref::<arrow_array::PrimitiveArray<T>>()
++ .ok_or_else(|| invalid("multi-vector stored element type mismatch"))?;
++ if vectors.null_count() != 0
++ || values.null_count() != 0
++ || values
++ .values()
++ .iter()
++ .any(|v| !Into::<f64>::into(*v).is_finite())
++ {
++ return Err(invalid(
++ "multi-vector stored subvectors must contain only finite,
non-null elements",
++ ));
++ }
++ let dimension = vectors.value_length() as usize;
++ let distance = metric.func();
++ // Subtracting each small distance from 1 rounds it away before TopK. Sum
minima
++ // directly, using f64 only for the accumulator; the base kernels and
output remain f32.
++ let mut score = 0.0f64;
++ for query_vector in q.values().chunks_exact(dimension) {
++ let best = values
++ .values()
++ .chunks_exact(dimension)
++ .map(|vector| distance(query_vector, vector))
++ // Finite zero-norm vectors have undefined cosine distance.
Ignore those
++ // pairs; a query with no defined match masks this row, not the
whole scan.
++ .filter(|distance| !distance.is_nan())
++ .min_by(f32::total_cmp);
++ let Some(best) = best else {
++ return Ok(None);
++ };
++ score += best as f64;
++ }
++ let score = score as f32;
++ if !score.is_finite() {
++ return Err(invalid("multi-vector distance is not finite"));
++ }
++ Ok(Some(score))
++}
++
++fn vector_column(batch: &RecordBatch, column: &str) -> Result<ArrayRef> {
++ if let Some(array) = batch.column_by_name(column) {
++ return Ok(array.clone());
++ }
++ // The planner resolves field paths, including quoted dotted names. Its
private
++ // KNN resolver is not exported, so use the same parser and struct
traversal here.
++ let parts = lance_core::datatypes::parse_field_path(column)
++ .map_err(|e| invalid(format!("invalid vector column path '{column}':
{e}")))?;
++ let root = parts
++ .first()
++ .ok_or_else(|| invalid("empty vector column path"))?;
++ let mut array = batch
++ .column_by_name(root)
++ .cloned()
++ .ok_or_else(|| invalid(format!("missing vector column '{column}'")))?;
++ for part in &parts[1..] {
++ array = array
++ .as_any()
++ .downcast_ref::<arrow_array::StructArray>()
++ .and_then(|parent| parent.column_by_name(part))
++ .cloned()
++ .ok_or_else(|| {
++ invalid(format!(
++ "missing struct field '{part}' in vector column
'{column}'"
++ ))
++ })?;
++ }
++ Ok(array)
++}
++
++fn exact_batch(
++ batch: RecordBatch,
++ query: ArrayRef,
++ column: &str,
++ metric: DistanceType,
++ schema: SchemaRef,
++) -> Result<RecordBatch> {
++ if batch.num_rows() == 0 {
++ return Ok(RecordBatch::new_empty(schema));
++ }
++ let array = vector_column(&batch, column)?;
++ let vectors = array
++ .as_any()
++ .downcast_ref::<ListArray>()
++ .ok_or_else(|| invalid("multi-vector scoring requires a List
column"))?;
++ let row_ids = batch.column_by_name("_rowid");
++ let mut scores = Vec::with_capacity(batch.num_rows());
++ for (i, vector) in vectors.iter().enumerate() {
++ if row_ids.is_some_and(|ids| ids.is_null(i)) {
++ scores.push(None);
++ continue;
++ }
++ let Some(vector) = vector else {
++ scores.push(None);
++ continue;
++ };
++ let vector = vector
++ .as_any()
++ .downcast_ref::<FixedSizeListArray>()
++ .ok_or_else(|| invalid("multi-vector row must be a
FixedSizeList"))?;
++ if vector.is_empty() {
++ scores.push(None);
++ continue;
++ }
++ let score = match query.data_type() {
++ DataType::Float16 => row_distance::<Float16Type>(query.as_ref(),
vector, metric),
++ DataType::Float32 => row_distance::<Float32Type>(query.as_ref(),
vector, metric),
++ DataType::Float64 => row_distance::<Float64Type>(query.as_ref(),
vector, metric),
++ _ => Err(invalid("unsupported multi-vector element type")),
++ }?;
++ scores.push(score);
++ }
++ let mask = BooleanArray::from_iter(scores.iter().map(|score|
Some(score.is_some())));
++ let distances: ArrayRef = Arc::new(Float32Array::from(scores));
++ let columns = schema
++ .fields()
++ .iter()
++ .map(|field| {
++ if field.name() == "_distance" {
++ Ok(distances.clone())
++ } else {
++ batch
++ .column_by_name(field.name())
++ .cloned()
++ .ok_or_else(|| invalid(format!("missing score output
column {}", field.name())))
++ }
++ })
++ .collect::<Result<Vec<_>>>()?;
++ Ok(arrow::compute::filter_record_batch(
++ &RecordBatch::try_new(schema, columns)?,
++ &mask,
++ )?)
++}
++
++async fn indexed_batch(
++ inputs: Vec<SendableRecordBatchStream>,
++ schema: SchemaRef,
++) -> Result<RecordBatch> {
++ // A query child may emit several batches. Reduce its entire stream
exactly once;
++ // treating each batch as a query adds spurious missing-query
contributions.
++ let queries = futures::future::try_join_all(inputs.into_iter().map(|mut
input| async move {
++ let mut rows = HashMap::<u64, f32>::new();
++ let mut maximum: Option<f32> = None;
++ while let Some(batch) = input.try_next().await? {
++ let ids = batch
++ .column_by_name("_rowid")
++ .and_then(|a| a.as_any().downcast_ref::<UInt64Array>())
++ .ok_or_else(|| invalid("indexed multi-vector scorer requires
row IDs"))?;
++ let distances = batch
++ .column_by_name("_distance")
++ .and_then(|a| a.as_any().downcast_ref::<Float32Array>())
++ .ok_or_else(|| invalid("indexed multi-vector scorer requires
distances"))?;
++ for i in 0..batch.num_rows() {
++ let distance = distances.value(i);
++ if ids.is_null(i) || distances.is_null(i) ||
!distance.is_finite() {
++ return Err(invalid(
++ "indexed multi-vector candidate has invalid row ID or
distance",
++ ));
++ }
++ maximum = Some(maximum.map_or(distance, |old|
old.max(distance)));
++ rows.entry(ids.value(i))
++ .and_modify(|old| *old = old.min(distance))
++ .or_insert(distance);
++ }
++ }
++ Ok::<_, DataFusionError>((rows, maximum.unwrap_or(1.0)))
++ }))
++ .await?;
++ let mut results = HashMap::<u64, f64>::new();
++ let mut missed = 0.0f64;
++ for (rows, maximum) in queries {
++ for (id, score) in &mut results {
++ *score += *rows.get(id).unwrap_or(&maximum) as f64;
++ }
++ for (id, score) in rows {
++ results.entry(id).or_insert(score as f64 + missed);
++ }
++ missed += maximum as f64;
++ }
++ let (ids, scores): (Vec<_>, Vec<_>) = results.into_iter().unzip();
++ Ok(RecordBatch::try_new(
++ schema,
++ vec![
++ Arc::new(Float32Array::from_iter_values(
++ scores.into_iter().map(|score| score as f32),
++ )),
++ Arc::new(UInt64Array::from(ids)),
++ ],
++ )?)
++}
++
++pub(crate) fn validate_query(values: &dyn Array) -> Result<()> {
++ fn finite<T: ArrowPrimitiveType>(values: &dyn Array) -> bool
++ where
++ T::Native: Into<f64>,
++ {
++ values
++ .as_any()
++ .downcast_ref::<arrow_array::PrimitiveArray<T>>()
++ .is_some_and(|array| {
++ array.null_count() == 0
++ && array
++ .values()
++ .iter()
++ .all(|v| Into::<f64>::into(*v).is_finite())
++ })
++ }
++ let valid = match values.data_type() {
++ DataType::Float16 => finite::<Float16Type>(values),
++ DataType::Float32 => finite::<Float32Type>(values),
++ DataType::Float64 => finite::<Float64Type>(values),
++ _ => false,
++ };
++ if valid {
++ Ok(())
++ } else {
++ Err(invalid(
++ "multi-vector query must contain only finite, non-null elements",
++ ))
++ }
++}
++
++#[cfg(test)]
++mod tests {
++ use super::*;
++ use arrow_schema::{Field, Schema};
++ use lance_datafusion::exec::{
++ LanceExecutionOptions, OneShotExec, StrictBatchSizeExec, execute_plan,
++ };
++
++ #[test]
++ fn result_window_preserves_strict_batching_and_distance_order() {
++ crate::runtime::block_on(async {
++ for size in [2, 3] {
++ for (offset, limit) in [
++ (1, Some(4)),
++ (1, Some(3)),
++ (5, Some(4)),
++ (6, Some(4)),
++ (1, None),
++ ] {
++ let schema = Arc::new(Schema::new(vec![Field::new(
++ "_distance",
++ DataType::Float32,
++ false,
++ )]));
++ let batches = [[5., 0.], [4., 1.], [3., 2.]]
++ .into_iter()
++ .map(|values| {
++ RecordBatch::try_new(
++ schema.clone(),
++
vec![Arc::new(Float32Array::from(values.to_vec()))],
++ )
++ .map_err(DataFusionError::from)
++ })
++ .collect::<Vec<_>>();
++ let input = Arc::new(OneShotExec::new(Box::pin(
++ RecordBatchStreamAdapter::new(schema,
stream::iter(batches)),
++ )));
++ let plan = Arc::new(StrictBatchSizeExec::new(input,
size));
++ let plan = apply_result_window(plan, offset,
limit).unwrap();
++ let batches: Vec<_> = execute_plan(
++ plan,
++ LanceExecutionOptions {
++ batch_size: Some(2),
++ ..Default::default()
++ },
++ )
++ .unwrap()
++ .try_collect()
++ .await
++ .unwrap();
++ let actual: Vec<_> = batches
++ .iter()
++ .flat_map(|b| {
++ b.column(0)
++ .as_any()
++ .downcast_ref::<Float32Array>()
++ .unwrap()
++ .values()
++ .to_vec()
++ })
++ .collect();
++ let expected: Vec<_> = (0..6)
++ .skip(offset)
++ .take(limit.unwrap_or(6))
++ .map(|i| i as f32)
++ .collect();
++ assert_eq!(actual, expected);
++ assert_eq!(
++ batches
++ .iter()
++ .map(RecordBatch::num_rows)
++ .collect::<Vec<_>>(),
++
expected.chunks(size).map(<[f32]>::len).collect::<Vec<_>>()
++ );
++ }
++ }
++ });
++ }
++}
+diff --git a/src/scanner.rs b/src/scanner.rs
+index bda554d..f4b3f36 100644
+--- a/src/scanner.rs
++++ b/src/scanner.rs
+@@ -363,8 +363,18 @@ impl LanceScanner {
+ if let Some(cols) = &self.columns {
+ scanner.project(cols)?;
+ }
++ let multi_vector = self.nearest.as_ref().is_some_and(|query| {
++ matches!(
++ query.query.data_type(),
++ arrow_schema::DataType::FixedSizeList(_, _)
++ )
++ });
+ if self.limit.is_some() || self.offset.is_some() {
+ scanner.limit(self.limit, self.offset)?;
++ if multi_vector {
++ // Retain Lance's window validation, but defer truncation
until the final sort.
++ scanner.limit(None, None)?;
++ }
+ }
+ if let Some(bs) = self.batch_size {
+ scanner.batch_size(bs);
+@@ -440,7 +450,27 @@ impl LanceScanner {
+ if let Some(query_parallelism) = self.query_parallelism {
+ scanner.query_parallelism(query_parallelism);
+ }
+- if let Some(rf) = self.refine_factor {
++ if multi_vector {
++ if matches!(
++ self.metric_override,
++ Some(crate::index::LanceMetricType::Hamming)
++ ) {
++ return Err(lance_core::Error::invalid_input_source(
++ "multi-vector queries support only l2, cosine, and
dot metrics".into(),
++ ));
++ }
++ let refine = self.refine_factor.unwrap_or(1);
++ if refine == 0
++ || n.k as usize
++ > crate::multivector::MAX_QUERY_VECTOR_CANDIDATES /
refine as usize
++ {
++ return Err(lance_core::Error::invalid_input_source(
++ "multi-vector refined candidate count must be in
1..=100000".into(),
++ ));
++ }
++ // Validate actual stored values and refine candidate scores
before TopK.
++ scanner.refine(refine);
++ } else if let Some(rf) = self.refine_factor {
+ scanner.refine(rf);
+ }
+ if let Some(ef) = self.ef {
+@@ -448,6 +478,9 @@ impl LanceScanner {
+ }
+ if let Some(m) = self.metric_override {
+ scanner.distance_metric(m.to_distance());
++ } else if multi_vector {
++ // Resolve the same default on indexed and uncovered
fragments.
++
scanner.distance_metric(lance_linalg::distance::DistanceType::L2);
+ }
+ if let Some(ui) = self.use_index {
+ scanner.use_index(ui);
+@@ -506,6 +539,12 @@ impl LanceScanner {
+ scanner,
+ distributed_fts,
+ scalar_segment,
++ multi_vector_window: multi_vector.then_some((
++ self.offset.unwrap_or(0) as usize,
++ self.limit.map(|n| n as usize),
++ )),
++ batch_size: self.batch_size,
++ scan_statistics_callback: self.scan_statistics_callback.clone(),
+ })
+ }
+ }
+@@ -521,6 +560,9 @@ struct PreparedScanner {
+ scanner: lance::dataset::scanner::Scanner,
+ distributed_fts: Option<PreparedFtsExecution>,
+ scalar_segment: Option<PreparedScalarSegment>,
++ multi_vector_window: Option<(usize, Option<usize>)>,
++ batch_size: Option<usize>,
++ scan_statistics_callback: Option<ExecutionStatsCallback>,
+ }
+
+ impl PreparedScanner {
+@@ -532,6 +574,19 @@ impl PreparedScanner {
+ .try_into_stream()
+ .await;
+ }
++ if let Some((offset, limit)) = self.multi_vector_window {
++ let plan =
crate::multivector::rewrite(self.scanner.create_plan().await?)?;
++ let plan = crate::multivector::apply_result_window(plan, offset,
limit)?;
++ let stream = lance_datafusion::exec::execute_plan(
++ plan,
++ lance_datafusion::exec::LanceExecutionOptions {
++ batch_size: self.batch_size,
++ execution_stats_callback: self.scan_statistics_callback,
++ ..Default::default()
++ },
++ )?;
++ return Ok(DatasetRecordBatchStream::new(stream));
++ }
+ let Some(distributed_fts) = self.distributed_fts else {
+ return self.scanner.try_into_stream().await;
+ };
+@@ -2744,6 +2799,21 @@ unsafe fn scanner_nearest_inner(
+ }
+ let column_str = unsafe { helpers::parse_c_string(column)? }.unwrap();
+
++ let query = unsafe { decode_query_values(query_data, query_len,
element_type)? };
++
++ s.nearest = Some(NearestQuery {
++ column: column_str.to_string(),
++ query,
++ k,
++ });
++ Ok(0)
++}
++
++unsafe fn decode_query_values(
++ query_data: *const c_void,
++ query_len: usize,
++ element_type: i32,
++) -> Result<arrow_array::ArrayRef> {
+ let dtype = match element_type {
+ 0 => LanceDataType::Float32,
+ 1 => LanceDataType::Float16,
+@@ -2782,9 +2852,112 @@ unsafe fn scanner_nearest_inner(
+ }
+ };
+
++ Ok(query)
++}
++
++/// Set one multi-vector query, supplied as a row-major matrix of
floating-point values.
++/// The caller must supply dimension * num_vectors aligned elements matching
the column type.
++#[unsafe(no_mangle)]
++pub unsafe extern "C" fn lance_scanner_nearest_multivector(
++ scanner: *mut LanceScanner,
++ column: *const c_char,
++ query_data: *const c_void,
++ dimension: usize,
++ num_vectors: usize,
++ element_type: i32,
++ k: u32,
++) -> i32 {
++ scanner_poison_check!(scanner, -1);
++ scanner_ffi_try!(scanner, unsafe {
++ nearest_multivector_inner(
++ scanner,
++ column,
++ query_data,
++ dimension,
++ num_vectors,
++ element_type,
++ k,
++ )
++ },)
++}
++
++unsafe fn nearest_multivector_inner(
++ scanner: *mut LanceScanner,
++ column: *const c_char,
++ query_data: *const c_void,
++ dimension: usize,
++ num_vectors: usize,
++ element_type: i32,
++ k: u32,
++) -> Result<i32> {
++ use arrow_schema::{DataType, Field};
++ let invalid = |message: &str|
lance_core::Error::invalid_input_source(message.into());
++ if scanner.is_null() || column.is_null() || query_data.is_null() {
++ return Err(invalid("scanner, column, and query_data must not be
NULL"));
++ }
++ if dimension == 0 || dimension > i32::MAX as usize || num_vectors == 0 ||
k == 0 {
++ return Err(invalid(
++ "dimension, num_vectors, and k must be positive; dimension must
fit int32",
++ ));
++ }
++ if num_vectors > crate::multivector::MAX_QUERY_VECTORS
++ || num_vectors > crate::multivector::MAX_QUERY_VECTOR_CANDIDATES / k
as usize
++ {
++ return Err(invalid(
++ "multi-vector query exceeds 128 subvectors or 100000
subvector-candidates",
++ ));
++ }
++ let (data_type, width) = match element_type {
++ 0 => (DataType::Float32, 4),
++ 1 => (DataType::Float16, 2),
++ 2 => (DataType::Float64, 8),
++ _ => {
++ return Err(invalid(
++ "multi-vector queries require float16, float32, or float64",
++ ));
++ }
++ };
++ let count = dimension
++ .checked_mul(num_vectors)
++ .filter(|count| *count <= isize::MAX as usize / width)
++ .ok_or_else(|| invalid("query matrix byte size overflows"))?;
++ let s = unsafe { &mut *scanner };
++ if s.fts_query.is_some() || s.fts_context.is_some() {
++ return Err(invalid(
++ "nearest and full-text search are mutually exclusive",
++ ));
++ }
++ let column = unsafe { helpers::parse_c_string(column)? }.unwrap();
++ let field = s
++ .dataset
++ .schema()
++ .field(column)
++ .ok_or_else(|| invalid("multi-vector column does not exist"))?;
++ match field.data_type() {
++ DataType::List(child) if !child.is_nullable() => match
child.data_type() {
++ DataType::FixedSizeList(element, dim)
++ if *dim == dimension as i32 && *element.data_type() ==
data_type => {}
++ _ => return Err(invalid("multi-vector dimension/type mismatch")),
++ },
++ _ => {
++ return Err(invalid(
++ "multi-vector column must be List of non-nullable
FixedSizeList",
++ ));
++ }
++ }
++ // A primitive array is interpreted as one vector by Lance. Preserve
matrix shape even
++ // for a single subvector. Lance does not preserve element nullability in
its schema.
++ let values = unsafe { decode_query_values(query_data, count,
element_type)? };
++ crate::multivector::validate_query(values.as_ref())?;
++ let query = arrow_array::FixedSizeListArray::try_new(
++ Arc::new(Field::new("item", data_type, false)),
++ dimension as i32,
++ values,
++ None,
++ )?;
+ s.nearest = Some(NearestQuery {
+- column: column_str.to_string(),
+- query,
++ column: column.to_string(),
++ query: Arc::new(query),
+ k,
+ });
+ Ok(0)
+diff --git a/tests/c_api_test.rs b/tests/c_api_test.rs
+index 04699da..24bef9f 100644
+--- a/tests/c_api_test.rs
++++ b/tests/c_api_test.rs
+@@ -13388,3 +13388,28 @@ fn
test_scalar_segment_requires_explicit_domain_and_checks_uuid() {
+ lance_dataset_close(ds);
+ }
+ }
++
++#[test]
++fn test_multivector_nearest_rejects_null_handle() {
++ let column = c_str("vectors");
++ let query = [1.0f32, 0.0];
++ let status = unsafe {
++ lance_scanner_nearest_multivector(
++ ptr::null_mut(),
++ column.as_ptr(),
++ query.as_ptr().cast(),
++ 2,
++ 1,
++ 0,
++ 1,
++ )
++ };
++ assert_eq!(status, -1);
++ let error = lance_last_error_message();
++ assert!(!error.is_null());
++ let message = unsafe { std::ffi::CStr::from_ptr(error) }
++ .to_string_lossy()
++ .into_owned();
++ unsafe { lance_free_string(error) };
++ assert!(message.contains("NULL"));
++}
+diff --git a/tests/cpp/test_cpp_api.cpp b/tests/cpp/test_cpp_api.cpp
+index fc6fc82..11badc6 100644
+--- a/tests/cpp/test_cpp_api.cpp
++++ b/tests/cpp/test_cpp_api.cpp
+@@ -427,6 +427,20 @@ static void test_nearest_smoke(const std::string& uri) {
+ PASS();
+ }
+
++static void test_multivector_rejects_flat_column(const std::string& uri) {
++ TEST(test_multivector_rejects_flat_column);
++ auto scanner = lance::Dataset::open(uri).scan();
++ const float query[8] = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
++ bool caught = false;
++ try {
++ scanner.nearest_multivector("embedding", query, 8, 1,
LANCE_DTYPE_FLOAT32, 1);
++ } catch (const lance::Error&) {
++ caught = true;
++ }
++ assert(caught);
++ PASS();
++}
++
+ static void test_index_segments_smoke(const std::string& /*uri*/) {
+ TEST(test_index_segments_smoke);
+
+@@ -971,6 +985,7 @@ int main(int argc, char** argv) {
+ test_error_exception(uri);
+ test_index_lifecycle(uri);
+ test_nearest_smoke(uri);
++ test_multivector_rejects_flat_column(uri);
+ test_index_segments_smoke(uri);
+ test_index_segment_builder(uri);
+ test_vector_models_and_reusable_segments(uri);
+diff --git a/tests/multivector_test.rs b/tests/multivector_test.rs
+new file mode 100644
+index 0000000..f02e850
+--- /dev/null
++++ b/tests/multivector_test.rs
+@@ -0,0 +1,965 @@
++// SPDX-License-Identifier: Apache-2.0
++// SPDX-FileCopyrightText: Copyright The Lance Authors
++
++use std::ffi::{CString, c_void};
++use std::ptr;
++use std::sync::Arc;
++
++use arrow::buffer::{NullBuffer, OffsetBuffer};
++use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream};
++use arrow::record_batch::RecordBatchIterator;
++use arrow_array::{Array, FixedSizeListArray, Float32Array, Int32Array,
ListArray, RecordBatch};
++use arrow_schema::{DataType, Field, Schema};
++use lance::Dataset;
++use lance_c::*;
++
++fn fixture() -> (tempfile::TempDir, CString) {
++ let dir = tempfile::tempdir().unwrap();
++ let path = dir.path().join("vectors.lance");
++ let element = Arc::new(Field::new("item", DataType::Float32, false));
++ let vectors = FixedSizeListArray::try_new(
++ element,
++ 2,
++ Arc::new(Float32Array::from(vec![
++ 1., 0., 0., 1., 2., 0., 3., 0., 0., 3.,
++ ])),
++ None,
++ )
++ .unwrap();
++ let child = Arc::new(Field::new("item", vectors.data_type().clone(),
false));
++ let rows = ListArray::try_new(
++ child,
++ OffsetBuffer::new(vec![0i32, 2, 3, 5, 5, 5].into()),
++ Arc::new(vectors),
++ Some(NullBuffer::from(vec![true, true, true, true, false])),
++ )
++ .unwrap();
++ let schema = Arc::new(Schema::new(vec![
++ Field::new("id", DataType::Int32, false),
++ Field::new("vectors", rows.data_type().clone(), true),
++ ]));
++ let batch = RecordBatch::try_new(
++ schema.clone(),
++ vec![
++ Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])),
++ Arc::new(rows),
++ ],
++ )
++ .unwrap();
++ lance_c::runtime::block_on(async {
++ Dataset::write(
++ RecordBatchIterator::new(vec![Ok(batch)], schema),
++ path.to_str().unwrap(),
++ None,
++ )
++ .await
++ .unwrap();
++ });
++ (dir, CString::new(path.to_str().unwrap()).unwrap())
++}
++
++unsafe fn collect(scanner: *mut LanceScanner) -> Vec<(i32, f32)> {
++ let mut stream = FFI_ArrowArrayStream::empty();
++ let status = unsafe { lance_scanner_to_arrow_stream(scanner, &mut stream)
};
++ if status != 0 {
++ panic!(
++ "{}",
++ unsafe { std::ffi::CStr::from_ptr(lance_last_error_message())
}.to_string_lossy()
++ );
++ }
++ let reader = unsafe { ArrowArrayStreamReader::from_raw(&mut stream)
}.unwrap();
++ reader
++ .flat_map(|batch| {
++ let batch = batch.unwrap();
++ let ids = batch
++ .column_by_name("id")
++ .unwrap()
++ .as_any()
++ .downcast_ref::<Int32Array>()
++ .unwrap();
++ let distances = batch
++ .column_by_name("_distance")
++ .unwrap()
++ .as_any()
++ .downcast_ref::<Float32Array>()
++ .unwrap();
++ (0..batch.num_rows())
++ .map(|i| (ids.value(i), distances.value(i)))
++ .collect::<Vec<_>>()
++ })
++ .collect()
++}
++
++#[test]
++fn multivector_search_scores_logical_rows_and_excludes_empty_and_null_rows() {
++ let (_dir, uri) = fixture();
++ let column = CString::new("vectors").unwrap();
++ let q = [1.0f32, 0., 0., 1.];
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ assert!(!ds.is_null());
++ for count in [1, 2] {
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ q.as_ptr() as *const c_void,
++ 2,
++ count,
++ 0,
++ 10
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, false), 0);
++ assert_eq!(lance_scanner_set_prefilter(scan, true), 0);
++ let rows = collect(scan);
++ let expected = if count == 2 {
++ vec![(1, 0.), (2, 6.), (3, 8.)]
++ } else {
++ vec![(1, 0.), (2, 1.), (3, 4.)]
++ };
++ assert_eq!(rows, expected);
++ lance_scanner_close(scan);
++ }
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn multivector_rejects_invalid_shape_and_preserves_previous_query() {
++ let (_dir, uri) = fixture();
++ let column = CString::new("vectors").unwrap();
++ let q = [1.0f32, 0., 0., 1.];
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ q.as_ptr() as *const c_void,
++ 2,
++ 2,
++ 0,
++ 10
++ ),
++ 0
++ );
++ for (dim, count, dtype, k) in [
++ (0, 2, 0, 10),
++ (2, 0, 0, 10),
++ (3, 1, 0, 10),
++ (2, 2, 3, 10),
++ (2, 2, 4, 10),
++ (2, 2, 1, 10),
++ (2, 2, 0, 0),
++ (usize::MAX, 2, 0, 10),
++ (2, usize::MAX, 0, 10),
++ ] {
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ q.as_ptr() as *const c_void,
++ dim,
++ count,
++ dtype,
++ k
++ ),
++ -1
++ );
++ }
++ assert_eq!(
++ lance_scanner_nearest_multivector(scan, column.as_ptr(),
ptr::null(), 2, 2, 0, 10),
++ -1
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, false), 0);
++ for (limit, offset) in [(-1, 0), (10, -1)] {
++ assert_eq!(lance_scanner_set_limit(scan, limit), 0);
++ assert_eq!(lance_scanner_set_offset(scan, offset), 0);
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(lance_scanner_to_arrow_stream(scan, &mut stream), -1);
++ }
++ assert_eq!(lance_scanner_set_limit(scan, 10), 0);
++ assert_eq!(lance_scanner_set_offset(scan, 0), 0);
++ assert_eq!(collect(scan), vec![(1, 0.), (2, 6.), (3, 8.)]);
++ lance_scanner_close(scan);
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn fragment_scoped_indexed_search_orders_candidates_before_offset() {
++ use lance::index::DatasetIndexExt;
++ use lance::index::vector::VectorIndexParams;
++ use lance_index::IndexType;
++ use lance_linalg::distance::MetricType;
++
++ let (_dir, uri) = fixture();
++ lance_c::runtime::block_on(async {
++ let mut ds = Dataset::open(uri.to_str().unwrap()).await.unwrap();
++ let batch = ds.scan().try_into_batch().await.unwrap();
++ ds.create_index(
++ &["vectors"],
++ IndexType::Vector,
++ None,
++ &VectorIndexParams::ivf_flat(1, MetricType::Cosine),
++ false,
++ )
++ .await
++ .unwrap();
++ ds.append(
++ RecordBatchIterator::new(vec![Ok(batch.clone())], batch.schema()),
++ None,
++ )
++ .await
++ .unwrap();
++ });
++ let column = CString::new("vectors").unwrap();
++ let filter = CString::new("id >= 2").unwrap();
++ let q = [1.0f32, 0., 0., 1.];
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for _ in 0..10 {
++ let scan = lance_scanner_new(ds, ptr::null(), filter.as_ptr());
++ assert_eq!(
++ lance_scanner_set_fragment_ids(scan, [0u64, 1].as_ptr(), 2),
++ 0
++ );
++ assert_eq!(lance_scanner_set_prefilter(scan, true), 0);
++ assert_eq!(lance_scanner_set_batch_size(scan, 1), 0);
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ q.as_ptr() as *const c_void,
++ 2,
++ 2,
++ 0,
++ 4
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_metric(scan, 1), 0);
++ assert_eq!(lance_scanner_set_refine_factor(scan, 1), 0);
++ assert_eq!(lance_scanner_set_offset(scan, 1), 0);
++ assert_eq!(lance_scanner_set_limit(scan, 2), 0);
++ assert_eq!(collect(scan), vec![(3, 0.), (2, 1.)]);
++ lance_scanner_close(scan);
++ }
++ lance_dataset_close(ds);
++ }
++}
++
++fn custom_fixture(
++ rows: Vec<Vec<Option<f32>>>,
++ dim: i32,
++ indexed: bool,
++) -> (tempfile::TempDir, CString) {
++ custom_fixture_at(rows, dim, indexed, "vectors")
++}
++
++fn custom_fixture_at(
++ rows: Vec<Vec<Option<f32>>>,
++ dim: i32,
++ indexed: bool,
++ column: &str,
++) -> (tempfile::TempDir, CString) {
++ use lance::index::DatasetIndexExt;
++ use lance::index::vector::VectorIndexParams;
++ use lance_index::IndexType;
++ use lance_linalg::distance::MetricType;
++ let dir = tempfile::tempdir().unwrap();
++ let path = dir.path().join("vectors.lance");
++ let mut offsets = vec![0i32];
++ let mut values = Vec::new();
++ for row in &rows {
++ values.extend_from_slice(row);
++ offsets.push(values.len() as i32 / dim);
++ }
++ let vectors = FixedSizeListArray::try_new(
++ Arc::new(Field::new("item", DataType::Float32, true)),
++ dim,
++ Arc::new(Float32Array::from(values)),
++ None,
++ )
++ .unwrap();
++ let rows_array = ListArray::try_new(
++ Arc::new(Field::new("item", vectors.data_type().clone(), false)),
++ OffsetBuffer::new(offsets.into()),
++ Arc::new(vectors),
++ None,
++ )
++ .unwrap();
++ let parts = lance_core::datatypes::parse_field_path(column).unwrap();
++ let mut array: arrow_array::ArrayRef = Arc::new(rows_array);
++ for name in parts[1..].iter().rev() {
++ let field = Arc::new(Field::new(name, array.data_type().clone(),
true));
++ let labels: arrow_array::ArrayRef = Arc::new(Int32Array::from(vec![7;
rows.len()]));
++ array = Arc::new(arrow_array::StructArray::from(vec![
++ (field, array),
++ (
++ Arc::new(Field::new("label", DataType::Int32, false)),
++ labels,
++ ),
++ ]));
++ }
++ let schema = Arc::new(Schema::new(vec![
++ Field::new("id", DataType::Int32, false),
++ Field::new(&parts[0], array.data_type().clone(), true),
++ ]));
++ let batch = RecordBatch::try_new(
++ schema.clone(),
++ vec![
++ Arc::new(Int32Array::from_iter_values(1..=rows.len() as i32)),
++ array,
++ ],
++ )
++ .unwrap();
++ lance_c::runtime::block_on(async {
++ let mut ds = Dataset::write(
++ RecordBatchIterator::new(vec![Ok(batch)], schema),
++ path.to_str().unwrap(),
++ None,
++ )
++ .await
++ .unwrap();
++ if indexed {
++ ds.create_index(
++ &[column],
++ IndexType::Vector,
++ None,
++ &VectorIndexParams::ivf_flat(1, MetricType::Cosine),
++ false,
++ )
++ .await
++ .unwrap();
++ }
++ });
++ (dir, CString::new(path.to_str().unwrap()).unwrap())
++}
++
++#[test]
++fn exact_top_one_preserves_small_distances_before_truncation() {
++ let (_dir, uri) = custom_fixture(vec![vec![Some(0.00015)],
vec![Some(0.0001)]], 1, false);
++ let column = CString::new("vectors").unwrap();
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for count in [1, 2] {
++ for batch_size in [1, 1024] {
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [0.0f32, 0.0].as_ptr().cast(),
++ 1,
++ count,
++ 0,
++ 1
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, false), 0);
++ assert_eq!(lance_scanner_set_batch_size(scan, batch_size), 0);
++ let rows = collect(scan);
++ assert_eq!(rows.len(), 1);
++ assert_eq!(rows[0].0, 2);
++ assert!((rows[0].1 - count as f32 * 1e-8).abs() < 1e-14,
"{rows:?}");
++ lance_scanner_close(scan);
++ }
++ }
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn indexed_top_one_is_independent_of_child_batch_boundaries() {
++ let rows = vec![
++ vec![Some(1.), Some(0.)],
++ vec![Some(0.), Some(1.)],
++ vec![Some(1.), Some(1.)],
++ vec![Some(2.), Some(1.)],
++ vec![Some(1.), Some(2.)],
++ ];
++ let (_dir, uri) = custom_fixture(rows, 2, true);
++ let column = CString::new("vectors").unwrap();
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for batch_size in [1, 2, 1024] {
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [1.0f32, 0., 0., 1.].as_ptr().cast(),
++ 2,
++ 2,
++ 0,
++ 1
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_metric(scan, 1), 0);
++ assert_eq!(lance_scanner_set_refine_factor(scan, 1), 0);
++ assert_eq!(lance_scanner_set_batch_size(scan, batch_size), 0);
++ let result = collect(scan);
++ assert_eq!(result[0].0, 3, "batch_size={batch_size}");
++ assert!((result[0].1 - (2.0 - 2.0f32.sqrt())).abs() < 1e-6);
++ lance_scanner_close(scan);
++ }
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn actual_null_and_nonfinite_stored_elements_fail_search() {
++ let column = CString::new("vectors").unwrap();
++ for value in [
++ None,
++ Some(f32::NAN),
++ Some(f32::INFINITY),
++ Some(f32::NEG_INFINITY),
++ ] {
++ let (_dir, uri) = custom_fixture(vec![vec![value, Some(0.)]], 2,
false);
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [0.0f32, 0.].as_ptr().cast(),
++ 2,
++ 1,
++ 0,
++ 1
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, false), 0);
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(lance_scanner_to_arrow_stream(scan, &mut stream), 0);
++ let mut reader = ArrowArrayStreamReader::from_raw(&mut
stream).unwrap();
++ let result = reader.next();
++ assert!(
++ matches!(result, Some(Err(_))),
++ "value={value:?}: {result:?}"
++ );
++ drop(reader);
++ lance_scanner_close(scan);
++ lance_dataset_close(ds);
++ }
++ }
++}
++
++#[test]
++fn rejects_excessive_multivector_plan_width_and_candidate_work() {
++ let (_dir, uri) = fixture();
++ let column = CString::new("vectors").unwrap();
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ let q = [0.0f32; 258];
++ for (count, k) in [(129, 1), (128, 782), (1, 100001)] {
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ q.as_ptr().cast(),
++ 2,
++ count,
++ 0,
++ k
++ ),
++ -1
++ );
++ }
++ lance_scanner_close(scan);
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn omitted_metric_is_l2_on_indexed_and_unindexed_fragments() {
++ let (_dir, uri) = custom_fixture(vec![vec![Some(2.), Some(0.)]], 2, true);
++ lance_c::runtime::block_on(async {
++ let mut ds = Dataset::open(uri.to_str().unwrap()).await.unwrap();
++ let old = ds.scan().try_into_batch().await.unwrap();
++ let vectors = FixedSizeListArray::try_new(
++ Arc::new(Field::new("item", DataType::Float32, true)),
++ 2,
++ Arc::new(Float32Array::from(vec![1.0, 0.1])),
++ None,
++ )
++ .unwrap();
++ let lists = ListArray::try_new(
++ Arc::new(Field::new("item", vectors.data_type().clone(), false)),
++ OffsetBuffer::new(vec![0i32, 1].into()),
++ Arc::new(vectors),
++ None,
++ )
++ .unwrap();
++ let batch = RecordBatch::try_new(
++ old.schema(),
++ vec![Arc::new(Int32Array::from(vec![2])), Arc::new(lists)],
++ )
++ .unwrap();
++ ds.append(
++ RecordBatchIterator::new(vec![Ok(batch)], old.schema()),
++ None,
++ )
++ .await
++ .unwrap();
++ });
++ let column = CString::new("vectors").unwrap();
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for (fragment, id, score) in [(0u64, 1, 1.0f32), (1u64, 2, 0.01f32)] {
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [1.0f32, 0.].as_ptr().cast(),
++ 2,
++ 1,
++ 0,
++ 1
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_prefilter(scan, true), 0);
++ assert_eq!(lance_scanner_set_fragment_ids(scan, &fragment, 1), 0);
++ let rows = collect(scan);
++ assert_eq!(rows[0].0, id);
++ assert!(
++ (rows[0].1 - score).abs() < 1e-6,
++ "fragment={fragment}: {rows:?}"
++ );
++ lance_scanner_close(scan);
++ }
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn query_values_and_refinement_are_validated_before_execution() {
++ let (_dir, uri) = fixture();
++ let column = CString::new("vectors").unwrap();
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [value, 0.0f32].as_ptr().cast(),
++ 2,
++ 1,
++ 0,
++ 1
++ ),
++ -1
++ );
++ }
++ let query = [1.0f32; 256];
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ query.as_ptr().cast(),
++ 2,
++ 128,
++ 0,
++ 781
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_metric(scan, 3), 0);
++ let mut invalid_metric_stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(
++ lance_scanner_to_arrow_stream(scan, &mut invalid_metric_stream),
++ -1
++ );
++ assert_eq!(lance_scanner_set_metric(scan, 0), 0);
++ assert_eq!(lance_scanner_set_refine_factor(scan, u32::MAX), 0);
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(lance_scanner_to_arrow_stream(scan, &mut stream), -1);
++ lance_scanner_close(scan);
++ lance_dataset_close(ds);
++ }
++}
++
++#[test]
++fn float16_and_float64_queries_support_all_distance_metrics() {
++ let (dir, source) = fixture();
++ for (code, dtype) in [(1, DataType::Float16), (2, DataType::Float64)] {
++ let path = dir.path().join(format!("typed_{code}.lance"));
++ lance_c::runtime::block_on(async {
++ let source =
Dataset::open(source.to_str().unwrap()).await.unwrap();
++ let batch = source.scan().try_into_batch().await.unwrap();
++ let vector_type = DataType::List(Arc::new(Field::new(
++ "item",
++ DataType::FixedSizeList(Arc::new(Field::new("item", dtype,
true)), 2),
++ false,
++ )));
++ let vectors =
++
arrow::compute::cast(batch.column_by_name("vectors").unwrap(), &vector_type)
++ .unwrap();
++ let schema = Arc::new(Schema::new(vec![
++ Field::new("id", DataType::Int32, false),
++ Field::new("vectors", vector_type, true),
++ ]));
++ let batch =
++ RecordBatch::try_new(schema.clone(),
vec![batch.column(0).clone(), vectors])
++ .unwrap();
++ Dataset::write(
++ RecordBatchIterator::new(vec![Ok(batch)], schema),
++ path.to_str().unwrap(),
++ None,
++ )
++ .await
++ .unwrap();
++ });
++ let uri = CString::new(path.to_str().unwrap()).unwrap();
++ let column = CString::new("vectors").unwrap();
++ let f16_query = [1.0f32, 0., 0., 1.].map(half::f16::from_f32);
++ let f64_query = [1.0f64, 0., 0., 1.];
++ let query = if code == 1 {
++ f16_query.as_ptr().cast()
++ } else {
++ f64_query.as_ptr().cast()
++ };
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for (metric, expected) in [(0, [0., 6., 8.]), (1, [0., 1., 0.]),
(2, [0., 0., -4.])] {
++ let scanner = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scanner,
++ column.as_ptr(),
++ query,
++ 2,
++ 2,
++ code,
++ 10
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_metric(scanner, metric), 0);
++ assert_eq!(lance_scanner_set_use_index(scanner, false), 0);
++ let mut rows = collect(scanner);
++ rows.sort_by_key(|row| row.0);
++ assert_eq!(
++ rows,
++ vec![(1, expected[0]), (2, expected[1]), (3, expected[2])]
++ );
++ lance_scanner_close(scanner);
++ }
++ lance_dataset_close(ds);
++ }
++ }
++}
++
++#[test]
++fn cosine_zero_norm_rows_do_not_abort_exact_or_refined_search() {
++ let rows = vec![
++ vec![Some(0.), Some(0.)],
++ vec![Some(1.), Some(0.)],
++ vec![Some(0.), Some(0.), Some(0.), Some(1.)],
++ ];
++ for indexed in [false, true] {
++ let (_dir, uri) = custom_fixture(rows.clone(), 2, indexed);
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for query in [[1.0f32, 0., 0., 1.], [0., 0., 0., 1.]] {
++ for count in [1, 2] {
++ let scan = lance_scanner_new(ds, ptr::null(),
ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ c"vectors".as_ptr(),
++ query.as_ptr().cast(),
++ 2,
++ count,
++ 0,
++ 3
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, indexed), 0);
++ assert_eq!(lance_scanner_set_metric(scan, 1), 0);
++ assert_eq!(lance_scanner_set_refine_factor(scan, 2), 0);
++ let mut actual = collect(scan);
++ actual.sort_by_key(|row| row.0);
++ let expected = if query[0] == 0. {
++ vec![]
++ } else if count == 1 {
++ vec![(2, 0.), (3, 1.)]
++ } else {
++ vec![(2, 1.), (3, 1.)]
++ };
++ assert_eq!(
++ actual, expected,
++ "indexed={indexed}, query={query:?}, count={count}"
++ );
++ lance_scanner_close(scan);
++ }
++ }
++ lance_dataset_close(ds);
++ }
++ }
++}
++
++#[test]
++fn nested_and_quoted_columns_support_exact_and_refined_search() {
++ for column in [
++ "payload.vectors",
++ "payload.`vectors.with.dot`",
++ "payload.inner.`vectors.with.dot`",
++ ] {
++ let rows = vec![
++ vec![Some(1.), Some(0.), Some(0.), Some(1.)],
++ vec![Some(2.), Some(0.)],
++ vec![Some(1.), Some(1.)],
++ ];
++ for indexed in [false, true] {
++ let (_dir, uri) = custom_fixture_at(rows.clone(), 2, indexed,
column);
++ let column = CString::new(column).unwrap();
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [1.0f32, 0., 0., 1.].as_ptr().cast(),
++ 2,
++ 2,
++ 0,
++ 3
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, indexed), 0);
++ assert_eq!(lance_scanner_set_metric(scan, 1), 0);
++ assert_eq!(lance_scanner_set_refine_factor(scan, 2), 0);
++ let actual = collect(scan);
++ assert_eq!(actual.len(), 3);
++ assert_eq!(
++ actual.iter().map(|row| row.0).collect::<Vec<_>>(),
++ vec![1, 3, 2]
++ );
++ assert_eq!(actual[0].1, 0.);
++ assert!((actual[1].1 - (2. - 2.0f32.sqrt())).abs() < 1e-6);
++ assert_eq!(actual[2].1, 1.);
++ lance_scanner_close(scan);
++ lance_dataset_close(ds);
++ }
++ }
++ }
++}
++
++#[test]
++fn
nested_projections_preserve_schema_and_values_in_exact_indexed_and_hybrid_search()
{
++ for column in ["payload.vectors", "payload.`vectors.with.dot`"] {
++ for indexed in [false, true] {
++ for appended in [false, true] {
++ let (_dir, uri) = custom_fixture_at(
++ vec![vec![Some(1.), Some(0.)], vec![Some(0.), Some(1.)]],
++ 2,
++ indexed,
++ column,
++ );
++ if appended {
++ lance_c::runtime::block_on(async {
++ let mut ds =
Dataset::open(uri.to_str().unwrap()).await.unwrap();
++ let batch = ds.scan().try_into_batch().await.unwrap();
++ ds.append(
++ RecordBatchIterator::new(vec![Ok(batch.clone())],
batch.schema()),
++ None,
++ )
++ .await
++ .unwrap();
++ });
++ }
++ for projection in [
++ vec![],
++ vec!["id"],
++ vec!["payload.label"],
++ vec!["id", column],
++ ] {
++ let expected = lance_c::runtime::block_on(async {
++ let ds =
Dataset::open(uri.to_str().unwrap()).await.unwrap();
++ let mut scanner = ds.scan();
++ scanner.scan_in_order(true);
++ if !projection.is_empty() {
++ scanner.project(&projection).unwrap();
++ }
++ scanner.try_into_batch().await.unwrap()
++ });
++ // The appended fragment repeats the same two values;
distance order groups
++ // both exact matches before the orthogonal rows,
regardless of tie order.
++ let indices = arrow_array::UInt32Array::from(if appended {
++ vec![0, 2, 1, 3]
++ } else {
++ vec![0, 1]
++ });
++ let expected = RecordBatch::try_new(
++ expected.schema(),
++ expected
++ .columns()
++ .iter()
++ .map(|array| {
++ arrow::compute::take(array.as_ref(),
&indices, None).unwrap()
++ })
++ .collect(),
++ )
++ .unwrap();
++ unsafe {
++ let names: Vec<_> = projection
++ .iter()
++ .map(|name| CString::new(*name).unwrap())
++ .collect();
++ let mut columns: Vec<_> = names.iter().map(|name|
name.as_ptr()).collect();
++ columns.push(ptr::null());
++ let ds = lance_dataset_open(uri.as_ptr(),
ptr::null(), 0);
++ let scan = lance_scanner_new(
++ ds,
++ if projection.is_empty() {
++ ptr::null()
++ } else {
++ columns.as_ptr()
++ },
++ ptr::null(),
++ );
++ let column = CString::new(column).unwrap();
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ column.as_ptr(),
++ [1.0f32, 0.].as_ptr().cast(),
++ 2,
++ 1,
++ 0,
++ 10
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan,
indexed), 0);
++ assert_eq!(lance_scanner_set_metric(scan, 1), 0);
++ assert_eq!(lance_scanner_set_batch_size(scan, 1), 0);
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(lance_scanner_to_arrow_stream(scan, &mut
stream), 0);
++ let batches = ArrowArrayStreamReader::from_raw(&mut
stream)
++ .unwrap()
++ .collect::<Result<Vec<_>, _>>()
++ .unwrap();
++ lance_scanner_close(scan);
++ lance_dataset_close(ds);
++ let actual =
++
arrow::compute::concat_batches(&batches[0].schema(), &batches).unwrap();
++ let distances = actual
++ .column_by_name("_distance")
++ .unwrap()
++ .as_any()
++ .downcast_ref::<Float32Array>()
++ .unwrap();
++ assert_eq!(
++ distances.values().as_ref(),
++ if appended {
++ &[0., 0., 1., 1.][..]
++ } else {
++ &[0., 1.][..]
++ }
++ );
++ let positions: Vec<_> = actual
++ .schema()
++ .fields()
++ .iter()
++ .enumerate()
++ .filter_map(|(i, field)| (field.name() !=
"_distance").then_some(i))
++ .collect();
++ assert_eq!(
++ actual.project(&positions).unwrap(),
++ expected,
++ "column={column:?} indexed={indexed}
appended={appended} projection={projection:?}"
++ );
++ }
++ }
++ }
++ }
++ }
++}
++
++#[test]
++fn strict_batches_apply_after_multivector_offset_and_limit() {
++ let (_dir, uri) = custom_fixture(
++ (1..=6).map(|i| vec![Some(i as f32), Some(0.)]).collect(),
++ 2,
++ false,
++ );
++ unsafe {
++ let ds = lance_dataset_open(uri.as_ptr(), ptr::null(), 0);
++ for batch_size in [Some(2), None] {
++ for (offset, limit) in [(1, 4), (1, 3), (5, 4), (6, 4), (0, 6)] {
++ let scan = lance_scanner_new(ds, ptr::null(), ptr::null());
++ assert_eq!(
++ lance_scanner_nearest_multivector(
++ scan,
++ c"vectors".as_ptr(),
++ [1.0f32, 0.].as_ptr().cast(),
++ 2,
++ 1,
++ 0,
++ 6
++ ),
++ 0
++ );
++ assert_eq!(lance_scanner_set_use_index(scan, false), 0);
++ if let Some(size) = batch_size {
++ assert_eq!(lance_scanner_set_batch_size(scan, size), 0);
++ }
++ assert_eq!(lance_scanner_set_strict_batch_size(scan, true),
0);
++ assert_eq!(lance_scanner_set_offset(scan, offset), 0);
++ assert_eq!(lance_scanner_set_limit(scan, limit), 0);
++ let mut stream = FFI_ArrowArrayStream::empty();
++ assert_eq!(lance_scanner_to_arrow_stream(scan, &mut stream),
0);
++ let batches = ArrowArrayStreamReader::from_raw(&mut stream)
++ .unwrap()
++ .collect::<Result<Vec<_>, _>>()
++ .unwrap();
++ lance_scanner_close(scan);
++ let sizes: Vec<_> =
batches.iter().map(RecordBatch::num_rows).collect();
++ let ids: Vec<_> = batches
++ .iter()
++ .flat_map(|b| {
++ b.column_by_name("id")
++ .unwrap()
++ .as_any()
++ .downcast_ref::<Int32Array>()
++ .unwrap()
++ .values()
++ .to_vec()
++ })
++ .collect();
++ let expected_ids: Vec<_> =
++ (1..=6).skip(offset as usize).take(limit as
usize).collect();
++ let expected_sizes: Vec<_> = expected_ids
++ .chunks(batch_size.unwrap_or(8192) as usize)
++ .map(<[i32]>::len)
++ .collect();
++ assert_eq!(ids, expected_ids);
++ assert_eq!(
++ sizes, expected_sizes,
++ "batch_size={batch_size:?} offset={offset} limit={limit}"
++ );
++ }
++ }
++ lance_dataset_close(ds);
++ }
++}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]