This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 1a44193ec2 [global-index] Fix full-text range selection and
scalar-filter coverage for multi-field indexes (#8547)
1a44193ec2 is described below
commit 1a44193ec2bfa57a3c0dff18b67192e657e1c5d6
Author: CrownChu <[email protected]>
AuthorDate: Tue Jul 14 14:17:20 2026 +0800
[global-index] Fix full-text range selection and scalar-filter coverage for
multi-field indexes (#8547)
A global index may contain one primary field and multiple extra fields.
However, the current scan paths assume that:
1. a full-text search column must be the primary index field; and
2. a vector-primary index cannot also serve scalar predicates on its
extra fields.
These assumptions prevent multi-field indexes from serving full-text and
scalar queries correctly. They may also cause indexed row ranges to be
skipped when different index definitions cover different ranges of the
same column.
---
.../paimon/globalindex/GlobalIndexEvaluator.java | 69 ++++-
.../paimon/globalindex/UnionGlobalIndexReader.java | 9 +-
.../globalindex/GlobalIndexEvaluatorTest.java | 105 +++++++-
.../TestFullTextGlobalIndexerFactory.java | 20 ++
.../TestMultiFieldVectorGlobalIndexer.java | 261 +++++++++++++++++++
.../testvector/TestVectorGlobalIndexerFactory.java | 11 +
.../paimon/globalindex/GlobalIndexScanner.java | 49 ++--
.../table/source/DataEvolutionVectorScan.java | 22 +-
.../paimon/table/source/FullTextReadImpl.java | 58 +++--
.../paimon/table/source/FullTextScanImpl.java | 284 +++++++++++++++++++--
.../table/source/IndexFullTextSearchSplit.java | 41 ++-
.../table/source/FullTextSearchBuilderTest.java | 225 +++++++++++++++-
.../table/source/VectorSearchBuilderTest.java | 130 +++++++++-
13 files changed, 1193 insertions(+), 91 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
index 5343df6117..0f8a0e3ccd 100644
---
a/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
+++
b/paimon-common/src/main/java/org/apache/paimon/globalindex/GlobalIndexEvaluator.java
@@ -20,7 +20,13 @@ package org.apache.paimon.globalindex;
import org.apache.paimon.predicate.CompoundPredicate;
import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.IsNaN;
+import org.apache.paimon.predicate.IsNotNull;
+import org.apache.paimon.predicate.LeafBinaryFunction;
+import org.apache.paimon.predicate.LeafFunction;
+import org.apache.paimon.predicate.LeafNAryFunction;
import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.LeafTernaryFunction;
import org.apache.paimon.predicate.Or;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.types.RowType;
@@ -33,9 +39,11 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
@@ -125,7 +133,8 @@ public class GlobalIndexEvaluator implements Closeable {
private CompletableFuture<Optional<GlobalIndexResult>> visitCompoundAsync(
CompoundPredicate predicate) {
- List<Predicate> children = flattenChildren(predicate);
+ List<Predicate> children =
+ pruneRedundantIsNotNullForAnd(flattenChildren(predicate),
predicate);
List<CompletableFuture<Optional<GlobalIndexResult>>> childFutures =
new ArrayList<>(children.size());
for (Predicate child : children) {
@@ -192,6 +201,64 @@ public class GlobalIndexEvaluator implements Closeable {
return result;
}
+ private List<Predicate> pruneRedundantIsNotNullForAnd(
+ List<Predicate> children, CompoundPredicate predicate) {
+ if (predicate.function() instanceof Or) {
+ return children;
+ }
+
+ // Only a null-rejecting sibling makes "f IS NOT NULL" redundant. A
predicate such as
+ // "f IS NULL" must NOT count here: pruning IS NOT NULL from "f IS
NULL AND f IS NOT NULL"
+ // would turn the empty result into the set of null rows. So we
whitelist explicitly
+ // null-rejecting predicates instead of treating every non-IsNotNull
leaf as constraining.
+ Set<String> constrainedFields = new HashSet<>();
+ for (Predicate child : children) {
+ if (!isNullRejecting(child)) {
+ continue;
+ }
+ Optional<FieldRef> fieldRef = ((LeafPredicate)
child).fieldRefOptional();
+ fieldRef.ifPresent(ref -> constrainedFields.add(ref.name()));
+ }
+ if (constrainedFields.isEmpty()) {
+ return children;
+ }
+
+ List<Predicate> pruned = new ArrayList<>(children.size());
+ for (Predicate child : children) {
+ if (isIsNotNull(child)) {
+ Optional<FieldRef> fieldRef = ((LeafPredicate)
child).fieldRefOptional();
+ if (fieldRef.isPresent() &&
constrainedFields.contains(fieldRef.get().name())) {
+ continue;
+ }
+ }
+ pruned.add(child);
+ }
+ return pruned;
+ }
+
+ private boolean isIsNotNull(Predicate predicate) {
+ return predicate instanceof LeafPredicate
+ && ((LeafPredicate) predicate).function() instanceof IsNotNull;
+ }
+
+ /**
+ * A predicate is null-rejecting when it cannot match a row whose tested
field is null. Under
+ * SQL three-valued logic every comparison/match predicate (=, <>,
<, >, IN, NOT IN,
+ * BETWEEN, LIKE, ...) and IS NaN reject null; only IS NULL accepts it
(and IS NOT NULL is the
+ * predicate we are deciding whether to prune). We whitelist by arity base
class so future
+ * comparison functions are covered automatically without re-introducing
the IS NULL hazard.
+ */
+ private boolean isNullRejecting(Predicate predicate) {
+ if (!(predicate instanceof LeafPredicate)) {
+ return false;
+ }
+ LeafFunction function = ((LeafPredicate) predicate).function();
+ return function instanceof LeafBinaryFunction
+ || function instanceof LeafNAryFunction
+ || function instanceof LeafTernaryFunction
+ || function instanceof IsNaN;
+ }
+
@Override
public void close() {
IOUtils.closeAllQuietly(
diff --git
a/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
b/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
index eca23818bd..9914fa1115 100644
---
a/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
+++
b/paimon-common/src/main/java/org/apache/paimon/globalindex/UnionGlobalIndexReader.java
@@ -20,6 +20,7 @@ package org.apache.paimon.globalindex;
import org.apache.paimon.predicate.FieldRef;
import org.apache.paimon.predicate.VectorSearch;
+import org.apache.paimon.utils.IOUtils;
import java.io.IOException;
import java.util.ArrayList;
@@ -183,8 +184,12 @@ public class UnionGlobalIndexReader implements
GlobalIndexReader {
@Override
public void close() throws IOException {
- for (GlobalIndexReader reader : readers) {
- reader.close();
+ try {
+ IOUtils.closeAll(readers);
+ } catch (IOException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException("Failed to close union global index
readers", e);
}
}
}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
b/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
index 9a95d89cd0..b3ffad5263 100644
---
a/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/globalindex/GlobalIndexEvaluatorTest.java
@@ -32,6 +32,7 @@ import org.apache.paimon.utils.RoaringNavigableMap64;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
+import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -40,9 +41,11 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** Tests for {@link GlobalIndexEvaluator}. */
class GlobalIndexEvaluatorTest {
@@ -566,6 +569,79 @@ class GlobalIndexEvaluatorTest {
evaluator.close();
}
+ @Test
+ void testIsNullAndIsNotNullSameFieldIsEmptyNotPruned() {
+ executor = Executors.newFixedThreadPool(2);
+ RowType rowType = rowType();
+
+ // A reader where IS NULL matches the null rows {3,7} and IS NOT NULL
matches the
+ // complementary non-null rows {1,2,4,5}. "a IS NULL AND a IS NOT
NULL" must be the
+ // intersection (empty). The old prune treated IS NULL as a
constraining sibling and
+ // dropped IS NOT NULL, wrongly yielding the null rows {3,7}.
+ GlobalIndexReader nullAwareReader =
+ new StubGlobalIndexReader(null) {
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNull(
+ FieldRef fieldRef) {
+ return
CompletableFuture.completedFuture(Optional.of(resultOf(3, 7)));
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNotNull(
+ FieldRef fieldRef) {
+ return
CompletableFuture.completedFuture(Optional.of(resultOf(1, 2, 4, 5)));
+ }
+ };
+
+ GlobalIndexEvaluator evaluator =
+ new GlobalIndexEvaluator(
+ rowType, fieldId ->
Collections.singletonList(nullAwareReader));
+
+ PredicateBuilder builder = new PredicateBuilder(rowType);
+ Predicate predicate = PredicateBuilder.and(builder.isNull(0),
builder.isNotNull(0));
+
+ Optional<GlobalIndexResult> result = evaluator.evaluate(predicate);
+
+ assertThat(result).isPresent();
+ assertThat(result.get().results().isEmpty()).isTrue();
+ evaluator.close();
+ }
+
+ @Test
+ void testRedundantIsNotNullStillPrunedWithNullRejectingSibling() {
+ executor = Executors.newFixedThreadPool(2);
+ RowType rowType = rowType();
+ AtomicBoolean isNotNullVisited = new AtomicBoolean();
+
+ // "a = 42 AND a IS NOT NULL": a = 42 is null-rejecting, so IS NOT
NULL is redundant and
+ // pruned. An unsupported Optional.empty() child is ignored by AND
evaluation, so the
+ // result alone cannot prove pruning; record whether the visitor is
invoked.
+ GlobalIndexReader equalOnlyReader =
+ new StubGlobalIndexReader(resultOf(1, 2, 3)) {
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNotNull(
+ FieldRef fieldRef) {
+ isNotNullVisited.set(true);
+ // Simulate a reader that cannot serve IS NOT NULL.
+ return
CompletableFuture.completedFuture(Optional.empty());
+ }
+ };
+
+ GlobalIndexEvaluator evaluator =
+ new GlobalIndexEvaluator(
+ rowType, fieldId ->
Collections.singletonList(equalOnlyReader));
+
+ PredicateBuilder builder = new PredicateBuilder(rowType);
+ Predicate predicate = PredicateBuilder.and(builder.equal(0, 42),
builder.isNotNull(0));
+
+ Optional<GlobalIndexResult> result = evaluator.evaluate(predicate);
+
+ assertThat(result).isPresent();
+ assertBitmapContainsExactly(result.get().results(), 1L, 2L, 3L);
+ assertThat(isNotNullVisited).isFalse();
+ evaluator.close();
+ }
+
@Test
void testNullPredicate() {
RowType rowType = rowType();
@@ -578,6 +654,33 @@ class GlobalIndexEvaluatorTest {
evaluator.close();
}
+ @Test
+ void testUnionReaderClosesRemainingReadersAfterFailure() {
+ AtomicBoolean secondClosed = new AtomicBoolean();
+ GlobalIndexReader failingReader =
+ new StubGlobalIndexReader(null) {
+ @Override
+ public void close() throws IOException {
+ throw new IOException("expected close failure");
+ }
+ };
+ GlobalIndexReader secondReader =
+ new StubGlobalIndexReader(null) {
+ @Override
+ public void close() {
+ secondClosed.set(true);
+ }
+ };
+
+ UnionGlobalIndexReader union =
+ new UnionGlobalIndexReader(Arrays.asList(failingReader,
secondReader));
+
+ assertThatThrownBy(union::close)
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("expected close failure");
+ assertThat(secondClosed).isTrue();
+ }
+
private static void assertBitmapContainsExactly(
RoaringNavigableMap64 bitmap, long... expected) {
assertThat(bitmap.getLongCardinality()).isEqualTo(expected.length);
@@ -677,6 +780,6 @@ class GlobalIndexEvaluatorTest {
}
@Override
- public void close() {}
+ public void close() throws IOException {}
}
}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexerFactory.java
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexerFactory.java
index e381fef383..e8894affbe 100644
---
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexerFactory.java
+++
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testfulltext/TestFullTextGlobalIndexerFactory.java
@@ -22,6 +22,9 @@ import org.apache.paimon.globalindex.GlobalIndexer;
import org.apache.paimon.globalindex.GlobalIndexerFactory;
import org.apache.paimon.options.Options;
import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.VarCharType;
+
+import java.util.List;
/**
* A test-only {@link GlobalIndexerFactory} for full-text search. Uses
brute-force in-memory
@@ -45,4 +48,21 @@ public class TestFullTextGlobalIndexerFactory implements
GlobalIndexerFactory {
public GlobalIndexer create(DataField field, Options options) {
return new TestFullTextGlobalIndexer(field.type(), options);
}
+
+ @Override
+ public GlobalIndexer create(
+ DataField indexField, List<DataField> extraFields, Options
options) {
+ // Multi-column support: this brute-force backend indexes a single
text column, which may be
+ // the primary field or an extra field. Pick the VARCHAR/STRING column
among them.
+ DataField textField = indexField;
+ if (!(textField.type() instanceof VarCharType) && extraFields != null)
{
+ for (DataField extra : extraFields) {
+ if (extra.type() instanceof VarCharType) {
+ textField = extra;
+ break;
+ }
+ }
+ }
+ return new TestFullTextGlobalIndexer(textField.type(), options);
+ }
}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestMultiFieldVectorGlobalIndexer.java
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestMultiFieldVectorGlobalIndexer.java
new file mode 100644
index 0000000000..e984dd03b2
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestMultiFieldVectorGlobalIndexer.java
@@ -0,0 +1,261 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.globalindex.testvector;
+
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.globalindex.GlobalIndexIOMeta;
+import org.apache.paimon.globalindex.GlobalIndexMultiColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexReader;
+import org.apache.paimon.globalindex.GlobalIndexResult;
+import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
+import org.apache.paimon.globalindex.GlobalIndexWriter;
+import org.apache.paimon.globalindex.ResultEntry;
+import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
+import org.apache.paimon.globalindex.VectorGlobalIndexer;
+import org.apache.paimon.globalindex.btree.BTreeGlobalIndexer;
+import org.apache.paimon.globalindex.io.GlobalIndexFileReader;
+import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.predicate.BatchVectorSearch;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.VectorSearch;
+import org.apache.paimon.types.DataField;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/** Test-only vector indexer supporting one scalar extra field through a
companion B-tree file. */
+class TestMultiFieldVectorGlobalIndexer implements VectorGlobalIndexer {
+
+ private final TestVectorGlobalIndexer vectorIndexer;
+ private final BTreeGlobalIndexer scalarIndexer;
+ private final DataField vectorField;
+ private final DataField scalarField;
+
+ TestMultiFieldVectorGlobalIndexer(
+ DataField vectorField, List<DataField> extraFields, Options
options) {
+ checkArgument(
+ extraFields.size() == 1,
+ "Test multi-field vector index supports exactly one extra
field, but got: %s",
+ extraFields);
+ this.vectorField = vectorField;
+ this.scalarField = extraFields.get(0);
+ this.vectorIndexer = new TestVectorGlobalIndexer(vectorField.type(),
options);
+ this.scalarIndexer = new BTreeGlobalIndexer(scalarField, options);
+ }
+
+ @Override
+ public GlobalIndexWriter createWriter(GlobalIndexFileWriter fileWriter)
throws IOException {
+ return new MultiColumnWriter(
+ (GlobalIndexSingleColumnWriter)
vectorIndexer.createWriter(fileWriter),
+ (GlobalIndexSingleColumnWriter)
scalarIndexer.createWriter(fileWriter),
+ vectorField,
+ scalarField);
+ }
+
+ @Override
+ public GlobalIndexReader createReader(
+ GlobalIndexFileReader fileReader,
+ List<GlobalIndexIOMeta> files,
+ ExecutorService executor) {
+ List<GlobalIndexIOMeta> vectorFiles = new ArrayList<>();
+ List<GlobalIndexIOMeta> scalarFiles = new ArrayList<>();
+ for (GlobalIndexIOMeta file : files) {
+ // The test vector format has no file metadata; the B-tree
companion always has it.
+ (file.metadata() == null ? vectorFiles : scalarFiles).add(file);
+ }
+ checkArgument(vectorFiles.size() == 1, "Expected one test vector file,
got: %s", files);
+ checkArgument(
+ scalarFiles.size() == 1, "Expected one scalar companion file,
got: %s", files);
+ return new MultiColumnReader(
+ vectorIndexer.createReader(fileReader, vectorFiles, executor),
+ scalarIndexer.createReader(fileReader, scalarFiles, executor));
+ }
+
+ @Override
+ public String metric() {
+ return vectorIndexer.metric();
+ }
+
+ private static class MultiColumnWriter implements
GlobalIndexMultiColumnWriter {
+
+ private final GlobalIndexSingleColumnWriter vectorWriter;
+ private final GlobalIndexSingleColumnWriter scalarWriter;
+ private final InternalRow.FieldGetter vectorGetter;
+ private final InternalRow.FieldGetter scalarGetter;
+
+ private MultiColumnWriter(
+ GlobalIndexSingleColumnWriter vectorWriter,
+ GlobalIndexSingleColumnWriter scalarWriter,
+ DataField vectorField,
+ DataField scalarField) {
+ this.vectorWriter = vectorWriter;
+ this.scalarWriter = scalarWriter;
+ this.vectorGetter =
InternalRow.createFieldGetter(vectorField.type(), 0);
+ this.scalarGetter =
InternalRow.createFieldGetter(scalarField.type(), 1);
+ }
+
+ @Override
+ public void write(long rowId, @Nullable InternalRow row) {
+ Object vector = row == null ? null :
vectorGetter.getFieldOrNull(row);
+ Object scalar = row == null ? null :
scalarGetter.getFieldOrNull(row);
+ vectorWriter.write(vector, rowId);
+ scalarWriter.write(scalar, rowId);
+ }
+
+ @Override
+ public List<ResultEntry> finish() {
+ List<ResultEntry> results = new ArrayList<>();
+ results.addAll(vectorWriter.finish());
+ results.addAll(scalarWriter.finish());
+ return results;
+ }
+ }
+
+ /**
+ * Routes vector operations to the vector file and scalar predicates to
its B-tree companion.
+ */
+ private static class MultiColumnReader implements GlobalIndexReader {
+
+ private final GlobalIndexReader vectorReader;
+ private final GlobalIndexReader scalarReader;
+
+ private MultiColumnReader(GlobalIndexReader vectorReader,
GlobalIndexReader scalarReader) {
+ this.vectorReader = vectorReader;
+ this.scalarReader = scalarReader;
+ }
+
+ @Override
+ public CompletableFuture<Optional<ScoredGlobalIndexResult>>
visitVectorSearch(
+ VectorSearch vectorSearch) {
+ return vectorReader.visitVectorSearch(vectorSearch);
+ }
+
+ @Override
+ public CompletableFuture<List<Optional<ScoredGlobalIndexResult>>>
visitBatchVectorSearch(
+ BatchVectorSearch batchVectorSearch) {
+ return vectorReader.visitBatchVectorSearch(batchVectorSearch);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNotNull(FieldRef fieldRef) {
+ return scalarReader.visitIsNotNull(fieldRef);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitIsNull(FieldRef fieldRef) {
+ return scalarReader.visitIsNull(fieldRef);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitStartsWith(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitStartsWith(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitEndsWith(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitEndsWith(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitContains(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitContains(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitLike(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitLike(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitLessThan(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitLessThan(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>>
visitGreaterOrEqual(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitGreaterOrEqual(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitNotEqual(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitNotEqual(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitLessOrEqual(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitLessOrEqual(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitEqual(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitEqual(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitGreaterThan(
+ FieldRef fieldRef, Object literal) {
+ return scalarReader.visitGreaterThan(fieldRef, literal);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitIn(
+ FieldRef fieldRef, List<Object> literals) {
+ return scalarReader.visitIn(fieldRef, literals);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitNotIn(
+ FieldRef fieldRef, List<Object> literals) {
+ return scalarReader.visitNotIn(fieldRef, literals);
+ }
+
+ @Override
+ public CompletableFuture<Optional<GlobalIndexResult>> visitBetween(
+ FieldRef fieldRef, Object from, Object to) {
+ return scalarReader.visitBetween(fieldRef, from, to);
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ vectorReader.close();
+ } finally {
+ scalarReader.close();
+ }
+ }
+ }
+}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerFactory.java
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerFactory.java
index 92c0d8126c..04543f7919 100644
---
a/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerFactory.java
+++
b/paimon-common/src/test/java/org/apache/paimon/globalindex/testvector/TestVectorGlobalIndexerFactory.java
@@ -23,6 +23,8 @@ import org.apache.paimon.globalindex.GlobalIndexerFactory;
import org.apache.paimon.options.Options;
import org.apache.paimon.types.DataField;
+import java.util.List;
+
/**
* A test-only {@link GlobalIndexerFactory} for vector similarity search. Uses
brute-force linear
* scan, no native dependencies required.
@@ -40,4 +42,13 @@ public class TestVectorGlobalIndexerFactory implements
GlobalIndexerFactory {
public GlobalIndexer create(DataField field, Options options) {
return new TestVectorGlobalIndexer(field.type(), options);
}
+
+ @Override
+ public GlobalIndexer create(
+ DataField indexField, List<DataField> extraFields, Options
options) {
+ if (extraFields == null || extraFields.isEmpty()) {
+ return create(indexField, options);
+ }
+ return new TestMultiFieldVectorGlobalIndexer(indexField, extraFields,
options);
+ }
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
index 1385970b24..e9ec45b041 100644
---
a/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
+++
b/paimon-core/src/main/java/org/apache/paimon/globalindex/GlobalIndexScanner.java
@@ -119,23 +119,33 @@ public class GlobalIndexScanner implements Closeable {
IntFunction<Collection<GlobalIndexReader>> readersFunction =
fId -> {
+ List<IndexMetaFileGroup> groups = new ArrayList<>();
IndexMetaFileGroup group = indexMetas.get(fId);
if (group != null) {
- return createReaders(indexFileReader, group, rowType,
Long.MIN_VALUE);
+ groups.add(group);
}
List<IndexMetaFileGroup> extraGroups =
extraIndexMetas.get(fId);
- if (extraGroups == null || extraGroups.isEmpty()) {
- return Collections.emptyList();
+ if (extraGroups != null) {
+ for (IndexMetaFileGroup extraGroup : extraGroups) {
+ if (!groups.contains(extraGroup)) {
+ groups.add(extraGroup);
+ }
+ }
}
- long maxEnd = Long.MIN_VALUE;
- for (IndexMetaFileGroup g : extraGroups) {
- maxEnd = Math.max(maxEnd, g.coverageEnd());
+ if (groups.isEmpty()) {
+ return Collections.emptyList();
}
+
+ // A field can be covered by its dedicated primary index
and by one or more
+ // multi-column indexes that carry it as an extra field.
These are alternative
+ // sources of matches, possibly over different row ranges,
so union them. The
+ // previous primary-only choice made coverage planning
believe the extra-field
+ // tail was indexed while the evaluator silently ignored
it.
List<GlobalIndexReader> allReaders = new ArrayList<>();
- for (IndexMetaFileGroup g : extraGroups) {
- allReaders.addAll(createReaders(indexFileReader, g,
rowType, maxEnd));
+ for (IndexMetaFileGroup indexGroup : groups) {
+ allReaders.addAll(createReaders(indexFileReader,
indexGroup, rowType));
}
- return allReaders;
+ return Collections.singletonList(new
UnionGlobalIndexReader(allReaders));
};
this.globalIndexEvaluator = new GlobalIndexEvaluator(rowType,
readersFunction);
}
@@ -146,7 +156,6 @@ public class GlobalIndexScanner implements Closeable {
private final int indexFieldId;
private final List<Integer> fieldIds;
private final Map<String, Map<Range, List<IndexFileMeta>>> metas = new
HashMap<>();
- private long coverageEnd = Long.MIN_VALUE;
IndexMetaFileGroup(int indexFieldId, List<Integer> fieldIds) {
this.indexFieldId = indexFieldId;
@@ -154,17 +163,11 @@ public class GlobalIndexScanner implements Closeable {
}
void addFile(String indexType, Range range, IndexFileMeta indexFile) {
- coverageEnd = Math.max(coverageEnd, range.to);
metas.computeIfAbsent(indexType, k -> new HashMap<>())
.computeIfAbsent(range, k -> new ArrayList<>())
.add(indexFile);
}
- /** The largest indexed rowId across all files of this index (ranges
start at 0). */
- long coverageEnd() {
- return coverageEnd;
- }
-
/** The primary index column. */
DataField indexField(RowType rowType) {
return rowType.getField(indexFieldId);
@@ -286,10 +289,7 @@ public class GlobalIndexScanner implements Closeable {
}
private Collection<GlobalIndexReader> createReaders(
- GlobalIndexFileReader indexFileReadWrite,
- IndexMetaFileGroup group,
- RowType rowType,
- long padToEnd) {
+ GlobalIndexFileReader indexFileReadWrite, IndexMetaFileGroup
group, RowType rowType) {
DataField indexField = group.indexField(rowType);
List<DataField> extraFields = group.extraFields(rowType);
@@ -301,11 +301,9 @@ public class GlobalIndexScanner implements Closeable {
GlobalIndexer globalIndexer =
globalIndexerFactory.create(indexField, extraFields,
options);
- long typeEnd = Long.MIN_VALUE;
List<CompletableFuture<GlobalIndexReader>> futures = new
ArrayList<>(metas.size());
for (Map.Entry<Range, List<IndexFileMeta>> rangeMetas :
metas.entrySet()) {
Range range = rangeMetas.getKey();
- typeEnd = Math.max(typeEnd, range.to);
List<IndexFileMeta> indexFileMetas = rangeMetas.getValue();
List<GlobalIndexIOMeta> globalMetas =
indexFileMetas.stream()
@@ -327,13 +325,6 @@ public class GlobalIndexScanner implements Closeable {
for (CompletableFuture<GlobalIndexReader> future : futures) {
unionReader.add(future.join());
}
- // Pad this index's missing tail with an all-hit reader so AND-ing
it with a
- // longer-range index does not drop rows it has not indexed
(ranges start at 0).
- if (padToEnd > typeEnd) {
- unionReader.add(
- new ConstantGlobalIndexReader(
- GlobalIndexResult.fromRange(new Range(typeEnd
+ 1, padToEnd))));
- }
readers.add(new UnionGlobalIndexReader(unionReader));
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionVectorScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionVectorScan.java
index 79a9fe81d0..6f123632c1 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionVectorScan.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/DataEvolutionVectorScan.java
@@ -107,7 +107,12 @@ public class DataEvolutionVectorScan implements VectorScan
{
vectorIndexType = configuredVectorIndexType();
}
- // Group vector index files by (rowRangeStart, rowRangeEnd)
+ // Group vector index files by (rowRangeStart, rowRangeEnd). A file is
treated as the vector
+ // index for this search only when the vector column is its PRIMARY
field (indexFieldId);
+ // the canonical ES hybrid layout is vector-as-primary with
text/scalar columns carried as
+ // extra fields. If the vector column were instead an extra field, no
IndexVectorSearchSplit
+ // is produced here and the search falls back to a brute-force
RawVectorSearchSplit (correct
+ // results, but the ANN index is bypassed).
Map<Range, List<IndexFileMeta>> vectorByRange = new HashMap<>();
List<IndexFileMeta> vectorIndexFiles = new ArrayList<>();
for (IndexFileMeta indexFile : allIndexFiles) {
@@ -130,7 +135,7 @@ public class DataEvolutionVectorScan implements VectorScan {
f -> {
GlobalIndexMeta globalIndex =
checkNotNull(f.globalIndexMeta());
- if (isPrimaryColumn(globalIndex,
vectorColumn.id())) {
+ if
(!canServeScalarFilter(globalIndex)) {
return false;
}
return
range.hasIntersection(globalIndex.rowRange());
@@ -180,7 +185,7 @@ public class DataEvolutionVectorScan implements VectorScan {
.filter(
f -> {
GlobalIndexMeta globalIndex =
checkNotNull(f.globalIndexMeta());
- return !isPrimaryColumn(globalIndex,
vectorColumn.id());
+ return canServeScalarFilter(globalIndex);
})
.collect(Collectors.toList());
}
@@ -191,7 +196,7 @@ public class DataEvolutionVectorScan implements VectorScan {
.filter(
f -> {
GlobalIndexMeta globalIndex =
checkNotNull(f.globalIndexMeta());
- if (isPrimaryColumn(globalIndex,
vectorColumn.id())) {
+ if (!canServeScalarFilter(globalIndex)) {
return false;
}
return hasIntersection(rowRanges,
globalIndex.rowRange());
@@ -199,6 +204,15 @@ public class DataEvolutionVectorScan implements VectorScan
{
.collect(Collectors.toList());
}
+ private boolean canServeScalarFilter(GlobalIndexMeta meta) {
+ return !isPrimaryColumn(meta, vectorColumn.id()) ||
hasExtraFields(meta);
+ }
+
+ private static boolean hasExtraFields(GlobalIndexMeta meta) {
+ int[] extraFieldIds = meta.extraFieldIds();
+ return extraFieldIds != null && extraFieldIds.length > 0;
+ }
+
private static boolean hasIntersection(List<Range> ranges, Range range) {
for (Range r : ranges) {
if (r.hasIntersection(range)) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
index 835630db28..0a2134c7a5 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextReadImpl.java
@@ -159,26 +159,14 @@ public class FullTextReadImpl implements FullTextRead {
return ScoredGlobalIndexResult.createEmpty();
}
- IndexFileMeta firstFile =
columnSplits.get(0).fullTextIndexFiles().get(0);
- String indexType = firstFile.indexType();
- GlobalIndexMeta firstMeta = checkNotNull(firstFile.globalIndexMeta());
- GlobalIndexer globalIndexer;
- if (firstMeta.extraFieldIds() != null) {
- globalIndexer =
- GlobalIndexerFactoryUtils.load(indexType)
- .create(
- firstMeta.getIndexField(table.rowType()),
- firstMeta.getExtraFields(table.rowType()),
- table.coreOptions().toConfiguration());
- } else {
- globalIndexer =
- GlobalIndexerFactoryUtils.load(indexType)
- .create(textColumn,
table.coreOptions().toConfiguration());
- }
-
+ // A column can carry splits from more than one index identity
(per-range selection in the
+ // scan when different indexes cover different ranges of the same
column), so build the
+ // reader from each split's own file meta rather than reusing the
first split's identity.
List<CompletableFuture<Optional<ScoredGlobalIndexResult>>> futures =
new ArrayList<>(columnSplits.size());
for (IndexFullTextSearchSplit split : columnSplits) {
+ GlobalIndexer globalIndexer =
+ createIndexer(split.fullTextIndexFiles().get(0),
textColumn);
futures.add(
eval(
globalIndexer,
@@ -188,8 +176,7 @@ public class FullTextReadImpl implements FullTextRead {
split.fullTextIndexFiles(),
indexFileReader,
executor,
- GlobalIndexLiveRowFilter.forRange(
- liveRows, split.rowRangeStart(),
split.rowRangeEnd())));
+ includeRowIds(split, liveRows)));
}
CompletableFuture.allOf(futures.toArray(new
CompletableFuture[0])).join();
@@ -205,6 +192,39 @@ public class FullTextReadImpl implements FullTextRead {
return result;
}
+ @Nullable
+ private static RoaringNavigableMap64 includeRowIds(
+ IndexFullTextSearchSplit split, @Nullable RoaringNavigableMap64
liveRows) {
+ RoaringNavigableMap64 include = new RoaringNavigableMap64();
+ for (Range range : split.searchRowRanges()) {
+ include.addRange(range);
+ }
+ if (liveRows != null) {
+ include.and(liveRows);
+ }
+ long physicalRowCount = split.rowRangeEnd() - split.rowRangeStart() +
1;
+ return include.getLongCardinality() == physicalRowCount ? null :
include;
+ }
+
+ /**
+ * Builds the {@link GlobalIndexer} for a single split from its own index
file meta, so a column
+ * served by several index identities (over different row ranges) reads
each split with the
+ * matching field configuration instead of the first split's.
+ */
+ private GlobalIndexer createIndexer(IndexFileMeta file, DataField
textColumn) {
+ String indexType = file.indexType();
+ GlobalIndexMeta meta = checkNotNull(file.globalIndexMeta());
+ if (meta.extraFieldIds() != null) {
+ return GlobalIndexerFactoryUtils.load(indexType)
+ .create(
+ meta.getIndexField(table.rowType()),
+ meta.getExtraFields(table.rowType()),
+ table.coreOptions().toConfiguration());
+ }
+ return GlobalIndexerFactoryUtils.load(indexType)
+ .create(textColumn, table.coreOptions().toConfiguration());
+ }
+
private CompletableFuture<Optional<ScoredGlobalIndexResult>> eval(
GlobalIndexer globalIndexer,
IndexPathFactory indexPathFactory,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
index 5fdebadf70..ecf6719f74 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/FullTextScanImpl.java
@@ -19,6 +19,7 @@
package org.apache.paimon.table.source;
import org.apache.paimon.Snapshot;
+import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.globalindex.GlobalIndexCoverage;
import org.apache.paimon.globalindex.GlobalIndexerFactory;
import org.apache.paimon.globalindex.GlobalIndexerFactoryUtils;
@@ -34,13 +35,16 @@ import org.apache.paimon.utils.Filter;
import org.apache.paimon.utils.Range;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.TreeSet;
import java.util.stream.Collectors;
import static org.apache.paimon.utils.Preconditions.checkNotNull;
@@ -93,7 +97,7 @@ public class FullTextScanImpl implements FullTextScan {
if (globalIndex == null) {
return false;
}
- return textColumnIds.contains(globalIndex.indexFieldId())
+ return !matchedTextColumnIds(globalIndex,
textColumnIds).isEmpty()
&&
supportsFullTextSearch(entry.indexFile().indexType());
};
@@ -102,29 +106,16 @@ public class FullTextScanImpl implements FullTextScan {
.map(IndexManifestEntry::indexFile)
.collect(Collectors.toList());
- // Group full-text index files by column and row range.
- Map<String, Map<Range, List<IndexFileMeta>>> byColumnAndRange = new
HashMap<>();
- for (IndexFileMeta indexFile : allIndexFiles) {
- GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
- String columnName =
checkNotNull(idToColumn.get(meta.indexFieldId()));
- Range range = new Range(meta.rowRangeStart(), meta.rowRangeEnd());
- byColumnAndRange
- .computeIfAbsent(columnName, k -> new HashMap<>())
- .computeIfAbsent(range, k -> new ArrayList<>())
- .add(indexFile);
- }
-
List<FullTextSearchSplit> splits = new ArrayList<>();
- for (Map.Entry<String, Map<Range, List<IndexFileMeta>>> columnEntry :
- byColumnAndRange.entrySet()) {
- String columnName = columnEntry.getKey();
- for (Map.Entry<Range, List<IndexFileMeta>> rangeEntry :
- columnEntry.getValue().entrySet()) {
- Range range = rangeEntry.getKey();
- splits.add(
- new IndexFullTextSearchSplit(
- columnName, range.from, range.to,
rangeEntry.getValue()));
- }
+ for (IndexRangeSelection selection :
+ chooseIndexRanges(allIndexFiles, textColumnIds, idToColumn)) {
+ splits.add(
+ new IndexFullTextSearchSplit(
+ selection.columnName,
+ selection.fileRange.from,
+ selection.fileRange.to,
+ selection.files,
+ selection.searchRanges));
}
if (!allIndexFiles.isEmpty()) {
@@ -139,6 +130,253 @@ public class FullTextScanImpl implements FullTextScan {
return () -> splits;
}
+ /**
+ * Returns the searched text-column ids served by {@code meta}: its
primary {@code indexFieldId}
+ * plus any {@code extraFieldIds} present in {@code textColumnIds}. This
lets a multi-column
+ * index (e.g. vector primary + text extra) serve full-text search on its
extra text column.
+ */
+ private static List<Integer> matchedTextColumnIds(
+ GlobalIndexMeta meta, Set<Integer> textColumnIds) {
+ List<Integer> matched = new ArrayList<>();
+ if (textColumnIds.contains(meta.indexFieldId())) {
+ matched.add(meta.indexFieldId());
+ }
+ int[] extraFieldIds = meta.extraFieldIds();
+ if (extraFieldIds != null) {
+ for (int extraFieldId : extraFieldIds) {
+ if (textColumnIds.contains(extraFieldId)) {
+ matched.add(extraFieldId);
+ }
+ }
+ }
+ return matched;
+ }
+
+ /**
+ * Chooses one index definition for every disjoint row interval of each
searched text column. A
+ * dedicated index (the text column is its primary field) wins over an
index that only carries
+ * the column as an extra field; remaining ties are resolved
deterministically by index identity
+ * and physical file range.
+ *
+ * <p>Index files may cover overlapping but non-identical ranges.
Exact-range grouping would
+ * keep all such files and search the overlap more than once. This method
splits their coverage
+ * at every range boundary, assigns each resulting interval once, and
records the assigned
+ * sub-ranges separately from the physical file range. Readers still need
the physical range to
+ * translate file-local row ids correctly.
+ */
+ private static List<IndexRangeSelection> chooseIndexRanges(
+ List<IndexFileMeta> allIndexFiles,
+ Set<Integer> textColumnIds,
+ Map<Integer, String> idToColumn) {
+ // column -> physical range -> index identity -> candidate files
+ Map<String, Map<Range, Map<IndexIdentity, IndexRangeCandidate>>>
grouped = new HashMap<>();
+ for (IndexFileMeta indexFile : allIndexFiles) {
+ GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
+ IndexIdentity identity = IndexIdentity.of(indexFile);
+ Range range = new Range(meta.rowRangeStart(), meta.rowRangeEnd());
+ for (int columnId : matchedTextColumnIds(meta, textColumnIds)) {
+ String columnName = checkNotNull(idToColumn.get(columnId));
+ boolean primary = meta.indexFieldId() == columnId;
+ IndexRangeCandidate candidate =
+ grouped.computeIfAbsent(columnName, k -> new
HashMap<>())
+ .computeIfAbsent(range, k -> new HashMap<>())
+ .computeIfAbsent(
+ identity,
+ k ->
+ new IndexRangeCandidate(
+ columnName, range,
identity, primary));
+ candidate.files.add(indexFile);
+ }
+ }
+
+ List<IndexRangeSelection> selections = new ArrayList<>();
+ for (Map.Entry<String, Map<Range, Map<IndexIdentity,
IndexRangeCandidate>>> columnEntry :
+ grouped.entrySet()) {
+ List<IndexRangeCandidate> candidates = new ArrayList<>();
+ for (Map<IndexIdentity, IndexRangeCandidate> byIdentity :
+ columnEntry.getValue().values()) {
+ candidates.addAll(byIdentity.values());
+ }
+
+ TreeSet<Long> boundaries = new TreeSet<>();
+ Map<Long, List<IndexRangeCandidate>> startingAt = new HashMap<>();
+ Map<Long, List<IndexRangeCandidate>> endingAt = new HashMap<>();
+ for (IndexRangeCandidate candidate : candidates) {
+ boundaries.add(candidate.fileRange.from);
+ startingAt
+ .computeIfAbsent(candidate.fileRange.from, k -> new
ArrayList<>())
+ .add(candidate);
+ if (candidate.fileRange.to != Long.MAX_VALUE) {
+ long afterEnd = candidate.fileRange.to + 1;
+ boundaries.add(afterEnd);
+ endingAt.computeIfAbsent(afterEnd, k -> new
ArrayList<>()).add(candidate);
+ }
+ }
+
+ List<Long> sortedBoundaries = new ArrayList<>(boundaries);
+ Map<IndexRangeCandidate, List<Range>> assigned = new
LinkedHashMap<>();
+ TreeSet<IndexRangeCandidate> active =
+ new TreeSet<>(FullTextScanImpl::compareCandidates);
+ for (int i = 0; i < sortedBoundaries.size(); i++) {
+ long from = sortedBoundaries.get(i);
+ List<IndexRangeCandidate> ending = endingAt.get(from);
+ if (ending != null) {
+ active.removeAll(ending);
+ }
+ List<IndexRangeCandidate> starting = startingAt.get(from);
+ if (starting != null) {
+ active.addAll(starting);
+ }
+ long to =
+ i + 1 < sortedBoundaries.size()
+ ? sortedBoundaries.get(i + 1) - 1
+ : Long.MAX_VALUE;
+ if (!active.isEmpty()) {
+ IndexRangeCandidate best = active.first();
+ assigned.computeIfAbsent(best, k -> new
ArrayList<>()).add(new Range(from, to));
+ }
+ }
+
+ for (Map.Entry<IndexRangeCandidate, List<Range>> entry :
assigned.entrySet()) {
+ IndexRangeCandidate candidate = entry.getKey();
+ selections.add(
+ new IndexRangeSelection(
+ candidate.columnName,
+ candidate.fileRange,
+ candidate.identity,
+ candidate.files,
+ mergeAdjacent(entry.getValue())));
+ }
+ }
+
+ selections.sort(
+ (left, right) -> {
+ int result = left.columnName.compareTo(right.columnName);
+ if (result != 0) {
+ return result;
+ }
+ result = Long.compare(left.fileRange.from,
right.fileRange.from);
+ if (result != 0) {
+ return result;
+ }
+ result = Long.compare(left.fileRange.to,
right.fileRange.to);
+ return result != 0
+ ? result
+ :
left.identity.key().compareTo(right.identity.key());
+ });
+ return selections;
+ }
+
+ private static int compareCandidates(IndexRangeCandidate left,
IndexRangeCandidate right) {
+ if (left.primary != right.primary) {
+ return left.primary ? -1 : 1;
+ }
+ int result = left.identity.key().compareTo(right.identity.key());
+ if (result != 0) {
+ return result;
+ }
+ result = Long.compare(left.fileRange.from, right.fileRange.from);
+ return result != 0 ? result : Long.compare(left.fileRange.to,
right.fileRange.to);
+ }
+
+ private static List<Range> mergeAdjacent(List<Range> ranges) {
+ List<Range> merged = new ArrayList<>();
+ for (Range next : ranges) {
+ if (merged.isEmpty()) {
+ merged.add(next);
+ continue;
+ }
+ Range current = merged.get(merged.size() - 1);
+ if (current.to != Long.MAX_VALUE && current.to + 1 == next.from) {
+ merged.set(merged.size() - 1, new Range(current.from,
next.to));
+ } else {
+ merged.add(next);
+ }
+ }
+ return merged;
+ }
+
+ @VisibleForTesting
+ static boolean sameIndexIdentity(IndexFileMeta left, IndexFileMeta right) {
+ return IndexIdentity.of(left).equals(IndexIdentity.of(right));
+ }
+
+ /**
+ * Identity of a global index definition — its index type, primary {@code
indexFieldId} and
+ * extra field ids. Two index files with the same identity belong to the
same index definition
+ * (differing only by row range/shard); files with different identities
must not be merged into
+ * one reader input.
+ */
+ private static final class IndexIdentity {
+ private final String key;
+
+ private IndexIdentity(String key) {
+ this.key = key;
+ }
+
+ static IndexIdentity of(IndexFileMeta indexFile) {
+ GlobalIndexMeta meta = checkNotNull(indexFile.globalIndexMeta());
+ int[] extra = meta.extraFieldIds();
+ return new IndexIdentity(
+ indexFile.indexType()
+ + '|'
+ + meta.indexFieldId()
+ + '|'
+ + Arrays.toString(extra == null ? new int[0] :
extra));
+ }
+
+ String key() {
+ return key;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return o instanceof IndexIdentity && key.equals(((IndexIdentity)
o).key);
+ }
+
+ @Override
+ public int hashCode() {
+ return key.hashCode();
+ }
+ }
+
+ private static final class IndexRangeCandidate {
+ private final String columnName;
+ private final Range fileRange;
+ private final IndexIdentity identity;
+ private final boolean primary;
+ private final List<IndexFileMeta> files = new ArrayList<>();
+
+ private IndexRangeCandidate(
+ String columnName, Range fileRange, IndexIdentity identity,
boolean primary) {
+ this.columnName = columnName;
+ this.fileRange = fileRange;
+ this.identity = identity;
+ this.primary = primary;
+ }
+ }
+
+ private static final class IndexRangeSelection {
+ private final String columnName;
+ private final Range fileRange;
+ private final IndexIdentity identity;
+ private final List<IndexFileMeta> files;
+ private final List<Range> searchRanges;
+
+ private IndexRangeSelection(
+ String columnName,
+ Range fileRange,
+ IndexIdentity identity,
+ List<IndexFileMeta> files,
+ List<Range> searchRanges) {
+ this.columnName = columnName;
+ this.fileRange = fileRange;
+ this.identity = identity;
+ this.files = files;
+ this.searchRanges = searchRanges;
+ }
+ }
+
private static boolean supportsFullTextSearch(String indexType) {
GlobalIndexerFactory factory;
try {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
index 976620e015..54a55536f1 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/IndexFullTextSearchSplit.java
@@ -22,6 +22,7 @@ import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.index.IndexFileMetaSerializer;
import org.apache.paimon.io.DataInputViewStreamWrapper;
import org.apache.paimon.io.DataOutputViewStreamWrapper;
+import org.apache.paimon.utils.Range;
import java.io.IOException;
import java.io.ObjectInputStream;
@@ -44,6 +45,7 @@ public class IndexFullTextSearchSplit extends
FullTextSearchSplit {
private String columnName;
private long rowRangeStart;
private long rowRangeEnd;
+ private List<Range> searchRowRanges;
private transient List<IndexFileMeta> fullTextIndexFiles;
public IndexFullTextSearchSplit(
@@ -56,10 +58,34 @@ public class IndexFullTextSearchSplit extends
FullTextSearchSplit {
long rowRangeStart,
long rowRangeEnd,
List<IndexFileMeta> fullTextIndexFiles) {
+ this(
+ columnName,
+ rowRangeStart,
+ rowRangeEnd,
+ fullTextIndexFiles,
+ Collections.singletonList(new Range(rowRangeStart,
rowRangeEnd)));
+ }
+
+ public IndexFullTextSearchSplit(
+ String columnName,
+ long rowRangeStart,
+ long rowRangeEnd,
+ List<IndexFileMeta> fullTextIndexFiles,
+ List<Range> searchRowRanges) {
this.columnName = columnName;
this.rowRangeStart = rowRangeStart;
this.rowRangeEnd = rowRangeEnd;
this.fullTextIndexFiles = Collections.unmodifiableList(new
ArrayList<>(fullTextIndexFiles));
+ List<Range> ranges = new ArrayList<>(searchRowRanges);
+ for (Range range : ranges) {
+ if (range.from < rowRangeStart || range.to > rowRangeEnd) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Search range %s is outside physical index
range [%s, %s]",
+ range, rowRangeStart, rowRangeEnd));
+ }
+ }
+ this.searchRowRanges = Collections.unmodifiableList(ranges);
}
public String columnName() {
@@ -74,6 +100,10 @@ public class IndexFullTextSearchSplit extends
FullTextSearchSplit {
return rowRangeEnd;
}
+ public List<Range> searchRowRanges() {
+ return searchRowRanges;
+ }
+
public List<IndexFileMeta> fullTextIndexFiles() {
return fullTextIndexFiles;
}
@@ -96,6 +126,11 @@ public class IndexFullTextSearchSplit extends
FullTextSearchSplit {
DataInputViewStreamWrapper view = new DataInputViewStreamWrapper(in);
this.fullTextIndexFiles =
Collections.unmodifiableList(new
ArrayList<>(serializer.deserializeList(view)));
+ if (searchRowRanges == null) {
+ searchRowRanges = Collections.singletonList(new
Range(rowRangeStart, rowRangeEnd));
+ } else {
+ searchRowRanges = Collections.unmodifiableList(new
ArrayList<>(searchRowRanges));
+ }
}
@Override
@@ -107,12 +142,14 @@ public class IndexFullTextSearchSplit extends
FullTextSearchSplit {
return rowRangeStart == that.rowRangeStart
&& rowRangeEnd == that.rowRangeEnd
&& Objects.equals(columnName, that.columnName)
+ && Objects.equals(searchRowRanges, that.searchRowRanges)
&& Objects.equals(fullTextIndexFiles, that.fullTextIndexFiles);
}
@Override
public int hashCode() {
- return Objects.hash(columnName, rowRangeStart, rowRangeEnd,
fullTextIndexFiles);
+ return Objects.hash(
+ columnName, rowRangeStart, rowRangeEnd, searchRowRanges,
fullTextIndexFiles);
}
@Override
@@ -125,6 +162,8 @@ public class IndexFullTextSearchSplit extends
FullTextSearchSplit {
+ rowRangeStart
+ ", rowRangeEnd="
+ rowRangeEnd
+ + ", searchRowRanges="
+ + searchRowRanges
+ ", fullTextIndexFiles="
+ fullTextIndexFiles
+ '}';
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
index 052232e5bb..c092932ef7 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/FullTextSearchBuilderTest.java
@@ -32,6 +32,7 @@ import org.apache.paimon.globalindex.ResultEntry;
import org.apache.paimon.globalindex.ScoredGlobalIndexResult;
import org.apache.paimon.globalindex.btree.BTreeGlobalIndexerFactory;
import
org.apache.paimon.globalindex.testfulltext.TestFullTextGlobalIndexerFactory;
+import org.apache.paimon.index.GlobalIndexMeta;
import org.apache.paimon.index.IndexFileMeta;
import org.apache.paimon.io.CompactIncrement;
import org.apache.paimon.io.DataIncrement;
@@ -549,12 +550,14 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
}
@Test
- public void testFullTextSearchRequiresTextColumnAsPrimaryField() throws
Exception {
+ public void testFullTextSearchServesTextColumnAsExtraField() throws
Exception {
createTableDefault();
FileStoreTable table = getTableDefault();
String[] documents = {"Apache Paimon", "vector search"};
writeDocuments(table, documents);
+ // Multi-column index: primary is "id", the text column is an EXTRA
field. Full-text search
+ // on the extra text column is served (matched via extraFieldIds).
buildAndCommitIndexWithFields(
table,
documents,
@@ -566,6 +569,175 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
.withQuery(TEXT_FIELD_NAME, matchQuery("Paimon"))
.withLimit(2);
+
assertThat(searchBuilder.newFullTextScan().scan().splits()).isNotEmpty();
+ assertThat(searchBuilder.executeLocal().results().isEmpty()).isFalse();
+ }
+
+ @Test
+ public void
testFullTextSearchPrefersDedicatedIndexOverExtraFieldCoverage() throws
Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] documents = {"Apache Paimon", "vector search"};
+ writeDocuments(table, documents);
+
+ // Two full-text-capable indexes cover the same text column over the
same row range:
+ // (a) a dedicated full-text index whose PRIMARY field is the text
column, and
+ // (b) a multi-column index whose primary is "id" and carries the
text column as an EXTRA
+ // field.
+ // These are different index definitions and must not be merged into
one reader input; the
+ // dedicated index (a) takes precedence, so each split carries a
single index file.
+ buildAndCommitIndex(table, documents); // (a) primary = content
+ buildAndCommitIndexWithFields(
+ table,
+ documents,
+ Arrays.asList(
+ table.rowType().getField("id"),
+ table.rowType().getField(TEXT_FIELD_NAME))); // (b)
content as extra
+
+ FullTextSearchBuilder searchBuilder =
+ table.newFullTextSearchBuilder()
+ .withQuery(TEXT_FIELD_NAME, matchQuery("Paimon"))
+ .withLimit(2);
+
+ List<FullTextSearchSplit> splits =
searchBuilder.newFullTextScan().scan().splits();
+ assertThat(splits).isNotEmpty();
+ int textFieldId = table.rowType().getField(TEXT_FIELD_NAME).id();
+ for (FullTextSearchSplit split : splits) {
+ IndexFullTextSearchSplit indexSplit = (IndexFullTextSearchSplit)
split;
+ // Files from different index definitions are never merged into
one split ...
+ assertThat(indexSplit.fullTextIndexFiles()).hasSize(1);
+ // ... and the chosen definition is the dedicated one (text column
is its primary
+ // field).
+
assertThat(indexSplit.fullTextIndexFiles().get(0).globalIndexMeta().indexFieldId())
+ .isEqualTo(textFieldId);
+ }
+ assertThat(searchBuilder.executeLocal().results().isEmpty()).isFalse();
+ }
+
+ @Test
+ public void testFullTextSearchKeepsRangeCoveredOnlyByExtraFieldIndex()
throws Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] documents = {"paimon lake", "vector search", "paimon engine",
"streaming"};
+ writeDocuments(table, documents);
+
+ // Two index definitions cover DIFFERENT row ranges of the same text
column:
+ // - a dedicated full-text index (primary field = content) over rows
[0, 1], and
+ // - a multi-column index (primary = id, content as an extra field)
over rows [2, 3].
+ // Per-column selection would keep only the dedicated identity and
drop the extra-field
+ // index's [2, 3] file, while raw coverage (computed from all index
files) still treats
+ // [2, 3] as indexed, so row 2 ("paimon engine") would be silently
unsearchable. Per-range
+ // selection must keep both ranges searchable.
+ buildAndCommitIndexRange(
+ table,
+ new String[] {"paimon lake", "vector search"},
+
Collections.singletonList(table.rowType().getField(TEXT_FIELD_NAME)),
+ 0);
+ buildAndCommitIndexRange(
+ table,
+ new String[] {"paimon engine", "streaming"},
+ Arrays.asList(
+ table.rowType().getField("id"),
table.rowType().getField(TEXT_FIELD_NAME)),
+ 2);
+
+ GlobalIndexResult result =
+ table.newFullTextSearchBuilder()
+ .withQuery(TEXT_FIELD_NAME, matchQuery("paimon"))
+ .withLimit(10)
+ .executeLocal();
+
+ // Row 0 is served by the dedicated index over [0,1]; row 2 by the
extra-field index over
+ // [2,3]. Neither range may be dropped.
+ assertThat(readIds(table, result)).containsExactlyInAnyOrder(0, 2);
+ }
+
+ @Test
+ public void testFullTextSearchAssignsOverlappingRangesOnlyOnce() throws
Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] documents = {"needle zero", "other", "needle two", "needle
three"};
+ writeDocuments(table, documents);
+
+ // The dedicated index covers [0, 2], while an index carrying content
as an extra field
+ // covers [2, 3]. Row 2 is in both physical files, but must be
assigned only to the
+ // dedicated index. The second file is still read with its physical
[2, 3] offset and an
+ // include mask for its assigned tail [3, 3].
+ buildAndCommitIndexRange(
+ table,
+ new String[] {documents[0], documents[1], documents[2]},
+
Collections.singletonList(table.rowType().getField(TEXT_FIELD_NAME)),
+ 0);
+ buildAndCommitIndexRange(
+ table,
+ new String[] {documents[2], documents[3]},
+ Arrays.asList(
+ table.rowType().getField("id"),
table.rowType().getField(TEXT_FIELD_NAME)),
+ 2);
+
+ FullTextSearchBuilder searchBuilder =
+ table.newFullTextSearchBuilder()
+ .withQuery(TEXT_FIELD_NAME, matchQuery("needle"))
+ .withLimit(10);
+ List<IndexFullTextSearchSplit> indexSplits = new ArrayList<>();
+ for (FullTextSearchSplit split :
searchBuilder.newFullTextScan().scan().splits()) {
+ indexSplits.add((IndexFullTextSearchSplit) split);
+ }
+
+ assertThat(indexSplits).hasSize(2);
+ assertThat(indexSplits.get(0).rowRangeStart()).isEqualTo(0);
+ assertThat(indexSplits.get(0).rowRangeEnd()).isEqualTo(2);
+ assertThat(indexSplits.get(0).searchRowRanges()).containsExactly(new
Range(0, 2));
+ assertThat(indexSplits.get(1).rowRangeStart()).isEqualTo(2);
+ assertThat(indexSplits.get(1).rowRangeEnd()).isEqualTo(3);
+ assertThat(indexSplits.get(1).searchRowRanges()).containsExactly(new
Range(3, 3));
+
+ assertThat(readIds(table,
searchBuilder.executeLocal())).containsExactlyInAnyOrder(0, 2, 3);
+ }
+
+ @Test
+ public void testFullTextIndexIdentityPreservesExtraFieldOrder() {
+ IndexFileMeta titleThenBody =
+ new IndexFileMeta(
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ "title-body",
+ 1,
+ 2,
+ new GlobalIndexMeta(0, 1, 0, new int[] {1, 2}, null),
+ null);
+ IndexFileMeta bodyThenTitle =
+ new IndexFileMeta(
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ "body-title",
+ 1,
+ 2,
+ new GlobalIndexMeta(0, 1, 0, new int[] {2, 1}, null),
+ null);
+
+ // The reader's projected row layout follows this order, so these are
different index
+ // definitions even though they contain the same field-id set.
+ assertThat(FullTextScanImpl.sameIndexIdentity(titleThenBody,
bodyThenTitle)).isFalse();
+ }
+
+ @Test
+ public void testFullTextSearchSkipsIndexNotCoveringTextColumn() throws
Exception {
+ createTableDefault();
+ FileStoreTable table = getTableDefault();
+
+ String[] documents = {"Apache Paimon", "vector search"};
+ writeDocuments(table, documents);
+ // The index covers only "id" — the text column is neither the primary
nor an extra field —
+ // so full-text search on the text column finds no index and returns
empty.
+ buildAndCommitIndexWithFields(
+ table, documents,
Arrays.asList(table.rowType().getField("id")));
+
+ FullTextSearchBuilder searchBuilder =
+ table.newFullTextSearchBuilder()
+ .withQuery(TEXT_FIELD_NAME, matchQuery("Paimon"))
+ .withLimit(2);
+
FullTextScan.Plan plan = searchBuilder.newFullTextScan().scan();
assertThat(plan.splits()).isEmpty();
assertThat(searchBuilder.executeLocal().results().isEmpty()).isTrue();
@@ -604,6 +776,7 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
assertThat(deserialized.columnName()).isEqualTo(original.columnName());
assertThat(deserialized.rowRangeStart()).isEqualTo(original.rowRangeStart());
assertThat(deserialized.rowRangeEnd()).isEqualTo(original.rowRangeEnd());
+
assertThat(deserialized.searchRowRanges()).isEqualTo(original.searchRowRanges());
assertThat(deserialized.fullTextIndexFiles()).hasSize(original.fullTextIndexFiles().size());
for (int i = 0; i < original.fullTextIndexFiles().size(); i++) {
assertThat(deserialized.fullTextIndexFiles().get(i).fileName())
@@ -731,6 +904,56 @@ public class FullTextSearchBuilderTest extends
TableTestBase {
}
}
+ /**
+ * Builds and commits a single full-text index file covering rows
[rowStart, rowStart+N-1].
+ * {@code indexFields} determines the index identity (primary = first
field, the rest are extra
+ * fields), letting a test place different index definitions over
different row ranges.
+ */
+ private void buildAndCommitIndexRange(
+ FileStoreTable table, String[] documents, List<DataField>
indexFields, int rowStart)
+ throws Exception {
+ Options options = table.coreOptions().toConfiguration();
+ DataField textField = table.rowType().getField(TEXT_FIELD_NAME);
+
+ GlobalIndexSingleColumnWriter writer =
+ (GlobalIndexSingleColumnWriter)
+ GlobalIndexBuilderUtils.createIndexWriter(
+ table,
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ textField,
+ options);
+ // Doc ids are file-local (0-based); the global row offset is carried
by rowRange.from,
+ // which
+ // the OffsetGlobalIndexReader adds back when mapping local doc ids to
global row ids.
+ for (int i = 0; i < documents.length; i++) {
+ writer.write(documents[i], i);
+ }
+ List<ResultEntry> entries = writer.finish();
+
+ Range rowRange = new Range(rowStart, rowStart + documents.length - 1);
+ List<IndexFileMeta> indexFiles =
+ GlobalIndexBuilderUtils.toIndexFileMetas(
+ table.fileIO(),
+ table.store().pathFactory().globalIndexFileFactory(),
+ table.coreOptions(),
+ rowRange,
+ indexFields,
+ TestFullTextGlobalIndexerFactory.IDENTIFIER,
+ entries);
+
+ DataIncrement dataIncrement = DataIncrement.indexIncrement(indexFiles);
+ CommitMessage message =
+ new CommitMessageImpl(
+ BinaryRow.EMPTY_ROW,
+ 0,
+ null,
+ dataIncrement,
+ CompactIncrement.emptyIncrement());
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.commit(Collections.singletonList(message));
+ }
+ }
+
private FileStoreTable createMultiTextTable() throws Exception {
Identifier identifier = identifier("MultiTextTable");
Schema schema =
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
index 432e5333bf..93e219f442 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/source/VectorSearchBuilderTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.globalindex.GlobalIndexBuilderUtils;
+import org.apache.paimon.globalindex.GlobalIndexMultiColumnWriter;
import org.apache.paimon.globalindex.GlobalIndexResult;
import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
import org.apache.paimon.globalindex.ResultEntry;
@@ -1143,6 +1144,96 @@ public class VectorSearchBuilderTest extends
TableTestBase {
assertThat(searchBuilder.executeLocal().results().isEmpty()).isTrue();
}
+ @Test
+ public void testVectorPrimaryMultiFieldIndexServesScalarExtraField()
throws Exception {
+ catalog.createTable(
+ identifier("vector_primary_scalar_extra_field_table"),
+ vectorSchemaBuilder(VECTOR_FIELD_NAME)
+ .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(),
"full")
+ .build(),
+ false);
+ FileStoreTable table =
getTable(identifier("vector_primary_scalar_extra_field_table"));
+
+ float[][] vectors = {{1.0f, 0.0f}, {0.0f, 1.0f}};
+ writeVectors(table, vectors);
+
+ DataField vectorField = table.rowType().getField(VECTOR_FIELD_NAME);
+ DataField idField = table.rowType().getField("id");
+ buildAndCommitVectorIndexWithFields(
+ table, vectors, new Range(0, 1), Arrays.asList(vectorField,
idField));
+
+ Predicate idFilter = new
PredicateBuilder(table.rowType()).greaterOrEqual(0, 1);
+ VectorScan.Plan plan =
+ table.newVectorSearchBuilder()
+ .withVector(new float[] {1.0f, 0.0f})
+ .withLimit(2)
+ .withVectorColumn(VECTOR_FIELD_NAME)
+ .withFilter(idFilter)
+ .newVectorScan()
+ .scan();
+
+ // A vector-primary multi-field index can serve both the vector search
and a scalar
+ // predicate on an extra field. It must therefore be attached as both
vector and scalar
+ // index input, and full search mode must not create a raw fallback
for the covered range.
+ assertThat(indexVectorSearchSplits(plan.splits())).hasSize(1);
+ assertThat(rawVectorSearchSplits(plan.splits())).isEmpty();
+
+ IndexVectorSearchSplit split =
indexVectorSearchSplits(plan.splits()).get(0);
+ assertThat(split.vectorIndexFiles()).hasSize(2);
+
assertThat(split.scalarIndexFiles()).containsExactlyElementsOf(split.vectorIndexFiles());
+
+ for (IndexFileMeta indexFile : split.vectorIndexFiles()) {
+
assertThat(indexFile.globalIndexMeta().indexFieldId()).isEqualTo(vectorField.id());
+
assertThat(indexFile.globalIndexMeta().extraFieldIds()).containsExactly(idField.id());
+ }
+
+ GlobalIndexResult result =
+ table.newVectorSearchBuilder()
+ .withVector(new float[] {1.0f, 0.0f})
+ .withLimit(2)
+ .withVectorColumn(VECTOR_FIELD_NAME)
+ .withFilter(idFilter)
+ .executeLocal();
+ assertThat(result.results()).containsExactly(1L);
+ assertThat(readIds(table, result)).containsExactly(1);
+ }
+
+ @Test
+ public void testScalarExtraFieldComplementsPartialDedicatedIndex() throws
Exception {
+ catalog.createTable(
+ identifier("vector_extra_complements_dedicated_index_table"),
+ vectorSchemaBuilder(VECTOR_FIELD_NAME)
+ .option(CoreOptions.GLOBAL_INDEX_SEARCH_MODE.key(),
"full")
+ .build(),
+ false);
+ FileStoreTable table =
+
getTable(identifier("vector_extra_complements_dedicated_index_table"));
+
+ float[][] vectors = {{0.0f, 1.0f}, {0.1f, 0.9f}, {1.0f, 0.0f}, {0.9f,
0.1f}};
+ writeVectors(table, vectors);
+
+ DataField vectorField = table.rowType().getField(VECTOR_FIELD_NAME);
+ DataField idField = table.rowType().getField("id");
+ buildAndCommitVectorIndexWithFields(
+ table, vectors, new Range(0, 3), Arrays.asList(vectorField,
idField));
+ // The dedicated scalar index covers only the head. The vector index's
scalar extra field
+ // must still serve the tail; otherwise GlobalIndexScanner's
primary-field preference drops
+ // rows [2, 3] even though coverage planning treats them as indexed.
+ buildAndCommitBTreeIndex(table, new int[] {0, 1}, new Range(0, 1));
+
+ Predicate idFilter = new
PredicateBuilder(table.rowType()).greaterOrEqual(0, 2);
+ GlobalIndexResult result =
+ table.newVectorSearchBuilder()
+ .withVector(new float[] {1.0f, 0.0f})
+ .withLimit(1)
+ .withVectorColumn(VECTOR_FIELD_NAME)
+ .withFilter(idFilter)
+ .executeLocal();
+
+ assertThat(result.results()).containsExactly(2L);
+ assertThat(readIds(table, result)).containsExactly(2);
+ }
+
@Test
public void testVectorSearchSplitSerialization() throws Exception {
createTableDefault();
@@ -1713,17 +1804,36 @@ public class VectorSearchBuilderTest extends
TableTestBase {
Options options = table.coreOptions().toConfiguration();
DataField vectorField = table.rowType().getField(VECTOR_FIELD_NAME);
- GlobalIndexSingleColumnWriter writer =
- (GlobalIndexSingleColumnWriter)
- GlobalIndexBuilderUtils.createIndexWriter(
- table,
- TestVectorGlobalIndexerFactory.IDENTIFIER,
- vectorField,
- options);
- for (int i = 0; i < vectors.length; i++) {
- writer.write(vectors[i], i);
+ List<ResultEntry> entries;
+ if (indexFields.size() > 1 && indexFields.get(0).id() ==
vectorField.id()) {
+ GlobalIndexMultiColumnWriter writer =
+ (GlobalIndexMultiColumnWriter)
+ GlobalIndexBuilderUtils.createIndexWriter(
+ table,
+ TestVectorGlobalIndexerFactory.IDENTIFIER,
+ vectorField,
+ indexFields.subList(1, indexFields.size()),
+ options);
+ for (int i = 0; i < vectors.length; i++) {
+ writer.write(
+ i, GenericRow.of(new GenericArray(vectors[i]), (int)
(rowRange.from + i)));
+ }
+ entries = writer.finish();
+ } else {
+ // This path is used by the malformed-metadata test where the
vector is deliberately
+ // not the primary field. The scan rejects it before constructing
a reader.
+ GlobalIndexSingleColumnWriter writer =
+ (GlobalIndexSingleColumnWriter)
+ GlobalIndexBuilderUtils.createIndexWriter(
+ table,
+ TestVectorGlobalIndexerFactory.IDENTIFIER,
+ vectorField,
+ options);
+ for (int i = 0; i < vectors.length; i++) {
+ writer.write(vectors[i], i);
+ }
+ entries = writer.finish();
}
- List<ResultEntry> entries = writer.finish();
List<IndexFileMeta> indexFiles =
GlobalIndexBuilderUtils.toIndexFileMetas(