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 7f2895b3776 [Vector Upsert 1/5] Support allowed-document filtering in 
exact vector scan (#19297)
7f2895b3776 is described below

commit 7f2895b377668b2ee7722f69f7d2d052e3722926
Author: Xiang Fu <[email protected]>
AuthorDate: Wed Aug 26 12:34:59 2026 -0700

    [Vector Upsert 1/5] Support allowed-document filtering in exact vector scan 
(#19297)
    
    Extract a shared computeExactMatches helper that unifies the exact top-K and
    threshold scan paths and can restrict scoring to an allowed-document bitmap.
    
    Introduce VectorCandidateScope, an immutable set of document IDs a vector
    predicate is allowed to consider as candidates. Vector top-K is not 
monotonic,
    so unlike an ordinary predicate -- which is correct to intersect with the 
result
    afterwards -- a document set that defines what the query may see has to be
    applied before candidate generation. The type carries that contract and says
    nothing about where the restriction came from; callers decide that.
    ExactVectorScanFilterOperator accepts one via a new constructor overload, 
and no
    production caller passes one yet.
    
    Also let the operator record its own exact-scan search and fallback metrics,
    which were previously invisible to VectorSearchMetrics, and report the 
applied
    candidate-filter cardinality in explain output.
    
    Add filtered-reader coverage for HnswVectorIndexReader.
---
 .../filter/ExactVectorScanFilterOperator.java      | 188 ++++++++++++---------
 .../org/apache/pinot/core/plan/FilterPlanNode.java |   2 +-
 .../filter/ExactVectorScanFilterOperatorTest.java  | 152 ++++++++++++++++-
 .../index/creator/HnswVectorIndexCreatorTest.java  |  16 ++
 4 files changed, 273 insertions(+), 85 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java
index eaf0367ba18..958e72a7287 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperator.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.PriorityQueue;
@@ -36,6 +37,7 @@ import org.apache.pinot.spi.data.FieldSpec;
 import org.apache.pinot.spi.trace.FilterType;
 import org.apache.pinot.spi.trace.InvocationRecording;
 import org.apache.pinot.spi.trace.Tracing;
+import org.roaringbitmap.IntIterator;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.slf4j.Logger;
@@ -46,11 +48,11 @@ import org.slf4j.LoggerFactory;
 ///
 /// This operator is used when no ANN vector index exists on a segment for the 
target column
 /// (e.g., the segment was built before the vector index was added, or the 
index type is not
-/// supported). It reads all vectors from the forward index, computes exact 
distances to the
-/// query vector, and returns the top-K closest document IDs.
+/// supported). It reads vectors from the forward index, computes exact 
distances to the query vector, and returns the
+/// top-K closest document IDs. A mandatory candidate bitmap restricts the 
scan when present.
 ///
-/// The distance computation uses L2 (Euclidean) squared distance. For COSINE 
similarity,
-/// vectors should be pre-normalized. This matches the behavior of Lucene's 
HNSW implementation.
+/// Distance computation uses the function configured for the vector index, or 
Pinot's default when no index
+/// configuration is available.
 ///
 /// This operator is intentionally simple and correct rather than fast -- it 
is a safety net.
 /// A warning is logged when this operator is used because it scans all 
documents in the segment.
@@ -66,6 +68,10 @@ public class ExactVectorScanFilterOperator extends 
BaseFilterOperator {
   private final VectorExplainContext _vectorExplainContext;
   private final boolean _hasDistanceThreshold;
   private final float _distanceThreshold;
+  /// Documents this scan is allowed to consider, or null to scan the whole 
segment. Held by reference: the query
+  /// owns this bitmap and does not modify it while the plan executes.
+  @Nullable
+  private final ImmutableRoaringBitmap _requiredDocIds;
   private ImmutableRoaringBitmap _matches;
 
   /// Creates an exact scan operator.
@@ -74,28 +80,21 @@ public class ExactVectorScanFilterOperator extends 
BaseFilterOperator {
   /// @param predicate the vector similarity predicate containing query vector 
and top-K
   /// @param column the column name (for logging and explain)
   /// @param numDocs the total number of documents in the segment
-  public ExactVectorScanFilterOperator(ForwardIndexReader<?> 
forwardIndexReader,
-      VectorSimilarityPredicate predicate, String column, int numDocs) {
-    this(forwardIndexReader, predicate, column, numDocs, null, 
"vector_index_missing",
-        VectorSearchParams.DEFAULT);
-  }
-
-  public ExactVectorScanFilterOperator(ForwardIndexReader<?> 
forwardIndexReader,
-      VectorSimilarityPredicate predicate, String column, int numDocs, 
@Nullable VectorIndexConfig vectorIndexConfig,
-      String fallbackReason) {
-    this(forwardIndexReader, predicate, column, numDocs, vectorIndexConfig, 
fallbackReason,
-        VectorSearchParams.DEFAULT);
-  }
-
-  public ExactVectorScanFilterOperator(ForwardIndexReader<?> 
forwardIndexReader,
-      VectorSimilarityPredicate predicate, String column, int numDocs, 
@Nullable VectorIndexConfig vectorIndexConfig,
-      String fallbackReason, VectorSearchParams searchParams) {
+  /// @param vectorIndexConfig vector index configuration, used to resolve the 
distance function (may be null)
+  /// @param fallbackReason why this scan runs instead of an ANN search, 
reported in explain output
+  /// @param searchParams vector search parameters from query options
+  /// @param requiredDocIds documents this scan may consider, or null to scan 
the whole segment. The caller must not
+  ///        modify the bitmap afterwards.
+  public ExactVectorScanFilterOperator(ForwardIndexReader<?> 
forwardIndexReader, VectorSimilarityPredicate predicate,
+      String column, int numDocs, @Nullable VectorIndexConfig 
vectorIndexConfig, String fallbackReason,
+      VectorSearchParams searchParams, @Nullable ImmutableRoaringBitmap 
requiredDocIds) {
     super(numDocs, false);
     _forwardIndexReader = forwardIndexReader;
     _predicate = predicate;
     _column = column;
     _hasDistanceThreshold = searchParams.hasDistanceThreshold();
     _distanceThreshold = searchParams.getDistanceThreshold();
+    _requiredDocIds = requiredDocIds;
     float effectiveThreshold = _hasDistanceThreshold ? _distanceThreshold : 
-1f;
     _vectorExplainContext = new 
VectorExplainContext(VectorDistanceUtils.resolveBackendType(vectorIndexConfig),
         VectorDistanceUtils.resolveDistanceFunction(vectorIndexConfig), 
VectorExecutionMode.EXACT_SCAN,
@@ -149,6 +148,9 @@ public class ExactVectorScanFilterOperator extends 
BaseFilterOperator {
         + ", vector literal:" + Arrays.toString(_predicate.getValue())
         + ", topK to search:" + _predicate.getTopK()
         + ", fallbackReason:" + _vectorExplainContext.getFallbackReason()
+        + (_requiredDocIds != null
+            ? ", requiredDocIdFilterApplied:true, 
requiredDocIdFilterCardinality:" + _requiredDocIds.getCardinality()
+            : "")
         + ')';
   }
 
@@ -169,55 +171,42 @@ public class ExactVectorScanFilterOperator extends 
BaseFilterOperator {
     attributeBuilder.putString("vectorLiteral", 
Arrays.toString(_predicate.getValue()));
     attributeBuilder.putString("fallbackReason", 
_vectorExplainContext.getFallbackReason());
     attributeBuilder.putLongIdempotent("topKtoSearch", _predicate.getTopK());
+    if (_requiredDocIds != null) {
+      attributeBuilder.putBool("requiredDocIdFilterApplied", true);
+      attributeBuilder.putLong("requiredDocIdFilterCardinality", 
_requiredDocIds.getCardinality());
+    }
   }
 
   /// Performs brute-force exact search over all documents in the segment.
   /// When a distance threshold is set, returns all vectors within the 
threshold.
   /// Otherwise uses a max-heap to maintain the top-K closest vectors.
-  @SuppressWarnings("unchecked")
   private ImmutableRoaringBitmap computeExactTopK() {
-    LOGGER.warn("Performing exact vector scan fallback on column: {} for 
segment with {} docs. "
-            + "reason={}, distanceFunction={}, hasThreshold={}. "
-            + "This is expensive -- consider adding a vector index.",
-        _column, _numDocs, _vectorExplainContext.getFallbackReason(),
-        _vectorExplainContext.getDistanceFunction(), _hasDistanceThreshold);
-
-    float[] queryVector = _predicate.getValue();
-
-    if (_hasDistanceThreshold) {
-      return computeExactThreshold(queryVector);
+    ImmutableRoaringBitmap allowedDocIds = _requiredDocIds;
+    VectorSearchMetrics.getInstance().recordSearch(VectorSearchMode.EXACT_SCAN,
+        _vectorExplainContext.getBackendType());
+    
VectorSearchMetrics.getInstance().recordFallback(_vectorExplainContext.getFallbackReason());
+    if (allowedDocIds != null && allowedDocIds.isEmpty()) {
+      return new MutableRoaringBitmap();
     }
-
-    int topK = _predicate.getTopK();
-
-    // Max-heap: entry with largest distance is at the top so we can 
efficiently evict it
-    PriorityQueue<DocDistance> maxHeap = new PriorityQueue<>(topK + 1,
-        (a, b) -> Float.compare(b._distance, a._distance));
-
-    ForwardIndexReader rawReader = _forwardIndexReader;
-    try (ForwardIndexReaderContext context = rawReader.createContext()) {
-      for (int docId = 0; docId < _numDocs; docId++) {
-        float[] docVector = rawReader.getFloatMV(docId, context);
-        if (docVector == null || docVector.length == 0) {
-          continue;
-        }
-        float distance = VectorDistanceUtils.computeDistance(queryVector, 
docVector,
-            _vectorExplainContext.getDistanceFunction());
-        if (maxHeap.size() < topK) {
-          maxHeap.add(new DocDistance(docId, distance));
-        } else if (distance < maxHeap.peek()._distance) {
-          maxHeap.poll();
-          maxHeap.add(new DocDistance(docId, distance));
-        }
-      }
-    } catch (Exception e) {
-      throw new RuntimeException("Error during exact vector scan on column: " 
+ _column, e);
+    if (allowedDocIds != null) {
+      // Scanning a restricted candidate set is the steady-state path whenever 
the segment's vector index cannot do
+      // filtered search, so it is expected rather than anomalous. The 
recorded fallback metric carries the signal.
+      LOGGER.debug("Performing exact vector scan over {} allowed docs on 
column: {}. reason={}, distanceFunction={}, "
+              + "hasThreshold={}.",
+          allowedDocIds.getCardinality(), _column, 
_vectorExplainContext.getFallbackReason(),
+          _vectorExplainContext.getDistanceFunction(), _hasDistanceThreshold);
+    } else {
+      LOGGER.warn("Performing exact vector scan fallback on column: {} for 
segment with {} docs. "
+              + "reason={}, distanceFunction={}, hasThreshold={}. "
+              + "This is expensive -- consider adding a vector index.",
+          _column, _numDocs, _vectorExplainContext.getFallbackReason(),
+          _vectorExplainContext.getDistanceFunction(), _hasDistanceThreshold);
     }
 
-    MutableRoaringBitmap result = new MutableRoaringBitmap();
-    for (DocDistance dd : maxHeap) {
-      result.add(dd._docId);
-    }
+    float[] queryVector = _predicate.getValue();
+    Float threshold = _hasDistanceThreshold ? _distanceThreshold : null;
+    ImmutableRoaringBitmap result = computeExactMatches(_forwardIndexReader, 
queryVector, _predicate.getTopK(),
+        _numDocs, _vectorExplainContext.getDistanceFunction(), threshold, 
allowedDocIds, _column);
 
     LOGGER.debug("Exact vector scan on column: {} returned {} results from {} 
docs",
         _column, result.getCardinality(), _numDocs);
@@ -225,33 +214,80 @@ public class ExactVectorScanFilterOperator extends 
BaseFilterOperator {
     return result;
   }
 
-  /// Performs brute-force threshold scan: returns all vectors within the 
distance threshold.
-  @SuppressWarnings("unchecked")
-  private ImmutableRoaringBitmap computeExactThreshold(float[] queryVector) {
+  /// Performs an exact top-K or threshold search over all documents or the 
supplied allowed-document bitmap.
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  private static ImmutableRoaringBitmap 
computeExactMatches(ForwardIndexReader<?> forwardIndexReader,
+      float[] queryVector, int topK, int numDocs,
+      VectorIndexConfig.VectorDistanceFunction distanceFunction, @Nullable 
Float distanceThreshold,
+      @Nullable ImmutableRoaringBitmap allowedDocIds, String column) {
+    // Threshold search ignores topK; top-K search rejects non-positive values 
the same way the IVF readers do, so
+    // one query cannot fail on an indexed segment and quietly return nothing 
on a segment that falls back here.
+    if (distanceThreshold == null) {
+      Preconditions.checkArgument(topK > 0, "topK must be positive, got: %s", 
topK);
+    }
+    if (allowedDocIds != null && allowedDocIds.isEmpty()) {
+      return new MutableRoaringBitmap();
+    }
+
+    PriorityQueue<DocDistance> maxHeap = distanceThreshold == null
+        ? new PriorityQueue<>(topK + 1, (a, b) -> Float.compare(b._distance, 
a._distance)) : null;
     MutableRoaringBitmap result = new MutableRoaringBitmap();
-    ForwardIndexReader rawReader = _forwardIndexReader;
+    ForwardIndexReader rawReader = forwardIndexReader;
     try (ForwardIndexReaderContext context = rawReader.createContext()) {
-      for (int docId = 0; docId < _numDocs; docId++) {
-        float[] docVector = rawReader.getFloatMV(docId, context);
-        if (docVector == null || docVector.length == 0) {
-          continue;
+      if (allowedDocIds == null) {
+        for (int docId = 0; docId < numDocs; docId++) {
+          scoreDocument(rawReader, context, docId, queryVector, topK, 
distanceFunction, distanceThreshold,
+              maxHeap, result);
         }
-        float distance = VectorDistanceUtils.computeDistance(queryVector, 
docVector,
-            _vectorExplainContext.getDistanceFunction());
-        if (distance <= _distanceThreshold) {
-          result.add(docId);
+      } else {
+        IntIterator iterator = allowedDocIds.getIntIterator();
+        while (iterator.hasNext()) {
+          int docId = iterator.next();
+          // Query planning can race with later segment changes. Never read 
beyond the numDocs watermark captured by
+          // FilterPlanNode, even if a supplied bitmap contains larger 
document IDs.
+          if (docId < 0 || docId >= numDocs) {
+            break;
+          }
+          scoreDocument(rawReader, context, docId, queryVector, topK, 
distanceFunction, distanceThreshold,
+              maxHeap, result);
         }
       }
     } catch (Exception e) {
-      throw new RuntimeException("Error during exact threshold scan on column: 
" + _column, e);
+      throw new RuntimeException("Error during exact vector scan on column: " 
+ column, e);
     }
 
-    LOGGER.debug("Exact threshold scan on column: {} returned {} results from 
{} docs (threshold={})",
-        _column, result.getCardinality(), _numDocs, _distanceThreshold);
-
+    if (maxHeap != null) {
+      for (DocDistance docDistance : maxHeap) {
+        result.add(docDistance._docId);
+      }
+    }
     return result.toImmutableRoaringBitmap();
   }
 
+  private static void scoreDocument(ForwardIndexReader rawReader, 
ForwardIndexReaderContext context, int docId,
+      float[] queryVector, int topK, VectorIndexConfig.VectorDistanceFunction 
distanceFunction,
+      @Nullable Float distanceThreshold, @Nullable PriorityQueue<DocDistance> 
maxHeap,
+      MutableRoaringBitmap thresholdMatches) {
+    float[] docVector = rawReader.getFloatMV(docId, context);
+    if (docVector == null || docVector.length == 0) {
+      return;
+    }
+    float distance = VectorDistanceUtils.computeDistance(queryVector, 
docVector, distanceFunction);
+    if (distanceThreshold != null) {
+      if (distance <= distanceThreshold) {
+        thresholdMatches.add(docId);
+      }
+      return;
+    }
+
+    if (maxHeap.size() < topK) {
+      maxHeap.add(new DocDistance(docId, distance));
+    } else if (distance < maxHeap.peek()._distance) {
+      maxHeap.poll();
+      maxHeap.add(new DocDistance(docId, distance));
+    }
+  }
+
   /// Computes the squared L2 (Euclidean) distance between two vectors.
   /// Delegates to [VectorFunctions#euclideanDistance(float[], float[])] which 
returns
   /// the sum of squared differences (no sqrt), sufficient for ranking.
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 dea76ec01de..a1a2024c909 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
@@ -384,7 +384,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);
+        getVectorFallbackReason(vectorIndexConfig, isMutableSegment), 
searchParams, null);
   }
 
   /// Constructs a vector operator for a VECTOR_SIMILARITY predicate that is 
part of an AND
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperatorTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperatorTest.java
index 6bd92ba0f32..8bfc40a9b80 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperatorTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/ExactVectorScanFilterOperatorTest.java
@@ -27,10 +27,14 @@ import 
org.apache.pinot.segment.spi.index.reader.ForwardIndexReaderContext;
 import org.apache.pinot.spi.data.FieldSpec.DataType;
 import org.mockito.Mockito;
 import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.testng.Assert;
 import org.testng.annotations.Test;
 
 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;
 
 
@@ -56,7 +60,7 @@ public class ExactVectorScanFilterOperatorTest {
     VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(lhs, 
queryVector, 2);
 
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
-        "embedding", numDocs);
+        "embedding", numDocs, null, "vector_index_missing", 
VectorSearchParams.DEFAULT, null);
 
     // Should return doc 0 (distance=0) and doc 4 (distance=0.02)
     ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
@@ -81,7 +85,7 @@ public class ExactVectorScanFilterOperatorTest {
     VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(lhs, 
queryVector, 10);
 
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
-        "embedding", numDocs);
+        "embedding", numDocs, null, "vector_index_missing", 
VectorSearchParams.DEFAULT, null);
 
     ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
     Assert.assertEquals(result.getCardinality(), 3);
@@ -90,6 +94,132 @@ public class ExactVectorScanFilterOperatorTest {
     Assert.assertTrue(result.contains(2));
   }
 
+  @Test
+  public void testExactSearchOnlyScoresAllowedDocuments() {
+    float[][] vectors = {
+        {1.0f, 0.0f},
+        {1.0f, 0.0f},
+        {0.0f, 1.0f},
+        {0.0f, -1.0f}
+    };
+    ForwardIndexReader<?> mockReader = createMockForwardIndexReader(vectors);
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
2);
+    ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+        "embedding", 4, createVectorIndexConfig("HNSW", 
VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN),
+        "mutable_vector_index_not_filter_aware", VectorSearchParams.DEFAULT,
+        bitmapOf(2, 3));
+
+    Assert.assertEquals(operator.getBitmaps().reduce(), bitmapOf(2, 3));
+    ForwardIndexReader rawReader = mockReader;
+    verify(rawReader, never()).getFloatMV(Mockito.eq(0), Mockito.any());
+    verify(rawReader, never()).getFloatMV(Mockito.eq(1), Mockito.any());
+    verify(rawReader).getFloatMV(Mockito.eq(2), Mockito.any());
+    verify(rawReader).getFloatMV(Mockito.eq(3), Mockito.any());
+  }
+
+  @Test
+  public void testExactSearchWithEmptyAllowedDocumentsSkipsForwardIndex() {
+    ForwardIndexReader<?> mockReader = mock(ForwardIndexReader.class);
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
2);
+    ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+        "embedding", 4, null, "vector_index_missing", 
VectorSearchParams.DEFAULT,
+        new MutableRoaringBitmap());
+
+    Assert.assertTrue(operator.getBitmaps().reduce().isEmpty());
+    verifyNoInteractions(mockReader);
+  }
+
+  @Test
+  public void testExactSearchIgnoresAllowedDocumentsBeyondNumDocs() {
+    ForwardIndexReader<?> mockReader = createMockForwardIndexReader(new 
float[][]{
+        {1.0f, 0.0f}, {0.5f, 0.5f}, {0.0f, 1.0f}
+    });
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
2);
+    ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+        "embedding", 3, null, "mandatory_scope", VectorSearchParams.DEFAULT,
+        bitmapOf(2, 99));
+
+    Assert.assertEquals(operator.getBitmaps().reduce(), bitmapOf(2));
+    ForwardIndexReader rawReader = mockReader;
+    verify(rawReader).getFloatMV(Mockito.eq(2), Mockito.any());
+    verify(rawReader, never()).getFloatMV(Mockito.eq(99), Mockito.any());
+  }
+
+  /// topK comes straight from the query literal with no parse-time 
validation, so a segment falling back to this
+  /// operator must reject non-positive values exactly like the IVF readers 
do. Otherwise the same query errors on an
+  /// indexed segment and quietly returns nothing here.
+  @Test
+  public void testExactSearchRejectsNonPositiveTopK() {
+    ForwardIndexReader<?> mockReader = mock(ForwardIndexReader.class);
+
+    for (int topK : new int[]{0, -1}) {
+      VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+          ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 
0.0f}, topK);
+      ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+          "embedding", 4, null, "no_vector_index", VectorSearchParams.DEFAULT, 
null);
+      IllegalArgumentException exception =
+          Assert.expectThrows(IllegalArgumentException.class, 
operator::getBitmaps);
+      Assert.assertEquals(exception.getMessage(), "topK must be positive, got: 
" + topK);
+    }
+    verifyNoInteractions(mockReader);
+  }
+
+  /// Threshold search ignores topK entirely, so it must keep working when 
topK is not meaningful.
+  @Test
+  public void testExactThresholdSearchIgnoresNonPositiveTopK() {
+    ForwardIndexReader<?> mockReader = createMockForwardIndexReader(new 
float[][]{
+        {1.0f, 0.0f}, {0.0f, 1.0f}
+    });
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
0);
+    VectorSearchParams searchParams = new VectorSearchParams(null, null, null, 
0.5f, null, null, null);
+    ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+        "embedding", 2, null, "vector_index_missing", searchParams, null);
+
+    Assert.assertEquals(operator.getBitmaps().reduce(), bitmapOf(0));
+  }
+
+  @Test
+  public void testExactSearchTopKLargerThanAllowedCardinality() {
+    ForwardIndexReader<?> mockReader = createMockForwardIndexReader(new 
float[][]{
+        {1.0f, 0.0f},
+        {0.5f, 0.5f},
+        {0.0f, 1.0f},
+        {0.0f, -1.0f}
+    });
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
10);
+    ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+        "embedding", 4, null, "vector_index_missing", 
VectorSearchParams.DEFAULT,
+        bitmapOf(2, 3));
+
+    Assert.assertEquals(operator.getBitmaps().reduce(), bitmapOf(2, 3));
+  }
+
+  @Test
+  public void testExactThresholdSearchOnlyScoresAllowedDocuments() {
+    ForwardIndexReader<?> mockReader = createMockForwardIndexReader(new 
float[][]{
+        {1.0f, 0.0f},
+        {0.9f, 0.1f},
+        {0.0f, 1.0f},
+        {-1.0f, 0.0f}
+    });
+    VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(
+        ExpressionContext.forIdentifier("embedding"), new float[]{1.0f, 0.0f}, 
10);
+    VectorSearchParams searchParams = new VectorSearchParams(null, null, null, 
2.0f, null, null, null);
+    ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
+        "embedding", 4, null, "vector_index_missing", searchParams,
+        bitmapOf(2, 3));
+
+    Assert.assertEquals(operator.getBitmaps().reduce(), bitmapOf(2));
+    ForwardIndexReader rawReader = mockReader;
+    verify(rawReader, never()).getFloatMV(Mockito.eq(0), Mockito.any());
+    verify(rawReader, never()).getFloatMV(Mockito.eq(1), Mockito.any());
+  }
+
   @Test
   public void testL2SquaredDistance() {
     float[] a = {1.0f, 2.0f, 3.0f};
@@ -122,7 +252,7 @@ public class ExactVectorScanFilterOperatorTest {
     VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(lhs, 
queryVector, 2);
 
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
-        "embedding", numDocs);
+        "embedding", numDocs, null, "vector_index_missing", 
VectorSearchParams.DEFAULT, null);
 
     Assert.assertEquals(operator.getNumMatchingDocs(), 2);
   }
@@ -133,7 +263,7 @@ public class ExactVectorScanFilterOperatorTest {
     ExpressionContext lhs = ExpressionContext.forIdentifier("embedding");
     VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(lhs, 
new float[]{1.0f}, 1);
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
-        "embedding", 1);
+        "embedding", 1, null, "vector_index_missing", 
VectorSearchParams.DEFAULT, null);
     Assert.assertTrue(operator.canProduceBitmaps());
   }
 
@@ -144,7 +274,7 @@ public class ExactVectorScanFilterOperatorTest {
     VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(lhs, 
new float[]{1.0f, 2.0f}, 5);
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
         "embedding", 1, createVectorIndexConfig("IVF_PQ", 
VectorIndexConfig.VectorDistanceFunction.COSINE),
-        "ivf_pq_index_unavailable");
+        "ivf_pq_index_unavailable", VectorSearchParams.DEFAULT, null);
     String explain = operator.toExplainString();
     Assert.assertTrue(explain.contains("exact_scan"));
     Assert.assertTrue(explain.contains("embedding"));
@@ -167,7 +297,7 @@ public class ExactVectorScanFilterOperatorTest {
 
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
         "embedding", 2, createVectorIndexConfig("IVF_PQ", 
VectorIndexConfig.VectorDistanceFunction.COSINE),
-        "ivf_pq_index_unavailable");
+        "ivf_pq_index_unavailable", VectorSearchParams.DEFAULT, null);
 
     ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
     Assert.assertEquals(result.getCardinality(), 1);
@@ -188,7 +318,7 @@ public class ExactVectorScanFilterOperatorTest {
 
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
         "embedding", 2, createVectorIndexConfig("IVF_PQ", 
VectorIndexConfig.VectorDistanceFunction.INNER_PRODUCT),
-        "ivf_pq_index_unavailable");
+        "ivf_pq_index_unavailable", VectorSearchParams.DEFAULT, null);
 
     ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
     Assert.assertEquals(result.getCardinality(), 1);
@@ -208,7 +338,7 @@ public class ExactVectorScanFilterOperatorTest {
     VectorSimilarityPredicate predicate = new VectorSimilarityPredicate(lhs, 
queryVector, 1);
 
     ExactVectorScanFilterOperator operator = new 
ExactVectorScanFilterOperator(mockReader, predicate,
-        "embedding", 2);
+        "embedding", 2, null, "vector_index_missing", 
VectorSearchParams.DEFAULT, null);
 
     ImmutableRoaringBitmap result = operator.getBitmaps().reduce();
     Assert.assertEquals(result.getCardinality(), 1);
@@ -239,4 +369,10 @@ public class ExactVectorScanFilterOperatorTest {
     return new VectorIndexConfig(false, backendType, 2, 1, distanceFunction,
         Map.of("nlist", "4", "pqM", "2", "pqNbits", "8", "trainSampleSize", 
"16"));
   }
+
+  private static MutableRoaringBitmap bitmapOf(int... docIds) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    bitmap.add(docIds);
+    return bitmap;
+  }
 }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/HnswVectorIndexCreatorTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/HnswVectorIndexCreatorTest.java
index aacdb4e3980..0c42ec0f9e6 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/HnswVectorIndexCreatorTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/creator/HnswVectorIndexCreatorTest.java
@@ -26,6 +26,7 @@ import org.apache.commons.io.FileUtils;
 import 
org.apache.pinot.segment.local.segment.creator.impl.vector.HnswVectorIndexCreator;
 import 
org.apache.pinot.segment.local.segment.index.readers.vector.HnswVectorIndexReader;
 import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.testng.Assert;
 import org.testng.annotations.AfterMethod;
 import org.testng.annotations.BeforeMethod;
@@ -93,6 +94,21 @@ public class HnswVectorIndexCreatorTest {
     }
   }
 
+  @Test
+  public void testFilteredReaderReturnsKAllowedDocuments()
+      throws IOException {
+    MutableRoaringBitmap allowedDocIds = new MutableRoaringBitmap();
+    allowedDocIds.add(1);
+    allowedDocIds.add(3);
+    try (HnswVectorIndexReader reader = new HnswVectorIndexReader("foo", 
INDEX_DIR, 4, _config)) {
+      float[] queryVector = {5.0F, 42.0F, 54.33333F, 42.24F, 1001.045F};
+      Assert.assertEquals(reader.getDocIds(queryVector, 1).toArray(), new 
int[]{0},
+          "The nearest physical document should be outside the allowed set");
+      int[] matchedDocIds = reader.getDocIds(queryVector, 2, 
allowedDocIds).toArray();
+      Assert.assertEquals(matchedDocIds, new int[]{1, 3});
+    }
+  }
+
   @Test
   public void testEfSearchChangesRuntimeSearchBehavior()
       throws IOException {


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

Reply via email to