Gabriel39 commented on code in PR #68028:
URL: https://github.com/apache/doris/pull/68028#discussion_r4021778050


##########
be/src/format_v2/table/lance_reader.cpp:
##########
@@ -1235,6 +1265,26 @@ Status LanceTableReader::_fill_block_from_record_batch(
                                     
->read_column_from_arrow(*columns[output_idx],
                                                              
normalized_column.get(), 0, row_count,
                                                              _ctz));
+            if (_search_kind == SearchKind::VECTOR && field->name() == 
LANCE_DISTANCE_COLUMN) {
+                const auto& query = 
_scan_params->lance_scan_params.external_search_request
+                                            
.search_query.vector_search.query_vector;
+                if (query.__isset.num_vectors) {
+                    // Lance's exact score is 1 - sum(1 - distance). Expose 
sum(min distance)
+                    // so one logical row has the same scoring contract for 
every query size.
+                    IColumn* output = columns[output_idx].get();
+                    if (auto* nullable = 
check_and_get_column<ColumnNullable>(*output)) {
+                        output = &nullable->get_nested_column();
+                    }
+                    auto* distances = 
check_and_get_column<ColumnFloat32>(*output);
+                    if (distances == nullptr) {
+                        return Status::InternalError("Lance multi-vector 
distance must be FLOAT");
+                    }
+                    auto& data = distances->get_data();
+                    for (size_t i = data.size() - row_count; i < data.size(); 
++i) {
+                        data[i] += static_cast<float>(query.num_vectors - 1);

Review Comment:
   Fixed in cd8255cb4c. The Lance-C scoring adapter now accumulates 
sum(min(distance)) directly before the plan's TopK operators, using an f64 
accumulator with the existing f32 distance kernels/output. Removed the BE 
post-truncation normalization. Native, BE and SQL regression cases assert the 
winning row and score for top_k=1 with L2 distances around 1e-8 and 2.25e-8.



##########
thirdparty/patches/lance-c-0.1.9-multivector.patch:
##########
@@ -0,0 +1,508 @@
+diff --git a/src/scanner.rs b/src/scanner.rs
+--- a/src/scanner.rs
++++ b/src/scanner.rs
+@@ -226,8 +226,18 @@
+         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);
+@@ -300,6 +310,12 @@
+         Ok(PreparedScanner {
+             scanner,
+             distributed_fts,
++            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(),
+         })
+     }
+ }
+@@ -314,10 +330,45 @@
+ struct PreparedScanner {
+     scanner: lance::dataset::scanner::Scanner,
+     distributed_fts: Option<PreparedFtsExecution>,
++    multi_vector_window: Option<(usize, Option<usize>)>,
++    batch_size: Option<usize>,
++    scan_statistics_callback: Option<ExecutionStatsCallback>,
+ }
+
+ impl PreparedScanner {
+     async fn try_into_stream(self) -> Result<DatasetRecordBatchStream> {
++        if let Some((offset, limit)) = self.multi_vector_window {
++            use datafusion::physical_expr::{PhysicalSortExpr, expressions};
++            use datafusion::physical_plan::{
++                coalesce_partitions::CoalescePartitionsExec, 
limit::GlobalLimitExec,
++                sorts::sort::SortExec,
++            };
++            let plan = self.scanner.create_plan().await?;
++            let sort = PhysicalSortExpr {
++                expr: expressions::col("_distance", plan.schema().as_ref())?,
++                options: arrow::compute::SortOptions {
++                    descending: false,
++                    nulls_first: false,
++                },
++            };
++            // Fragment-scoped Lance plans can reorder candidate batches 
during payload take.
++            // Apply the result window only after restoring distance order 
across all partitions.
++            // The nearest plan already bounds the candidate rows by k.
++            let sorted = Arc::new(SortExec::new(
++                [sort].into(),
++                Arc::new(CoalescePartitionsExec::new(plan)),
++            ));
++            let plan = Arc::new(GlobalLimitExec::new(sorted, offset, limit));
++            let stream = lance_datafusion::exec::execute_plan(
++                plan,
++                lance_datafusion::exec::LanceExecutionOptions {
++                    batch_size: self.batch_size,

Review Comment:
   Fixed in cd8255cb4c. Indexed scoring reduces each logical query child over 
its complete stream before combining query contributions. The truncating Top1 
regression uses batch sizes 1, 2 and 1024 and asserts the same winning row and 
distance; it reproduces the wrong winner with the previous library. Covered in 
native, BE and SQL tests.



##########
docs/lance-multivector-search.md:
##########
@@ -0,0 +1,105 @@
+<!--
+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.
+-->
+
+# Lance multi-vector search
+
+A Lance `List<FixedSizeList<T, D>>` column stores a variable number of 
D-dimensional
+subvectors in each table row. Doris exposes it as `ARRAY<ARRAY<FLOAT>>` for 
Float16
+and Float32, or `ARRAY<ARRAY<DOUBLE>>` for Float64.
+
+`vector_search` accepts a non-empty JSON matrix for such a column. Every inner
+array must contain exactly D finite numeric values representable in the 
column's
+element type. A matrix with one inner array is still one multi-vector query.
+This is not a batch of independent searches or a search across several columns.
+Ordinary `FixedSizeList` columns continue to accept a one-dimensional JSON 
array.
+
+```sql
+SELECT id, _distance
+FROM vector_search(
+    "table" = "lance_catalog.default.documents",
+    "column" = "embeddings",
+    "query_vector" = "[[1,0],[0,1]]",
+    "top_k" = "10",
+    "metric" = "cosine",
+    "filter" = "id > 100"
+)
+ORDER BY _distance, id;
+```
+
+## Row-level score and filtering
+
+For query subvectors Q and a row's subvectors V, Doris reports:
+
+`_distance = sum(q in Q, min(v in V, distance(q, v)))`
+
+Smaller is better. The base distance is squared Euclidean distance for `l2`,
+`1 - cosine_similarity` for `cosine`, or `1 - dot_product` for `dot`. Each 
query
+subvector contributes once; the same stored subvector may match several query
+subvectors. The score is a sum, not an average, so duplicating a query 
subvector
+changes the score. `_distance` uses Doris FLOAT, including for Float64 inputs.
+
+`top_k` and `offset` count table rows, not subvectors. The TVF `filter` is 
applied
+before candidate search; an outer SQL WHERE filters the search's results.
+Distributed fragments return row candidates for the global TopK. Ties at the
+TopK boundary have no guaranteed row order.
+
+`use_index=false` computes exact scores over the selected rows. With indexes,
+candidate selection is approximate. Doris always refines multi-vector 
candidates
+against their original values so indexed and unindexed rows use the same score.
+`refine_factor` can increase the candidate pool beyond this default refinement.
+It does not turn ANN candidate selection into an exhaustive search.
+
+The pinned Lance version supports multi-vector indexes with the cosine metric.
+Use `use_index=false` for L2 or dot searches. `nprobes`, `ef`, and 
`refine_factor`

Review Comment:
   Fixed in cd8255cb4c. FE materializes L2 when metric is omitted, split 
construction preserves that resolved metric, and BE explicitly configures it on 
every scanner. The new C API also defaults multi-vector searches to L2. Added 
omitted-metric tests over a cosine-indexed fragment plus an appended fragment, 
checking comparable L2 distances and final ordering.



##########
be/src/format_v2/table/lance_reader.cpp:
##########
@@ -537,13 +538,27 @@ Status 
LanceTableReader::_validate_external_search_request() const {
             return Status::NotSupported("unsupported Lance query vector 
element type: {}",
                                         
static_cast<int>(query_vector.element_type));
         }
+        const bool multi_vector = query_vector.__isset.num_vectors;
+        if (multi_vector != (request.schema_version == 2) ||
+            (multi_vector && query_vector.num_vectors <= 0)) {

Review Comment:
   Fixed in cd8255cb4c. FE, BE and the C API cap queries at 128 subvectors and 
enforce a 100000 budget for num_vectors * candidate_k and refine_factor * 
candidate_k, where Doris candidate_k includes the offset. Checks precede ANN 
plan construction and use division/checked arithmetic to avoid overflow. Tests 
cover accepted boundaries, excessive counts, offset expansion and excessive 
refinement.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to