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 a4ed3a037c0 [Vector Upsert 4/5] Apply the query's visible-document set 
before vector candidate generation (#19300)
a4ed3a037c0 is described below

commit a4ed3a037c012b07c2255a3f83240ea3ea07e10b
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Aug 26 17:28:58 2026 -0700

    [Vector Upsert 4/5] Apply the query's visible-document set before vector 
candidate generation (#19300)
    
    FilterPlanNode constructed and executed VECTOR_SIMILARITY before adding
    SegmentContext.getDocIdsSnapshot() as an outer AND. Obsolete physical 
versions
    of upserted rows could therefore occupy per-segment ANN top-K slots and be
    removed only afterward, producing fewer than K rows or omitting nearer 
current
    rows.
    
    Pass the snapshot to vector predicates as a required VectorCandidateScope 
so it
    constrains candidate generation, while retaining the outer bitmap AND as 
defense
    in depth. FilterPlanNode remains the only place that knows the scope comes 
from
    the segment's queryable-document snapshot; the operators only see 'documents
    this predicate may consider'.
    
    Choose the execution path at plan time, where both the reader capability and
    forward index availability are known:
    
    - a filter-aware reader receives the scope and does filtered ANN;
    - a reader that cannot restrict its search is bypassed for
      ExactVectorScanFilterOperator over the allowed documents, which reports it
      through fallbackReason;
    - an empty scope needs no candidate generation and no reader capability at 
all,
      so it short-circuits to an empty operator;
    - when neither path is available the query fails clearly.
    
    Because the choice is made once, VectorSimilarityFilterOperator no longer 
needs
    a runtime exact-scan branch, and it captures the reader capability once 
instead
    of re-reading it per execution, so a plan built on one answer can never 
execute
    against another. Required scopes stay separate from optimizer-selected 
metadata
    filters and are intersected with them before candidate generation. 
Non-upsert
    queries keep the adaptive metadata behavior, and non-vector plans do not 
copy
    the snapshot.
---
 .../filter/VectorSimilarityFilterOperator.java     | 170 ++++++++++---
 .../org/apache/pinot/core/plan/FilterPlanNode.java |  65 ++++-
 .../filter/FilterAwareVectorSearchTest.java        | 122 ++++++++++
 .../apache/pinot/core/plan/FilterPlanNodeTest.java | 271 +++++++++++++++++++++
 4 files changed, 581 insertions(+), 47 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java
index 62e814d243c..1b850c80641 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.core.operator.filter;
 
 import com.google.common.base.CaseFormat;
+import com.google.common.base.Preconditions;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
@@ -58,9 +59,12 @@ import org.slf4j.LoggerFactory;
 ///      using exact distance from the forward index and re-sorted before 
final top-K selection.
 /// - **maxCandidates:** Controls how many ANN candidates are retrieved before 
rerank. Only
 ///      meaningful when rerank is enabled.
-/// - **Pre-filter:** For backends that implement 
FilterAwareVectorIndexReader, a pre-filter
-///      bitmap from sibling filter operators can be passed in to improve 
search quality under
-///      highly selective filters.
+/// - **Pre-filter:** For backends that implement 
FilterAwareVectorIndexReader, an optimizer-selected metadata bitmap
+///      can improve search quality under highly selective filters.
+/// - **Required candidate documents:** When the query restricts which 
documents are visible in a segment, that set
+///      is applied before candidate selection rather than intersected 
afterwards, because vector top-K is not
+///      monotonic. This operator requires a filter-aware reader in that case 
-- the planner picks
+///      [ExactVectorScanFilterOperator] when the reader cannot do filtered 
search.
 ///
 /// When no query options are specified, behavior is identical to the previous 
HNSW-only path
 /// (full backward compatibility).
@@ -84,13 +88,24 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
   private final boolean _hasThresholdPredicate;
   private final float _distanceThreshold;
   private final int _effectiveSearchCount;
+  /// Documents this predicate is allowed to consider as candidates, or null 
when the query places no such
+  /// restriction. Applied before top-K selection rather than intersected with 
the result, because vector top-K is
+  /// not monotonic: restricting the corpus changes which documents win.
+  @Nullable
+  private final ImmutableRoaringBitmap _requiredDocIds;
+  /// Captured once at construction: re-reading the reader capability per 
execution would let a plan built on one
+  /// answer execute against another.
+  private final boolean _readerSupportsPreFilter;
   private volatile VectorExplainContext _vectorExplainContext;
   private volatile int _annCandidateCount;
   private volatile int _rerankedCandidateCount;
   private ImmutableRoaringBitmap _matches;
   @Nullable
-  private volatile ImmutableRoaringBitmap _preFilterBitmap;
+  private volatile ImmutableRoaringBitmap _optionalPreFilterBitmap;
+  @Nullable
+  private volatile ImmutableRoaringBitmap _effectiveAllowedDocIds;
   private volatile VectorSearchMode _vectorSearchMode;
+  private volatile boolean _candidateGenerationSkipped;
 
   /// Backward-compatible constructor that uses default search params and no 
forward index.
   /// Existing callers that do not pass query options continue to work 
unchanged.
@@ -129,6 +144,20 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
   public VectorSimilarityFilterOperator(VectorIndexReader vectorIndexReader, 
VectorSimilarityPredicate predicate,
       int numDocs, VectorSearchParams searchParams, @Nullable 
ForwardIndexReader<?> forwardIndexReader,
       @Nullable VectorIndexConfig vectorIndexConfig, boolean 
hasMetadataFilter) {
+    this(vectorIndexReader, predicate, numDocs, searchParams, 
forwardIndexReader, vectorIndexConfig,
+        hasMetadataFilter, null);
+  }
+
+  /// Full constructor with a required candidate scope and optional 
optimizer-selected metadata.
+  ///
+  /// @param requiredDocIds documents this predicate may consider, applied 
before top-K selection, or null when the
+  ///        query places no such restriction. The reader must support 
filtered search when these are supplied;
+  ///        [org.apache.pinot.core.plan.FilterPlanNode] selects 
[ExactVectorScanFilterOperator] when it does not.
+  ///        Held by reference: the caller must not modify the bitmap 
afterwards.
+  public VectorSimilarityFilterOperator(VectorIndexReader vectorIndexReader, 
VectorSimilarityPredicate predicate,
+      int numDocs, VectorSearchParams searchParams, @Nullable 
ForwardIndexReader<?> forwardIndexReader,
+      @Nullable VectorIndexConfig vectorIndexConfig, boolean hasMetadataFilter,
+      @Nullable ImmutableRoaringBitmap requiredDocIds) {
     super(numDocs, false);
     _vectorIndexReader = vectorIndexReader;
     _predicate = predicate;
@@ -142,9 +171,14 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
     _hasMetadataFilter = hasMetadataFilter;
     _hasThresholdPredicate = searchParams.hasDistanceThreshold();
     _distanceThreshold = searchParams.getDistanceThreshold();
-    // When metadata filter is present, over-fetch ANN candidates to 
compensate for filter loss.
-    // Default over-fetch factor is 2x topK for filtered queries without 
explicit maxCandidates.
-    // For threshold queries, use a larger candidate pool since we need 
distance refinement.
+    _requiredDocIds = requiredDocIds;
+    _readerSupportsPreFilter = vectorIndexReader instanceof 
FilterAwareVectorIndexReader
+        && ((FilterAwareVectorIndexReader) 
vectorIndexReader).supportsPreFilter();
+    // An empty scope makes candidate generation unnecessary, so only a 
non-empty one demands filtered search.
+    Preconditions.checkState(_requiredDocIds == null || 
_requiredDocIds.isEmpty() || _readerSupportsPreFilter,
+        "Cannot honor required candidate doc IDs on vector column: %s -- 
vector index reader does not support "
+            + "filtered search", predicate.getLhs().getIdentifier());
+    // Threshold and exact-rerank queries use a larger candidate pool for 
refinement.
     int baseSearchCount;
     if (_hasThresholdPredicate) {
       // Threshold queries need a larger candidate pool for exact distance 
refinement.
@@ -156,27 +190,34 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
       // For plain top-K and filtered queries (no rerank, no threshold), 
always ask
       // the ANN index for exactly topK candidates. Over-fetching would change 
the
       // predicate semantics: vectorSimilarity(col, q, 10) must return at most 
10 docs.
-      // The metadata filter (bitmap AND) reduces this set further, which is 
correct.
+      // Adaptive metadata that remains post-filtered may reduce this set 
further. A required candidate scope is
+      // instead applied before this top-K selection.
       baseSearchCount = predicate.getTopK();
     }
     _effectiveSearchCount = baseSearchCount;
-    refreshExplainContext(null);
     _annCandidateCount = -1;
     _rerankedCandidateCount = -1;
     _matches = null;
-    _preFilterBitmap = null;
-    _vectorSearchMode = VectorSearchMode.POST_FILTER_ANN;
+    _optionalPreFilterBitmap = null;
+    recomputeEffectiveAllowedDocIds();
+    _vectorSearchMode = hasRequiredCandidateScope() ? 
VectorSearchMode.FILTER_THEN_ANN
+        : VectorSearchMode.POST_FILTER_ANN;
+    _candidateGenerationSkipped = hasRequiredCandidateScope() && 
_effectiveAllowedDocIds.isEmpty();
+    refreshExplainContext();
   }
 
-  /// Sets a pre-filter bitmap to restrict the ANN search to a subset of 
documents.
-  /// When set, the operator will use FILTER_THEN_ANN mode if the underlying 
reader
-  /// supports [FilterAwareVectorIndexReader].
+  /// Sets an optimizer-selected optional metadata bitmap. When a required 
candidate scope is also present, the
+  /// operator intersects private copies of the two bitmaps before candidate 
generation. When no scope is present,
+  /// readers that cannot pre-filter retain the existing unfiltered ANN 
behavior.
   ///
   /// This method must be called before any search execution (getTrues, 
getBitmaps, etc.).
   ///
   /// @param preFilterBitmap the bitmap of document IDs to restrict the search 
to
   public void setPreFilterBitmap(@Nullable ImmutableRoaringBitmap 
preFilterBitmap) {
-    _preFilterBitmap = preFilterBitmap;
+    _optionalPreFilterBitmap = copyBitmap(preFilterBitmap);
+    recomputeEffectiveAllowedDocIds();
+    _candidateGenerationSkipped = hasRequiredCandidateScope() && 
_effectiveAllowedDocIds.isEmpty();
+    refreshExplainContext();
   }
 
   @Override
@@ -244,6 +285,12 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
     if (explainContext.getFilterSelectivity() >= 0) {
       sb.append(", filterSelectivity:").append(String.format("%.4f", 
explainContext.getFilterSelectivity()));
     }
+    if (hasRequiredCandidateScope()) {
+      sb.append(", requiredDocIdFilterApplied:true")
+          .append(", 
requiredDocIdFilterCardinality:").append(_requiredDocIds.getCardinality())
+          .append(", 
effectiveAllowedDocIdsCardinality:").append(getEffectiveAllowedDocIdsCardinality())
+          .append(", 
candidateGenerationSkipped:").append(_candidateGenerationSkipped);
+    }
     sb.append(')');
     return sb.toString();
   }
@@ -292,12 +339,19 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
     if (explainContext.getFilterSelectivity() >= 0) {
       attributeBuilder.putString("filterSelectivity", String.format("%.4f", 
explainContext.getFilterSelectivity()));
     }
+    if (hasRequiredCandidateScope()) {
+      attributeBuilder.putBool("requiredDocIdFilterApplied", true);
+      // Additive, not idempotent: these counts differ per segment, and 
IDEMPOTENT attributes stop PlanNodeMerger
+      // from merging nodes whose values differ, which would expand a large 
explain to one node per segment.
+      attributeBuilder.putLong("requiredDocIdFilterCardinality", 
_requiredDocIds.getCardinality());
+      attributeBuilder.putLong("effectiveAllowedDocIdsCardinality", 
getEffectiveAllowedDocIdsCardinality());
+      attributeBuilder.putBool("candidateGenerationSkipped", 
_candidateGenerationSkipped);
+    }
   }
 
   /// Returns true if the underlying vector index reader supports pre-filter 
ANN search.
   public boolean supportsPreFilter() {
-    return _vectorIndexReader instanceof FilterAwareVectorIndexReader
-        && ((FilterAwareVectorIndexReader) 
_vectorIndexReader).supportsPreFilter();
+    return _readerSupportsPreFilter;
   }
 
   /// Executes the vector search with backend-specific parameter dispatch and 
optional rerank.
@@ -308,32 +362,37 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
     boolean backendParamsNeedCleanup = false;
     boolean searchExecuted = false;
     try {
+      ImmutableRoaringBitmap effectiveAllowedDocIds = _effectiveAllowedDocIds;
+      if (hasRequiredCandidateScope() && effectiveAllowedDocIds.isEmpty()) {
+        // Nothing in this segment is visible to the query, so candidate 
generation would be pure waste.
+        _candidateGenerationSkipped = true;
+        _annCandidateCount = 0;
+        _rerankedCandidateCount = 0;
+        return new MutableRoaringBitmap();
+      }
+      _candidateGenerationSkipped = false;
+
       // 1. Configure backend-specific parameters via interfaces
       // Claim cleanup before the first setter because configuration can fail 
after partially updating reader state.
       backendParamsNeedCleanup = true;
       configureBackendParams(column);
-      refreshExplainContext(null);
+      refreshExplainContext();
       explainContext = _vectorExplainContext;
 
       // 2. Determine effective search count (higher if rerank is enabled)
       int searchCount = explainContext.getEffectiveSearchCount();
 
       // 3. Execute ANN search (with pre-filter if available)
-      ImmutableRoaringBitmap preFilter = _preFilterBitmap;
+      ImmutableRoaringBitmap preFilter = effectiveAllowedDocIds;
       ImmutableRoaringBitmap annResults;
       searchExecuted = true;
-      if (preFilter != null && _vectorIndexReader instanceof 
FilterAwareVectorIndexReader) {
+      if (preFilter != null && _readerSupportsPreFilter) {
         FilterAwareVectorIndexReader filterAwareReader = 
(FilterAwareVectorIndexReader) _vectorIndexReader;
-        if (filterAwareReader.supportsPreFilter()) {
-          _vectorSearchMode = VectorSearchMode.FILTER_THEN_ANN;
-          annResults = filterAwareReader.getDocIds(queryVector, searchCount, 
preFilter);
-          LOGGER.debug("Pre-filter ANN search on column: {}, 
filterCardinality: {}, filterSelectivity: {}",
-              column, preFilter.getCardinality(),
-              _numDocs > 0 ? (double) preFilter.getCardinality() / _numDocs : 
0.0);
-        } else {
-          _vectorSearchMode = VectorSearchMode.POST_FILTER_ANN;
-          annResults = _vectorIndexReader.getDocIds(queryVector, searchCount);
-        }
+        _vectorSearchMode = VectorSearchMode.FILTER_THEN_ANN;
+        annResults = filterAwareReader.getDocIds(queryVector, searchCount, 
preFilter);
+        LOGGER.debug("Pre-filter ANN search on column: {}, filterCardinality: 
{}, filterSelectivity: {}",
+            column, preFilter.getCardinality(),
+            _numDocs > 0 ? (double) preFilter.getCardinality() / _numDocs : 
0.0);
       } else {
         _vectorSearchMode = VectorSearchMode.POST_FILTER_ANN;
         annResults = _vectorIndexReader.getDocIds(queryVector, searchCount);
@@ -391,7 +450,7 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
           VectorSearchMetrics.getInstance().recordSearch(_vectorSearchMode, 
_backendType);
         }
         // Refresh explain context with the final search mode decided during 
execution
-        refreshExplainContext(null);
+        refreshExplainContext();
       } finally {
         if (backendParamsNeedCleanup) {
           clearBackendParams(column);
@@ -521,14 +580,19 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
     return _backendType.name();
   }
 
-  private void refreshExplainContext(@Nullable String fallbackReason) {
-    VectorExecutionMode executionMode = 
VectorQueryExecutionContext.selectExecutionMode(
-        true, _hasMetadataFilter, _hasThresholdPredicate, 
_effectiveExactRerank);
-    ImmutableRoaringBitmap preFilter = _preFilterBitmap;
+  private void refreshExplainContext() {
+    VectorExecutionMode executionMode;
+    if (_vectorSearchMode == VectorSearchMode.FILTER_THEN_ANN) {
+      executionMode = VectorExecutionMode.FILTER_THEN_ANN;
+    } else {
+      executionMode = VectorQueryExecutionContext.selectExecutionMode(
+          true, _hasMetadataFilter, _hasThresholdPredicate, 
_effectiveExactRerank);
+    }
+    ImmutableRoaringBitmap preFilter = _effectiveAllowedDocIds;
     double filterSelectivity = (preFilter != null && _numDocs > 0)
         ? (double) preFilter.getCardinality() / _numDocs : -1.0;
-    Map<String, Object> indexDebugInfo =
-        _backendType.supportsNprobe() ? _vectorIndexReader.getIndexDebugInfo() 
: Map.of();
+    Map<String, Object> indexDebugInfo = !_candidateGenerationSkipped && 
_backendType.supportsNprobe()
+        ? _vectorIndexReader.getIndexDebugInfo() : Map.of();
     int effectiveEfSearch =
         resolveEffectiveEfSearch(_backendType, _searchParams);
     Boolean effectiveHnswUseRelativeDistance =
@@ -539,11 +603,41 @@ public class VectorSimilarityFilterOperator extends 
BaseFilterOperator {
     float effectiveThreshold = threshold != null ? threshold : -1f;
     _vectorExplainContext = new VectorExplainContext(_backendType, 
_distanceFunction, executionMode,
         resolveEffectiveNprobe(_backendType, _searchParams, indexDebugInfo),
-        _effectiveExactRerank, _effectiveSearchCount, fallbackReason, null,
+        _effectiveExactRerank, _effectiveSearchCount, null, null,
         effectiveEfSearch, effectiveThreshold, _vectorSearchMode, 
filterSelectivity,
         effectiveHnswUseRelativeDistance, effectiveHnswUseBoundedQueue);
   }
 
+  private boolean hasRequiredCandidateScope() {
+    return _requiredDocIds != null;
+  }
+
+  private void recomputeEffectiveAllowedDocIds() {
+    if (_requiredDocIds == null) {
+      _effectiveAllowedDocIds = _optionalPreFilterBitmap;
+      return;
+    }
+    if (_optionalPreFilterBitmap == null) {
+      _effectiveAllowedDocIds = _requiredDocIds;
+      return;
+    }
+    MutableRoaringBitmap effective = _requiredDocIds.toMutableRoaringBitmap();
+    effective.and(_optionalPreFilterBitmap);
+    _effectiveAllowedDocIds = effective.toImmutableRoaringBitmap();
+  }
+
+  private int getEffectiveAllowedDocIdsCardinality() {
+    return _effectiveAllowedDocIds != null ? 
_effectiveAllowedDocIds.getCardinality() : -1;
+  }
+
+  @Nullable
+  private static ImmutableRoaringBitmap copyBitmap(@Nullable 
ImmutableRoaringBitmap bitmap) {
+    if (bitmap == null) {
+      return null;
+    }
+    return bitmap.toMutableRoaringBitmap().toImmutableRoaringBitmap();
+  }
+
   private static int resolveEffectiveNprobe(VectorBackendType backendType, 
VectorSearchParams searchParams,
       Map<String, Object> indexDebugInfo) {
     if (!backendType.supportsNprobe()) {
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 34b77301a25..013774f3c03 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
@@ -65,6 +65,7 @@ import org.apache.pinot.segment.spi.index.IndexType;
 import org.apache.pinot.segment.spi.index.creator.VectorBackendType;
 import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
 import 
org.apache.pinot.segment.spi.index.multicolumntext.MultiColumnTextMetadata;
+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.JsonIndexReader;
 import org.apache.pinot.segment.spi.index.reader.NullValueVectorReader;
@@ -72,6 +73,7 @@ import 
org.apache.pinot.segment.spi.index.reader.TextIndexReader;
 import org.apache.pinot.segment.spi.index.reader.VectorIndexReader;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.exception.BadQueryRequestException;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 
 
@@ -80,6 +82,10 @@ public class FilterPlanNode implements PlanNode {
   private final SegmentContext _segmentContext;
   private final QueryContext _queryContext;
   private final FilterContext _filter;
+  /// Documents a vector predicate may consider as candidates for this query, 
or null when the query places no such
+  /// restriction. Held by reference; the snapshot is not modified while the 
plan executes.
+  @Nullable
+  private ImmutableRoaringBitmap _requiredVectorDocIds;
 
   // Cache the predicate evaluators
   private final List<Pair<Predicate, PredicateEvaluator>> _predicateEvaluators 
= new ArrayList<>(4);
@@ -97,20 +103,31 @@ public class FilterPlanNode implements PlanNode {
 
   @Override
   public BaseFilterOperator run() {
+    // Read the snapshot before the document count, never the other way round. 
A consuming segment publishes a row by
+    // adding it, then raising the count, then marking it valid, so reading 
the count last guarantees every document in
+    // the snapshot is below it. See FilterPlanNodeTest#testConsistentSnapshot.
     MutableRoaringBitmap docIdsSnapshot = _segmentContext.getDocIdsSnapshot();
     int numDocs = _indexSegment.getSegmentMetadata().getTotalDocs();
+    // Vector top-K is not monotonic, so a restricted visible-document set 
must reach candidate generation instead of
+    // only being intersected with the result. Test the snapshot first so 
ordinary queries skip the filter tree walk.
+    _requiredVectorDocIds = docIdsSnapshot != null && _filter != null && 
containsVectorPredicate(_filter)
+        ? docIdsSnapshot : null;
+
+    // Candidate generation and the outer valid-document AND must observe the 
same document set for this query.
+    ImmutableRoaringBitmap outerDocIdsSnapshot = docIdsSnapshot;
 
     if (_filter != null) {
       BaseFilterOperator filterOperator = constructPhysicalOperator(_filter, 
numDocs);
-      if (docIdsSnapshot != null) {
-        BaseFilterOperator validDocFilter = new 
BitmapBasedFilterOperator(docIdsSnapshot, false, numDocs);
+      if (outerDocIdsSnapshot != null) {
+        BaseFilterOperator validDocFilter =
+            new BitmapBasedFilterOperator(outerDocIdsSnapshot, false, numDocs);
         return FilterOperatorUtils.getAndFilterOperator(_queryContext, 
Arrays.asList(filterOperator, validDocFilter),
             numDocs);
       } else {
         return filterOperator;
       }
-    } else if (docIdsSnapshot != null) {
-      return new BitmapBasedFilterOperator(docIdsSnapshot, false, numDocs);
+    } else if (outerDocIdsSnapshot != null) {
+      return new BitmapBasedFilterOperator(outerDocIdsSnapshot, false, 
numDocs);
     } else {
       return new MatchAllFilterOperator(numDocs);
     }
@@ -367,8 +384,26 @@ public class FilterPlanNode implements PlanNode {
     boolean isMutableSegment = 
_indexSegment.getSegmentMetadata().isMutableSegment();
     VectorSearchParams searchParams = 
VectorSearchParams.fromQueryOptions(_queryContext.getQueryOptions());
 
+    // Nothing in this segment is visible to the query, so no candidate 
generation is needed and no reader
+    // capability is required. The outer valid-document AND would discard any 
result anyway.
+    if (_requiredVectorDocIds != null && _requiredVectorDocIds.isEmpty()) {
+      return EmptyFilterOperator.getInstance();
+    }
+
     if (vectorIndex != null) {
-      // ANN index path: pass forward index reader if rerank or threshold 
search requires exact distances
+      // A required candidate scope can only be honored by a reader that does 
filtered search. When it cannot, the
+      // ANN index is unusable for this query and an exact scan over the 
allowed documents is the correct plan --
+      // decided here, where both the reader capability and the forward index 
availability are known.
+      if (_requiredVectorDocIds != null && !supportsPreFilter(vectorIndex)) {
+        ForwardIndexReader<?> exactScanReader = dataSource.getForwardIndex();
+        Preconditions.checkState(exactScanReader != null,
+            "Cannot honor required candidate doc IDs on vector column: %s -- 
vector index reader does not support "
+                + "filtered search and no forward index is available", column);
+        return new ExactVectorScanFilterOperator(exactScanReader, predicate, 
column, numDocs, vectorIndexConfig,
+            getFilteredSearchUnsupportedReason(isMutableSegment), 
searchParams, _requiredVectorDocIds);
+      }
+
+      // ANN index path: pass the forward index when rerank or threshold needs 
exact distances.
       ForwardIndexReader<?> forwardIndexReader = null;
       VectorBackendType backendType = 
VectorDistanceUtils.resolveBackendType(vectorIndexConfig);
       if (searchParams.isExactRerank(backendType) || 
searchParams.hasDistanceThreshold()) {
@@ -378,7 +413,7 @@ public class FilterPlanNode implements PlanNode {
             column);
       }
       return new VectorSimilarityFilterOperator(vectorIndex, predicate, 
numDocs, searchParams, forwardIndexReader,
-          vectorIndexConfig, hasMetadataFilter);
+          vectorIndexConfig, hasMetadataFilter, _requiredVectorDocIds);
     }
 
     // Exact scan fallback: no vector index on this segment
@@ -386,7 +421,7 @@ public class FilterPlanNode implements PlanNode {
     Preconditions.checkState(forwardIndexReader != null,
         "Cannot apply VECTOR_SIMILARITY on column: %s -- no vector index and 
no forward index available", column);
     return new ExactVectorScanFilterOperator(forwardIndexReader, predicate, 
column, numDocs, vectorIndexConfig,
-        getVectorFallbackReason(vectorIndexConfig, isMutableSegment), 
searchParams, null);
+        getVectorFallbackReason(vectorIndexConfig, isMutableSegment), 
searchParams, _requiredVectorDocIds);
   }
 
   /// Constructs a vector operator for a VECTOR_SIMILARITY predicate that is 
part of an AND
@@ -460,8 +495,8 @@ public class FilterPlanNode implements PlanNode {
   /// 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, this method is a no-op. A query whose 
reader does not support
-  /// pre-filtering retains 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
@@ -560,6 +595,18 @@ public class FilterPlanNode implements PlanNode {
     }
   }
 
+  private static boolean supportsPreFilter(VectorIndexReader vectorIndex) {
+    return vectorIndex instanceof FilterAwareVectorIndexReader
+        && ((FilterAwareVectorIndexReader) vectorIndex).supportsPreFilter();
+  }
+
+  /// Reason reported by [ExactVectorScanFilterOperator] when a vector index 
exists but cannot restrict its search to
+  /// the documents the query is allowed to see.
+  private static String getFilteredSearchUnsupportedReason(boolean 
isMutableSegment) {
+    return isMutableSegment ? "mutable_vector_index_not_filter_aware"
+        : "vector_index_not_filter_aware";
+  }
+
   private static String getVectorFallbackReason(@Nullable VectorIndexConfig 
vectorIndexConfig,
       boolean isMutableSegment) {
     if (vectorIndexConfig == null || vectorIndexConfig.isDisabled()) {
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/FilterAwareVectorSearchTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/FilterAwareVectorSearchTest.java
index bef51f7e303..fb0e4a5a148 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/FilterAwareVectorSearchTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/FilterAwareVectorSearchTest.java
@@ -22,7 +22,12 @@ import 
org.apache.pinot.common.request.context.ExpressionContext;
 import 
org.apache.pinot.common.request.context.predicate.VectorSimilarityPredicate;
 import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
 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.ForwardIndexReaderContext;
 import org.apache.pinot.segment.spi.index.reader.VectorIndexReader;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.testng.Assert;
@@ -248,6 +253,98 @@ public class FilterAwareVectorSearchTest {
     verify(mockReader, never()).getDocIds(eq(queryVector), anyInt(), 
any(ImmutableRoaringBitmap.class));
   }
 
+  @Test
+  public void testRequiredCandidateScopeAlwaysUsesFilteredReader() {
+    FilterAwareVectorIndexReader mockReader = 
mock(FilterAwareVectorIndexReader.class);
+    float[] queryVector = {1.0f, 0.0f};
+    MutableRoaringBitmap requiredSnapshot = bitmapOf(2, 3);
+    MutableRoaringBitmap expectedResult = bitmapOf(2, 3);
+    when(mockReader.supportsPreFilter()).thenReturn(true);
+    when(mockReader.getDocIds(eq(queryVector), eq(2), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(expectedResult);
+
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2);
+
+    VectorSimilarityFilterOperator operator = new 
VectorSimilarityFilterOperator(mockReader, predicate, 4,
+        VectorSearchParams.DEFAULT, null, null, false, requiredSnapshot);
+
+    ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
+    Assert.assertEquals(result, expectedResult);
+
+    ArgumentCaptor<ImmutableRoaringBitmap> filterCaptor = 
ArgumentCaptor.forClass(ImmutableRoaringBitmap.class);
+    verify(mockReader).getDocIds(eq(queryVector), eq(2), 
filterCaptor.capture());
+    Assert.assertEquals(filterCaptor.getValue(), bitmapOf(2, 3));
+    verify(mockReader, never()).getDocIds(queryVector, 2);
+    
Assert.assertTrue(operator.toExplainString().contains("requiredDocIdFilterApplied:true"));
+    
Assert.assertTrue(operator.toExplainString().contains("requiredDocIdFilterCardinality:2"));
+    
Assert.assertTrue(operator.toExplainString().contains("searchMode:FILTER_THEN_ANN"));
+    
Assert.assertTrue(operator.getExplainInfo().getAttributes().get("requiredDocIdFilterApplied").getBool());
+    Assert.assertEquals(
+        
operator.getExplainInfo().getAttributes().get("requiredDocIdFilterCardinality").getLong(),
 2L);
+    
Assert.assertEquals(operator.getExplainInfo().getAttributes().get("searchMode").getString(),
+        "FILTER_THEN_ANN");
+  }
+
+  @Test
+  public void testEmptyRequiredCandidateScopeSkipsReader() {
+    FilterAwareVectorIndexReader mockReader = 
mock(FilterAwareVectorIndexReader.class);
+    float[] queryVector = {1.0f, 0.0f};
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2);
+    VectorSimilarityFilterOperator operator = new 
VectorSimilarityFilterOperator(mockReader, predicate, 4,
+        VectorSearchParams.DEFAULT, null, null, false, new 
MutableRoaringBitmap());
+
+    Assert.assertTrue(operator.getBitmaps().reduce().isEmpty());
+    // The reader is only asked about its capabilities, never to search.
+    verify(mockReader, never()).getDocIds(any(float[].class), anyInt());
+    verify(mockReader, never()).getDocIds(any(float[].class), anyInt(), 
any(ImmutableRoaringBitmap.class));
+    
Assert.assertTrue(operator.toExplainString().contains("candidateGenerationSkipped:true"));
+    
Assert.assertTrue(operator.getExplainInfo().getAttributes().get("candidateGenerationSkipped").getBool());
+  }
+
+  @Test
+  public void testRequiredAndOptionalFiltersAreIntersected() {
+    FilterAwareVectorIndexReader mockReader = 
mock(FilterAwareVectorIndexReader.class);
+    float[] queryVector = {1.0f, 0.0f};
+    MutableRoaringBitmap requiredSnapshot = bitmapOf(2, 3);
+    MutableRoaringBitmap optionalMetadata = bitmapOf(1, 3);
+    when(mockReader.supportsPreFilter()).thenReturn(true);
+    when(mockReader.getDocIds(eq(queryVector), eq(2), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(bitmapOf(3));
+
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2);
+    VectorSimilarityFilterOperator operator = new 
VectorSimilarityFilterOperator(mockReader, predicate, 4,
+        VectorSearchParams.DEFAULT, null, null, true,
+        requiredSnapshot);
+    operator.setPreFilterBitmap(optionalMetadata);
+
+    optionalMetadata.add(2);
+    ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
+    Assert.assertEquals(result, bitmapOf(3));
+    Assert.assertEquals(requiredSnapshot, bitmapOf(2, 3), "Required snapshot 
must not be mutated");
+
+    ArgumentCaptor<ImmutableRoaringBitmap> filterCaptor = 
ArgumentCaptor.forClass(ImmutableRoaringBitmap.class);
+    verify(mockReader).getDocIds(eq(queryVector), eq(2), 
filterCaptor.capture());
+    Assert.assertEquals(filterCaptor.getValue(), bitmapOf(3));
+    verify(mockReader, never()).getDocIds(queryVector, 2);
+  }
+
+  /// A reader that cannot do filtered search can never honor a required 
candidate scope. FilterPlanNode selects
+  /// ExactVectorScanFilterOperator in that case, so reaching this operator at 
all is a planning error.
+  @Test(expectedExceptions = IllegalStateException.class,
+      expectedExceptionsMessageRegExp = ".*required candidate doc IDs.*does 
not support filtered search.*")
+  public void testRequiredCandidateScopeRejectsReaderWithoutFilteredSearch() {
+    VectorIndexReader mockReader = mock(VectorIndexReader.class);
+    float[] queryVector = {1.0f, 0.0f};
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2);
+
+    new VectorSimilarityFilterOperator(mockReader, predicate, 4, 
VectorSearchParams.DEFAULT, null, null, false,
+        bitmapOf(2, 3));
+  }
+
   @Test
   public void testExplainStringIncludesSearchMode() {
     FilterAwareVectorIndexReader mockReader = 
mock(FilterAwareVectorIndexReader.class);
@@ -289,4 +386,29 @@ public class FilterAwareVectorSearchTest {
     Assert.assertEquals(context.getVectorSearchMode(), 
VectorSearchMode.POST_FILTER_ANN);
     Assert.assertEquals(context.getFilterSelectivity(), -1.0, 0.001);
   }
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  private static ForwardIndexReader<?> createMockForwardIndexReader(float[][] 
vectors) {
+    ForwardIndexReader mockReader = mock(ForwardIndexReader.class);
+    ForwardIndexReaderContext mockContext = 
mock(ForwardIndexReaderContext.class);
+    when(mockReader.createContext()).thenReturn(mockContext);
+    when(mockReader.isSingleValue()).thenReturn(false);
+    when(mockReader.isDictionaryEncoded()).thenReturn(false);
+    when(mockReader.getStoredType()).thenReturn(DataType.FLOAT);
+    for (int i = 0; i < vectors.length; i++) {
+      when(mockReader.getFloatMV(Mockito.eq(i), 
Mockito.any())).thenReturn(vectors[i]);
+    }
+    return mockReader;
+  }
+
+  private static VectorIndexConfig createVectorIndexConfig(
+      VectorIndexConfig.VectorDistanceFunction distanceFunction) {
+    return new VectorIndexConfig(false, "HNSW", 2, 1, distanceFunction, 
java.util.Map.of());
+  }
+
+  private static MutableRoaringBitmap bitmapOf(int... docIds) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    bitmap.add(docIds);
+    return bitmap;
+  }
 }
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 ec094f4ff53..a74e72d28b5 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
@@ -21,6 +21,7 @@ package org.apache.pinot.core.plan;
 import java.lang.reflect.Method;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.pinot.common.request.context.ExpressionContext;
 import org.apache.pinot.common.request.context.FilterContext;
@@ -30,8 +31,10 @@ 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.common.Operator;
 import org.apache.pinot.core.operator.blocks.FilterBlock;
 import org.apache.pinot.core.operator.filter.BaseFilterOperator;
+import org.apache.pinot.core.operator.filter.ExactVectorScanFilterOperator;
 import 
org.apache.pinot.core.operator.filter.predicate.BaseDictIdBasedRegexpLikePredicateEvaluator;
 import org.apache.pinot.core.operator.filter.predicate.PredicateEvaluator;
 import org.apache.pinot.core.query.request.context.QueryContext;
@@ -47,12 +50,15 @@ 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.ForwardIndexReaderContext;
 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.segment.spi.index.reader.VectorIndexReader;
 import org.apache.pinot.spi.config.table.FieldConfig;
 import org.apache.pinot.spi.data.DimensionFieldSpec;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mockito;
 import org.mockito.stubbing.Answer;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
@@ -66,6 +72,7 @@ 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.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertFalse;
@@ -74,11 +81,45 @@ import static org.testng.Assert.assertTrue;
 
 public class FilterPlanNodeTest {
 
+  @DataProvider(name = "vectorFilterShapes")
+  public Object[][] vectorFilterShapes() {
+    FilterContext vectorFilter = FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
2));
+    return new Object[][]{
+        {vectorFilter},
+        {FilterContext.forAnd(List.of(vectorFilter, 
FilterContext.CONSTANT_TRUE))},
+        {FilterContext.forOr(List.of(vectorFilter, 
FilterContext.CONSTANT_FALSE))},
+        {FilterContext.forNot(FilterContext.forNot(vectorFilter))}
+    };
+  }
+
   @DataProvider(name = "nestedVectorSubtreeShapes")
   public Object[][] nestedVectorSubtreeShapes() {
     return new Object[][]{{"AND"}, {"OR"}, {"NOT_OR"}};
   }
 
+  @DataProvider(name = "nonVectorFilterShapes")
+  public Object[][] nonVectorFilterShapes() {
+    return new Object[][]{{FilterContext.CONSTANT_TRUE}, {null}};
+  }
+
+  @Test(dataProvider = "nonVectorFilterShapes")
+  public void testNonVectorPlansApplyTheSnapshotUnchanged(FilterContext 
filter) {
+    IndexSegment segment = mock(IndexSegment.class);
+    SegmentMetadata metadata = mock(SegmentMetadata.class);
+    when(metadata.getTotalDocs()).thenReturn(4);
+    when(metadata.isMutableSegment()).thenReturn(true);
+    when(segment.getSegmentMetadata()).thenReturn(metadata);
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(filter);
+    MutableRoaringBitmap snapshot = bitmapOf(1, 3);
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(snapshot);
+
+    Assert.assertEquals(getFilteredDocIds(new FilterPlanNode(segmentContext, 
queryContext).run()), bitmapOf(1, 3));
+    verify(metadata, never()).isMutableSegment();
+  }
+
   @Test(dataProvider = "nestedVectorSubtreeShapes")
   public void 
testNestedVectorSimilaritySubtreeIsNeverMaterializedAsMetadata(String shape) {
     FilterAwareVectorIndexReader directVectorReader = 
mock(FilterAwareVectorIndexReader.class);
@@ -138,6 +179,146 @@ public class FilterPlanNodeTest {
         any(ImmutableRoaringBitmap.class));
   }
 
+  @Test
+  public void testNestedNotVectorIsNotPushedIntoSiblingCandidateScope() {
+    float[] directQuery = {1.0f, 0.0f};
+    float[] nestedQuery = {0.0f, 1.0f};
+    FilterAwareVectorIndexReader directVectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    FilterAwareVectorIndexReader nestedVectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    when(directVectorReader.supportsPreFilter()).thenReturn(true);
+    when(nestedVectorReader.supportsPreFilter()).thenReturn(true);
+    when(directVectorReader.getDocIds(eq(directQuery), eq(1), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(bitmapOf(0));
+    when(nestedVectorReader.getDocIds(eq(nestedQuery), eq(1), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(bitmapOf(0));
+    DataSource directDataSource = mock(DataSource.class);
+    DataSource nestedDataSource = mock(DataSource.class);
+    when(directDataSource.getVectorIndex()).thenReturn(directVectorReader);
+    when(nestedDataSource.getVectorIndex()).thenReturn(nestedVectorReader);
+    IndexSegment segment = mock(IndexSegment.class);
+    SegmentMetadata metadata = mock(SegmentMetadata.class);
+    when(metadata.getTotalDocs()).thenReturn(2);
+    when(segment.getSegmentMetadata()).thenReturn(metadata);
+    when(segment.getDataSource("embeddingA", 
null)).thenReturn(directDataSource);
+    when(segment.getDataSource("embeddingB", 
null)).thenReturn(nestedDataSource);
+    FilterContext directVector = FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embeddingA"), directQuery, 1));
+    FilterContext nestedVector = FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embeddingB"), nestedQuery, 1));
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(
+        FilterContext.forAnd(List.of(directVector, 
FilterContext.forNot(nestedVector))));
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(bitmapOf(0, 1));
+
+    Assert.assertTrue(getFilteredDocIds(new FilterPlanNode(segmentContext, 
queryContext).run()).isEmpty());
+    ArgumentCaptor<ImmutableRoaringBitmap> directScope = 
ArgumentCaptor.forClass(ImmutableRoaringBitmap.class);
+    verify(directVectorReader).getDocIds(eq(directQuery), eq(1), 
directScope.capture());
+    Assert.assertEquals(directScope.getValue(), bitmapOf(0, 1),
+        "NOT(vector) is a result predicate, not metadata that may narrow its 
sibling's top-K corpus");
+  }
+
+  @Test(dataProvider = "vectorFilterShapes")
+  public void 
testRequiredDocIdSnapshotReachesVectorCandidatesForAllBooleanShapes(FilterContext
 filter) {
+    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));
+    when(vectorReader.getDocIds(eq(queryVector), eq(2), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(bitmapOf(2, 3));
+
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    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(dataSource);
+
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(filter);
+    SegmentContext segmentContext = new SegmentContext(segment);
+    MutableRoaringBitmap snapshot = bitmapOf(2, 3);
+    segmentContext.setDocIdsSnapshot(snapshot);
+
+    BaseFilterOperator operator = new FilterPlanNode(segmentContext, 
queryContext).run();
+    Assert.assertEquals(getFilteredDocIds(operator), bitmapOf(2, 3));
+
+    ArgumentCaptor<ImmutableRoaringBitmap> filterCaptor = 
ArgumentCaptor.forClass(ImmutableRoaringBitmap.class);
+    verify(vectorReader).getDocIds(eq(queryVector), eq(2), 
filterCaptor.capture());
+    Assert.assertEquals(filterCaptor.getValue(), bitmapOf(2, 3));
+    verify(vectorReader, never()).getDocIds(queryVector, 2);
+  }
+
+  @Test
+  public void testEmptyDocIdSnapshotSkipsVectorReader() {
+    FilterAwareVectorIndexReader vectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    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(dataSource);
+
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
2)));
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(new MutableRoaringBitmap());
+
+    Assert.assertTrue(getFilteredDocIds(new FilterPlanNode(segmentContext, 
queryContext).run()).isEmpty());
+    verifyNoInteractions(vectorReader);
+  }
+
+  @Test
+  public void testNullDocIdSnapshotPreservesUnfilteredVectorSearch() {
+    float[] queryVector = {1.0f, 0.0f};
+    FilterAwareVectorIndexReader vectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    when(vectorReader.getDocIds(queryVector, 2)).thenReturn(bitmapOf(0, 1));
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    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(dataSource);
+
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2)));
+
+    Assert.assertEquals(getFilteredDocIds(new FilterPlanNode(new 
SegmentContext(segment), queryContext).run()),
+        bitmapOf(0, 1));
+    verify(vectorReader).getDocIds(queryVector, 2);
+    verify(vectorReader, never()).getDocIds(eq(queryVector), eq(2), 
any(ImmutableRoaringBitmap.class));
+  }
+
+  @Test
+  public void testRequiredDocIdSnapshotPreservesNotSemantics() {
+    float[] queryVector = {1.0f, 0.0f};
+    FilterAwareVectorIndexReader vectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    when(vectorReader.supportsPreFilter()).thenReturn(true);
+    when(vectorReader.getDocIds(eq(queryVector), eq(2), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(bitmapOf(2));
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    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(dataSource);
+    QueryContext queryContext = mock(QueryContext.class);
+    FilterContext vectorFilter = FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2));
+    
when(queryContext.getFilter()).thenReturn(FilterContext.forNot(vectorFilter));
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(bitmapOf(2, 3));
+
+    Assert.assertEquals(getFilteredDocIds(new FilterPlanNode(segmentContext, 
queryContext).run()), bitmapOf(3));
+    verify(vectorReader, never()).getDocIds(queryVector, 2);
+  }
+
   @Test
   public void 
testNonRestrictedMetadataScopePreservesAdaptivePostFilterBehavior() {
     float[] queryVector = {1.0f, 0.0f};
@@ -171,6 +352,64 @@ public class FilterPlanNodeTest {
     verify(vectorReader, never()).getDocIds(eq(queryVector), eq(2), 
any(ImmutableRoaringBitmap.class));
   }
 
+  @Test
+  public void 
testPlannerSelectsExactAllowedDocFallbackForNonFilterAwareReader() {
+    float[] queryVector = {1.0f, 0.0f};
+    VectorIndexReader vectorReader = mock(VectorIndexReader.class);
+    ForwardIndexReader<?> forwardIndexReader = 
createMockForwardIndexReader(new float[][]{
+        {1.0f, 0.0f}, {1.0f, 0.0f}, {0.0f, 1.0f}, {0.0f, -1.0f}
+    });
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    Mockito.doReturn(forwardIndexReader).when(dataSource).getForwardIndex();
+    IndexSegment segment = mock(IndexSegment.class);
+    SegmentMetadata metadata = mock(SegmentMetadata.class);
+    when(metadata.getTotalDocs()).thenReturn(4);
+    when(metadata.isMutableSegment()).thenReturn(true);
+    when(segment.getSegmentMetadata()).thenReturn(metadata);
+    when(segment.getDataSource("embedding", null)).thenReturn(dataSource);
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), queryVector, 2)));
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(bitmapOf(2, 3));
+
+    BaseFilterOperator operator = new FilterPlanNode(segmentContext, 
queryContext).run();
+    Assert.assertEquals(getFilteredDocIds(operator), bitmapOf(2, 3));
+    verify(vectorReader, never()).getDocIds(any(float[].class), eq(2));
+    verify(forwardIndexReader, never()).getFloatMV(eq(0), any());
+    verify(forwardIndexReader, never()).getFloatMV(eq(1), any());
+    verify(forwardIndexReader).getFloatMV(eq(2), any());
+    verify(forwardIndexReader).getFloatMV(eq(3), any());
+    ExactVectorScanFilterOperator exactScan = findOperator(operator, 
ExactVectorScanFilterOperator.class);
+    Assert.assertNotNull(exactScan,
+        "Planner must select the exact scan operator when the reader cannot 
restrict its search");
+    String explain = exactScan.toExplainString();
+    
Assert.assertTrue(explain.contains("fallbackReason:mutable_vector_index_not_filter_aware"),
 explain);
+    Assert.assertTrue(explain.contains("requiredDocIdFilterApplied:true"), 
explain);
+    Assert.assertTrue(explain.contains("requiredDocIdFilterCardinality:2"), 
explain);
+  }
+
+  @Test(expectedExceptions = IllegalStateException.class,
+      expectedExceptionsMessageRegExp = ".*required candidate doc IDs.*no 
forward index.*")
+  public void testPlannerFailsWhenRequiredFilterCannotBeHonored() {
+    VectorIndexReader vectorReader = mock(VectorIndexReader.class);
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    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(dataSource);
+    QueryContext queryContext = mock(QueryContext.class);
+    when(queryContext.getFilter()).thenReturn(FilterContext.forPredicate(new 
VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
2)));
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(bitmapOf(2, 3));
+
+    new FilterPlanNode(segmentContext, queryContext).run();
+  }
+
   @Test
   public void testConsistentSnapshot()
       throws Exception {
@@ -273,12 +512,44 @@ public class FilterPlanNodeTest {
     return docIds;
   }
 
+  /// Finds the first operator of the given type in the plan tree, or null 
when the plan contains none.
+  @Nullable
+  private static <T> T findOperator(Operator operator, Class<T> operatorClass) 
{
+    if (operatorClass.isInstance(operator)) {
+      return operatorClass.cast(operator);
+    }
+    List<? extends Operator> children = operator.getChildOperators();
+    if (children != null) {
+      for (Operator child : children) {
+        if (child != null) {
+          T found = findOperator(child, operatorClass);
+          if (found != null) {
+            return found;
+          }
+        }
+      }
+    }
+    return null;
+  }
+
   private static MutableRoaringBitmap bitmapOf(int... docIds) {
     MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
     bitmap.add(docIds);
     return bitmap;
   }
 
+
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  private static ForwardIndexReader<?> createMockForwardIndexReader(float[][] 
vectors) {
+    ForwardIndexReader mockReader = mock(ForwardIndexReader.class);
+    ForwardIndexReaderContext context = mock(ForwardIndexReaderContext.class);
+    when(mockReader.createContext()).thenReturn(context);
+    for (int i = 0; i < vectors.length; i++) {
+      when(mockReader.getFloatMV(Mockito.eq(i), 
Mockito.any())).thenReturn(vectors[i]);
+    }
+    return mockReader;
+  }
+
   @Test
   public void regexpLikeUsesIFSTEvaluatorWhenIFSTAndInvertedAvailable()
       throws Exception {


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

Reply via email to