This is an automated email from the ASF dual-hosted git repository.
xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git
The following commit(s) were added to refs/heads/master by this push:
new c32ae438eb0 [Vector Upsert 3/5] Never materialize nested vector
subtrees as metadata filters (#19299)
c32ae438eb0 is described below
commit c32ae438eb02760009aeecc4cafc0bf54656f724
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Aug 26 16:30:10 2026 -0700
[Vector Upsert 3/5] Never materialize nested vector subtrees as metadata
filters (#19299)
FilterPlanNode treated any non-leaf AND sibling as a metadata filter when
wiring pre-filter bitmaps for filter-aware ANN. A nested subtree containing
a
VECTOR_SIMILARITY predicate (e.g. AND(vector, AND(vector2, metadata)) or
AND(vector, NOT(vector2))) was eagerly materialized into a bitmap, executing
top-K candidate generation out of its boolean context and pushing its
results
into the sibling vector search as a pre-filter.
Track the retained child FilterContexts alongside their operators so
wirePreFilterForVectorOperators can pair them, and only treat subtrees with
no
vector predicate as metadata. Adaptive pre-filter selection for plain
metadata
siblings is unchanged.
---
.../org/apache/pinot/core/plan/FilterPlanNode.java | 71 +++++++-----
.../apache/pinot/core/plan/FilterPlanNodeTest.java | 125 +++++++++++++++++++++
2 files changed, 166 insertions(+), 30 deletions(-)
diff --git
a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
index a1a2024c909..34b77301a25 100644
--- a/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
+++ b/pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java
@@ -214,9 +214,10 @@ public class FilterPlanNode implements PlanNode {
case AND:
childFilters = filter.getChildren();
childFilterOperators = new ArrayList<>(childFilters.size());
+ List<FilterContext> retainedChildFilters = new
ArrayList<>(childFilters.size());
for (FilterContext childFilter : childFilters) {
BaseFilterOperator childFilterOperator;
- if (isVectorSimilarityFilter(childFilter) &&
hasNonVectorSibling(childFilters)) {
+ if (isVectorSimilarityFilter(childFilter) &&
hasSafeMetadataSibling(childFilters)) {
// Pass filtered context so vector operator reports correct
execution mode
childFilterOperator = constructFilteredVectorOperator(childFilter,
numDocs);
} else {
@@ -228,13 +229,14 @@ public class FilterPlanNode implements PlanNode {
} else if (!childFilterOperator.isResultMatchingAll()) {
// Remove child filter operators that match all records
childFilterOperators.add(childFilterOperator);
+ retainedChildFilters.add(childFilter);
}
}
// Wire pre-filter bitmaps for filter-aware ANN: if an AND contains a
// VectorSimilarityFilterOperator alongside other filter children,
evaluate the
// non-vector filters first and pass the resulting bitmap to the
vector operator
// so it can restrict HNSW graph traversal to the pre-filtered
document set.
- wirePreFilterForVectorOperators(childFilterOperators, numDocs);
+ wirePreFilterForVectorOperators(retainedChildFilters,
childFilterOperators, numDocs);
return FilterOperatorUtils.getAndFilterOperator(_queryContext,
childFilterOperators, numDocs);
case OR:
childFilters = filter.getChildren();
@@ -398,11 +400,10 @@ public class FilterPlanNode implements PlanNode {
numDocs, true);
}
- /// Returns true if the child list contains at least one
non-VECTOR_SIMILARITY predicate
- /// (i.e., a real metadata filter sibling).
- private static boolean hasNonVectorSibling(List<FilterContext> childFilters)
{
+ /// Returns true if the child list contains at least one sibling subtree
with no vector predicate.
+ private static boolean hasSafeMetadataSibling(List<FilterContext>
childFilters) {
for (FilterContext child : childFilters) {
- if (!isVectorSimilarityFilter(child)) {
+ if (!containsVectorPredicate(child)) {
return true;
}
}
@@ -415,6 +416,24 @@ public class FilterPlanNode implements PlanNode {
&& filter.getPredicate().getType() == Predicate.Type.VECTOR_SIMILARITY;
}
+ /// Returns true when any node in the subtree is a vector top-K predicate.
Such a subtree must never be
+ /// materialized as metadata for another vector predicate because doing so
executes candidate generation out of its
+ /// original boolean context and can incorrectly push candidate results into
a sibling.
+ private static boolean containsVectorPredicate(FilterContext filter) {
+ if (filter.getType() == FilterContext.Type.PREDICATE) {
+ return filter.getPredicate().getType() ==
Predicate.Type.VECTOR_SIMILARITY;
+ }
+ if (filter.getType() == FilterContext.Type.AND || filter.getType() ==
FilterContext.Type.OR
+ || filter.getType() == FilterContext.Type.NOT) {
+ for (FilterContext child : filter.getChildren()) {
+ if (containsVectorPredicate(child)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
/// Constructs the vector radius filter operator based on index availability.
///
/// The radius operator always needs the forward index for exact distance
computation.
@@ -430,38 +449,37 @@ public class FilterPlanNode implements PlanNode {
vectorIndexConfig);
}
- /// Wires pre-filter bitmaps for filter-aware ANN search when an AND node
contains both
- /// vector similarity operators and non-vector filter operators.
+ /// Wires metadata bitmaps when an AND node contains both vector similarity
operators and non-vector filters.
///
- /// When the vector index reader supports pre-filtering (implements
- ///
[org.apache.pinot.segment.spi.index.reader.FilterAwareVectorIndexReader]), the
non-vector
- /// siblings are evaluated eagerly to produce a combined bitmap. This bitmap
is passed to the
- /// [VectorSimilarityFilterOperator] so that the HNSW graph traversal is
restricted to
- /// pre-filtered documents, improving recall for selective filters.
+ /// The existing adaptive behavior is preserved: only bitmap-producing
filters are considered, and
+ /// [VectorSearchStrategy] decides whether the filter-aware reader should
receive the bitmap.
///
/// **Trade-off: eager filter evaluation.** The non-vector filter predicates
are materialized
/// into bitmaps before the vector search begins. This is intentional
because the filter bitmap must
/// be fully materialized before it can be passed to the vector index for
pre-filtered ANN search.
- /// The [VectorSearchStrategy] selectivity check below ensures we only pay
this cost when the
- /// estimated cardinality suggests pre-filtering is worthwhile.
+ /// The [VectorSearchStrategy] selectivity check below ensures queries only
pay this cost when the estimated
+ /// cardinality suggests pre-filtering is worthwhile.
///
- /// If no vector operators are found or the reader does not support
pre-filtering,
- /// this method is a no-op and the AND operator falls back to the default
post-filter path.
+ /// If no vector operators are found, this method is a no-op. A query whose
reader does not support
+ /// pre-filtering retains the default post-filter path.
///
/// @param childOperators the list of child filter operators under an AND
node
/// @param numDocs total documents in the segment
- private void wirePreFilterForVectorOperators(List<BaseFilterOperator>
childOperators, int numDocs) {
+ private void wirePreFilterForVectorOperators(List<FilterContext>
childFilters,
+ List<BaseFilterOperator> childOperators, int numDocs) {
if (childOperators.size() < 2) {
return;
}
- // Find vector similarity operators that support pre-filtering
+ // Find indexed vector similarity operators and non-vector metadata
siblings.
List<VectorSimilarityFilterOperator> vectorOps = new ArrayList<>();
List<BaseFilterOperator> nonVectorOps = new ArrayList<>();
- for (BaseFilterOperator op : childOperators) {
- if (op instanceof VectorSimilarityFilterOperator) {
+ for (int i = 0; i < childOperators.size(); i++) {
+ FilterContext childFilter = childFilters.get(i);
+ BaseFilterOperator op = childOperators.get(i);
+ if (isVectorSimilarityFilter(childFilter) && op instanceof
VectorSimilarityFilterOperator) {
vectorOps.add((VectorSimilarityFilterOperator) op);
- } else {
+ } else if (!containsVectorPredicate(childFilter)) {
nonVectorOps.add(op);
}
}
@@ -498,9 +516,7 @@ public class FilterPlanNode implements PlanNode {
}
// Combine non-vector filter bitmaps via AND.
- // Note: this eagerly evaluates non-vector filters. BaseFilterOperator
subclasses cache
- // their results, so the subsequent evaluation by AndFilterOperator will
reuse the cached
- // bitmaps without double-evaluation.
+ // Bitmap-producing filters are cheap to evaluate again when the final AND
executes.
MutableRoaringBitmap combinedBitmap = null;
for (BaseFilterOperator op : nonVectorOps) {
BitmapCollection bitmapCollection = op.getBitmaps();
@@ -520,11 +536,6 @@ public class FilterPlanNode implements PlanNode {
// the estimated selectivity. Only pass the bitmap if the strategy
recommends
// FILTER_THEN_ANN; otherwise fall back to the default post-filter path.
int estimatedFilteredDocs = combinedBitmap.getCardinality();
- // isMutableSegment=false is acceptable here because the
supportsPreFilter() check above
- // already ensures we only reach this point for immutable segments with
- // FilterAwareVectorIndexReader. MutableVectorIndex does not implement
- // FilterAwareVectorIndexReader, so mutable segments exit early via the
- // anySupportsPreFilter guard.
// backendType and searchParams are passed as null here because at the
pre-filter wiring
// stage we are deciding whether to activate pre-filtering at all, not
per-backend tuning.
// The strategy currently uses only selectivity (numDocs,
estimatedFilteredDocs) for this
diff --git
a/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeTest.java
b/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeTest.java
index b505c2e8dfb..ec094f4ff53 100644
---
a/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeTest.java
+++
b/pinot-core/src/test/java/org/apache/pinot/core/plan/FilterPlanNodeTest.java
@@ -19,12 +19,15 @@
package org.apache.pinot.core.plan;
import java.lang.reflect.Method;
+import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.pinot.common.request.context.ExpressionContext;
import org.apache.pinot.common.request.context.FilterContext;
+import org.apache.pinot.common.request.context.predicate.IsNullPredicate;
import org.apache.pinot.common.request.context.predicate.Predicate;
import org.apache.pinot.common.request.context.predicate.RegexpLikePredicate;
+import
org.apache.pinot.common.request.context.predicate.VectorSimilarityPredicate;
import org.apache.pinot.core.common.BlockDocIdIterator;
import org.apache.pinot.core.common.BlockDocIdSet;
import org.apache.pinot.core.operator.blocks.FilterBlock;
@@ -42,8 +45,10 @@ import
org.apache.pinot.segment.spi.datasource.DataSourceMetadata;
import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
import
org.apache.pinot.segment.spi.index.mutable.ThreadSafeMutableRoaringBitmap;
import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.segment.spi.index.reader.FilterAwareVectorIndexReader;
import org.apache.pinot.segment.spi.index.reader.ForwardIndexReader;
import org.apache.pinot.segment.spi.index.reader.InvertedIndexReader;
+import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
import org.apache.pinot.segment.spi.index.reader.TextIndexReader;
import org.apache.pinot.spi.config.table.FieldConfig;
import org.apache.pinot.spi.data.DimensionFieldSpec;
@@ -51,9 +56,16 @@ import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;
import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
@@ -62,6 +74,103 @@ import static org.testng.Assert.assertTrue;
public class FilterPlanNodeTest {
+ @DataProvider(name = "nestedVectorSubtreeShapes")
+ public Object[][] nestedVectorSubtreeShapes() {
+ return new Object[][]{{"AND"}, {"OR"}, {"NOT_OR"}};
+ }
+
+ @Test(dataProvider = "nestedVectorSubtreeShapes")
+ public void
testNestedVectorSimilaritySubtreeIsNeverMaterializedAsMetadata(String shape) {
+ FilterAwareVectorIndexReader directVectorReader =
mock(FilterAwareVectorIndexReader.class);
+ FilterAwareVectorIndexReader nestedVectorReader =
mock(FilterAwareVectorIndexReader.class);
+ when(directVectorReader.supportsPreFilter()).thenReturn(true);
+ when(nestedVectorReader.supportsPreFilter()).thenReturn(true);
+ when(nestedVectorReader.getDocIds(Mockito.any(float[].class),
Mockito.anyInt(),
+ any(ImmutableRoaringBitmap.class))).thenReturn(bitmapOf(1));
+ DataSource directVectorDataSource = mock(DataSource.class);
+ DataSource nestedVectorDataSource = mock(DataSource.class);
+
when(directVectorDataSource.getVectorIndex()).thenReturn(directVectorReader);
+
when(nestedVectorDataSource.getVectorIndex()).thenReturn(nestedVectorReader);
+ DataSource metadataDataSource = mock(DataSource.class);
+ NullValueVectorReader nullValueVectorReader =
mock(NullValueVectorReader.class);
+ when(nullValueVectorReader.getNullBitmap()).thenReturn(bitmapOf(1));
+
when(metadataDataSource.getNullValueVector()).thenReturn(nullValueVectorReader);
+
+ IndexSegment segment = mock(IndexSegment.class);
+ SegmentMetadata metadata = mock(SegmentMetadata.class);
+ when(metadata.getTotalDocs()).thenReturn(1_000);
+ when(segment.getSegmentMetadata()).thenReturn(metadata);
+ when(segment.getDataSource("embeddingA",
null)).thenReturn(directVectorDataSource);
+ when(segment.getDataSource("embeddingB",
null)).thenReturn(nestedVectorDataSource);
+ when(segment.getDataSource("tenant", null)).thenReturn(metadataDataSource);
+
+ FilterContext directVector = FilterContext.forPredicate(new
VectorSimilarityPredicate(
+ ExpressionContext.forIdentifier("embeddingA"), new float[]{1.0f,
0.0f}, 2));
+ FilterContext nestedVector = FilterContext.forPredicate(new
VectorSimilarityPredicate(
+ ExpressionContext.forIdentifier("embeddingB"), new float[]{1.0f,
0.0f}, 2));
+ FilterContext metadataFilter = FilterContext.forPredicate(
+ new IsNullPredicate(ExpressionContext.forIdentifier("tenant")));
+ FilterContext nestedSubtree;
+ switch (shape) {
+ case "AND":
+ nestedSubtree = FilterContext.forAnd(List.of(nestedVector,
metadataFilter));
+ break;
+ case "OR":
+ nestedSubtree = FilterContext.forOr(List.of(nestedVector,
metadataFilter));
+ break;
+ case "NOT_OR":
+ nestedSubtree =
FilterContext.forNot(FilterContext.forOr(List.of(nestedVector,
metadataFilter)));
+ break;
+ default:
+ throw new IllegalStateException("Unexpected shape: " + shape);
+ }
+ QueryContext queryContext = mock(QueryContext.class);
+
when(queryContext.getFilter()).thenReturn(FilterContext.forAnd(List.of(directVector,
nestedSubtree)));
+
+ SegmentContext segmentContext = new SegmentContext(segment);
+ segmentContext.setDocIdsSnapshot(bitmapOf(0, 1, 2, 3));
+ new FilterPlanNode(segmentContext, queryContext).run();
+
+ verify(nestedVectorReader, never()).getDocIds(Mockito.any(float[].class),
Mockito.anyInt());
+ verify(nestedVectorReader, never()).getDocIds(Mockito.any(float[].class),
Mockito.anyInt(),
+ any(ImmutableRoaringBitmap.class));
+ verify(directVectorReader, never()).getDocIds(Mockito.any(float[].class),
Mockito.anyInt(),
+ any(ImmutableRoaringBitmap.class));
+ }
+
+ @Test
+ public void
testNonRestrictedMetadataScopePreservesAdaptivePostFilterBehavior() {
+ float[] queryVector = {1.0f, 0.0f};
+ FilterAwareVectorIndexReader vectorReader =
mock(FilterAwareVectorIndexReader.class);
+ when(vectorReader.supportsPreFilter()).thenReturn(true);
+ when(vectorReader.getDocIds(queryVector, 2)).thenReturn(bitmapOf(0, 1));
+
+ DataSource vectorDataSource = mock(DataSource.class);
+ when(vectorDataSource.getVectorIndex()).thenReturn(vectorReader);
+ DataSource metadataDataSource = mock(DataSource.class);
+ NullValueVectorReader nullValueVectorReader =
mock(NullValueVectorReader.class);
+ when(nullValueVectorReader.getNullBitmap()).thenReturn(bitmapOf(2, 3));
+
when(metadataDataSource.getNullValueVector()).thenReturn(nullValueVectorReader);
+
+ IndexSegment segment = mock(IndexSegment.class);
+ SegmentMetadata metadata = mock(SegmentMetadata.class);
+ when(metadata.getTotalDocs()).thenReturn(4);
+ when(segment.getSegmentMetadata()).thenReturn(metadata);
+ when(segment.getDataSource("embedding",
null)).thenReturn(vectorDataSource);
+ when(segment.getDataSource("tenant", null)).thenReturn(metadataDataSource);
+
+ FilterContext vectorFilter = FilterContext.forPredicate(new
VectorSimilarityPredicate(
+ ExpressionContext.forIdentifier("embedding"), queryVector, 2));
+ FilterContext metadataFilter = FilterContext.forPredicate(
+ new IsNullPredicate(ExpressionContext.forIdentifier("tenant")));
+ QueryContext queryContext = mock(QueryContext.class);
+
when(queryContext.getFilter()).thenReturn(FilterContext.forAnd(List.of(vectorFilter,
metadataFilter)));
+
+ Assert.assertTrue(getFilteredDocIds(new FilterPlanNode(new
SegmentContext(segment), queryContext).run()).isEmpty());
+ verify(vectorReader).getDocIds(queryVector, 2);
+ verify(vectorReader, never()).getDocIds(eq(queryVector), eq(2),
any(ImmutableRoaringBitmap.class));
+ }
+
@Test
public void testConsistentSnapshot()
throws Exception {
@@ -154,6 +263,22 @@ public class FilterPlanNodeTest {
return numDocsFiltered;
}
+ private static MutableRoaringBitmap getFilteredDocIds(BaseFilterOperator
operator) {
+ MutableRoaringBitmap docIds = new MutableRoaringBitmap();
+ BlockDocIdIterator iterator =
operator.nextBlock().getBlockDocIdSet().iterator();
+ int docId;
+ while ((docId = iterator.next()) != Constants.EOF) {
+ docIds.add(docId);
+ }
+ return docIds;
+ }
+
+ private static MutableRoaringBitmap bitmapOf(int... docIds) {
+ MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+ bitmap.add(docIds);
+ return bitmap;
+ }
+
@Test
public void regexpLikeUsesIFSTEvaluatorWhenIFSTAndInvertedAvailable()
throws Exception {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]