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 20e3397efb9 Enable filtered vector search on mutable HNSW and share 
the doc-id filter query (#19303)
20e3397efb9 is described below

commit 20e3397efb9b26a6fb3c8940c512ddd48028e300
Author: Xiang Fu <[email protected]>
AuthorDate: Fri Sep 4 03:30:02 2026 -0700

    Enable filtered vector search on mutable HNSW and share the doc-id filter 
query (#19303)
    
    FULL-upsert vector search must apply the visible-document scope during 
candidate generation so obsolete rows cannot consume top-K slots. Make mutable 
HNSW honor that contract while consuming-segment rows are still uncommitted.
    
    Store Pinot document IDs as numeric doc values for filter membership and 
hit translation, and share the Lucene bitmap-filter query with immutable HNSW.
    
    Use a shared near-real-time SearcherManager and coalesce concurrent 
refreshes by exact writer generation, including out-of-order Pinot document IDs.
    
    Choose an exact forward-index scan for sparse mutable required scopes when 
available; retain filtered ANN for dense scopes and when the forward index is 
disabled.
    
    Avoid eager strategy explanation formatting for mode-only planning, return 
empty required scopes before search work, and preserve interruption as the 
IOException cause.
    
    Cover planner routing, refresh coalescing, live-writer visibility, 
shared-query behavior, realtime filtered HNSW, and FULL-upsert integration.
---
 .../core/operator/filter/VectorSearchStrategy.java |  48 +-
 .../org/apache/pinot/core/plan/FilterPlanNode.java |  27 +-
 .../operator/filter/VectorSearchStrategyTest.java  |  28 ++
 .../apache/pinot/core/plan/FilterPlanNodeTest.java | 128 ++++++
 .../tests/custom/HnswVectorRealtimeTest.java       | 236 ++++++++++
 .../tests/custom/VectorUpsertTableTest.java        |  10 +-
 .../realtime/impl/vector/MutableVectorIndex.java   | 309 +++++++++++--
 .../index/readers/vector/BaseFilterQuery.java      | 149 ++++++
 .../readers/vector/HnswVectorIndexReader.java      |  88 +---
 .../impl/vector/MutableVectorIndexTest.java        | 501 ++++++++++++++++++++-
 .../index/readers/vector/BaseFilterQueryTest.java  | 130 ++++++
 11 files changed, 1530 insertions(+), 124 deletions(-)

diff --git 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSearchStrategy.java
 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSearchStrategy.java
index cc1d3a2905c..f7e0edc03c8 100644
--- 
a/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSearchStrategy.java
+++ 
b/pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSearchStrategy.java
@@ -71,17 +71,32 @@ public final class VectorSearchStrategy {
   public static Decision decide(int numDocs, int estimatedFilteredDocs, 
boolean hasVectorIndex,
       boolean indexSupportsPreFilter, boolean isMutableSegment, @Nullable 
VectorBackendType backendType,
       @Nullable VectorSearchParams searchParams) {
+    return decide(numDocs, estimatedFilteredDocs, hasVectorIndex, 
indexSupportsPreFilter, isMutableSegment,
+        backendType, searchParams, true);
+  }
+
+  /// Decides only the search mode without constructing the explanation 
carried by [Decision].
+  public static VectorSearchMode decideMode(int numDocs, int 
estimatedFilteredDocs, boolean hasVectorIndex,
+      boolean indexSupportsPreFilter, boolean isMutableSegment, @Nullable 
VectorBackendType backendType,
+      @Nullable VectorSearchParams searchParams) {
+    return decide(numDocs, estimatedFilteredDocs, hasVectorIndex, 
indexSupportsPreFilter, isMutableSegment,
+        backendType, searchParams, false).getMode();
+  }
+
+  private static Decision decide(int numDocs, int estimatedFilteredDocs, 
boolean hasVectorIndex,
+      boolean indexSupportsPreFilter, boolean isMutableSegment, @Nullable 
VectorBackendType backendType,
+      @Nullable VectorSearchParams searchParams, boolean includeReason) {
 
     // No index → always exact scan
     if (!hasVectorIndex) {
       return new Decision(VectorSearchMode.EXACT_SCAN, -1.0,
-          "no_vector_index", numDocs, estimatedFilteredDocs);
+          includeReason ? "no_vector_index" : "", numDocs, 
estimatedFilteredDocs);
     }
 
     // Very small segment → exact scan is cheaper
     if (numDocs < MIN_ANN_SEGMENT_SIZE) {
       return new Decision(VectorSearchMode.EXACT_SCAN, -1.0,
-          "segment_too_small (numDocs=" + numDocs + " < " + 
MIN_ANN_SEGMENT_SIZE + ")",
+          includeReason ? "segment_too_small (numDocs=" + numDocs + " < " + 
MIN_ANN_SEGMENT_SIZE + ")" : "",
           numDocs, estimatedFilteredDocs);
     }
 
@@ -89,7 +104,7 @@ public final class VectorSearchStrategy {
     boolean hasFilter = estimatedFilteredDocs < numDocs;
     if (!hasFilter) {
       return new Decision(VectorSearchMode.POST_FILTER_ANN, 1.0,
-          "no_filter", numDocs, estimatedFilteredDocs);
+          includeReason ? "no_filter" : "", numDocs, estimatedFilteredDocs);
     }
 
     double selectivity = numDocs > 0 ? (double) estimatedFilteredDocs / 
numDocs : 1.0;
@@ -97,7 +112,9 @@ public final class VectorSearchStrategy {
     // Very few docs pass filter → exact scan on filtered set
     if (estimatedFilteredDocs < EXACT_SCAN_THRESHOLD) {
       return new Decision(VectorSearchMode.EXACT_SCAN, selectivity,
-          "filter_too_selective (filteredDocs=" + estimatedFilteredDocs + " < 
" + EXACT_SCAN_THRESHOLD + ")",
+          includeReason
+              ? "filter_too_selective (filteredDocs=" + estimatedFilteredDocs 
+ " < " + EXACT_SCAN_THRESHOLD + ")"
+              : "",
           numDocs, estimatedFilteredDocs);
     }
 
@@ -105,21 +122,26 @@ public final class VectorSearchStrategy {
     if (selectivity < HIGH_SELECTIVITY_THRESHOLD) {
       if (indexSupportsPreFilter && !isMutableSegment) {
         return new Decision(VectorSearchMode.FILTER_THEN_ANN, selectivity,
-            "high_selectivity (ratio=" + String.format("%.4f", selectivity) + 
" < "
-                + HIGH_SELECTIVITY_THRESHOLD + ")",
+            includeReason
+                ? "high_selectivity (ratio=" + String.format("%.4f", 
selectivity) + " < "
+                    + HIGH_SELECTIVITY_THRESHOLD + ")"
+                : "",
             numDocs, estimatedFilteredDocs);
       }
       // Pre-filter not supported → fall back to exact scan on filtered set
       return new Decision(VectorSearchMode.EXACT_SCAN, selectivity,
-          "high_selectivity_no_prefilter_support (ratio=" + 
String.format("%.4f", selectivity) + ")",
+          includeReason
+              ? "high_selectivity_no_prefilter_support (ratio=" + 
String.format("%.4f", selectivity) + ")" : "",
           numDocs, estimatedFilteredDocs);
     }
 
     // Low selectivity → post-filter ANN
     if (selectivity > LOW_SELECTIVITY_THRESHOLD) {
       return new Decision(VectorSearchMode.POST_FILTER_ANN, selectivity,
-          "low_selectivity (ratio=" + String.format("%.4f", selectivity) + " > 
"
-              + LOW_SELECTIVITY_THRESHOLD + ")",
+          includeReason
+              ? "low_selectivity (ratio=" + String.format("%.4f", selectivity) 
+ " > "
+                  + LOW_SELECTIVITY_THRESHOLD + ")"
+              : "",
           numDocs, estimatedFilteredDocs);
     }
 
@@ -133,14 +155,16 @@ public final class VectorSearchStrategy {
       double midpoint = (HIGH_SELECTIVITY_THRESHOLD + 
LOW_SELECTIVITY_THRESHOLD) / 2;
       if (selectivity < midpoint) {
         return new Decision(VectorSearchMode.FILTER_THEN_ANN, selectivity,
-            "cost_model_prefilter (ratio=" + String.format("%.4f", 
selectivity) + " < midpoint="
-                + String.format("%.4f", midpoint) + ")",
+            includeReason
+                ? "cost_model_prefilter (ratio=" + String.format("%.4f", 
selectivity) + " < midpoint="
+                    + String.format("%.4f", midpoint) + ")"
+                : "",
             numDocs, estimatedFilteredDocs);
       }
     }
 
     return new Decision(VectorSearchMode.POST_FILTER_ANN, selectivity,
-        "cost_model_postfilter (ratio=" + String.format("%.4f", selectivity) + 
")",
+        includeReason ? "cost_model_postfilter (ratio=" + 
String.format("%.4f", selectivity) + ")" : "",
         numDocs, estimatedFilteredDocs);
   }
 
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 013774f3c03..74bdb9f2a38 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
@@ -391,6 +391,7 @@ public class FilterPlanNode implements PlanNode {
     }
 
     if (vectorIndex != null) {
+      VectorBackendType backendType = 
VectorDistanceUtils.resolveBackendType(vectorIndexConfig);
       // 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.
@@ -403,9 +404,29 @@ public class FilterPlanNode implements PlanNode {
             getFilteredSearchUnsupportedReason(isMutableSegment), 
searchParams, _requiredVectorDocIds);
       }
 
+      // Mutable HNSW filtered search materializes the full NumericDocValues 
view before applying the bitmap. For a
+      // selective required scope, scanning only the visible forward-index 
rows is both exact and substantially
+      // cheaper. Preserve correctness for the other strategy outcomes by 
continuing to pass the required scope to
+      // candidate generation -- a required scope must never be applied as a 
post-filter. If the forward index is
+      // disabled, retain filtered ANN so this optimization does not make a 
previously executable query fail.
+      if (_requiredVectorDocIds != null && isMutableSegment) {
+        VectorSearchMode mode = VectorSearchStrategy.decideMode(numDocs,
+            _requiredVectorDocIds.getCardinality(),
+            /* hasVectorIndex= */ true,
+            /* indexSupportsPreFilter= */ true,
+            /* isMutableSegment= */ true,
+            backendType, searchParams);
+        if (mode == VectorSearchMode.EXACT_SCAN) {
+          ForwardIndexReader<?> exactScanReader = dataSource.getForwardIndex();
+          if (exactScanReader != null) {
+            return new ExactVectorScanFilterOperator(exactScanReader, 
predicate, column, numDocs, vectorIndexConfig,
+                "required_doc_ids_strategy_exact_scan", 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()) {
         forwardIndexReader = dataSource.getForwardIndex();
         Preconditions.checkState(!searchParams.hasDistanceThreshold() || 
forwardIndexReader != null,
@@ -575,7 +596,7 @@ public class FilterPlanNode implements PlanNode {
     // 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
     // decision. Per-backend and per-query-option tuning is handled later 
inside the operator.
-    VectorSearchStrategy.Decision decision = VectorSearchStrategy.decide(
+    VectorSearchMode mode = VectorSearchStrategy.decideMode(
         numDocs, estimatedFilteredDocs,
         /* hasVectorIndex= */ true,
         /* indexSupportsPreFilter= */ true,
@@ -583,7 +604,7 @@ public class FilterPlanNode implements PlanNode {
         /* backendType= */ null,
         /* searchParams= */ null);
 
-    if (decision.getMode() != VectorSearchMode.FILTER_THEN_ANN) {
+    if (mode != VectorSearchMode.FILTER_THEN_ANN) {
       return;
     }
 
diff --git 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorSearchStrategyTest.java
 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorSearchStrategyTest.java
index 0f81a4e0c9b..06f528b7863 100644
--- 
a/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorSearchStrategyTest.java
+++ 
b/pinot-core/src/test/java/org/apache/pinot/core/operator/filter/VectorSearchStrategyTest.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.core.operator.filter;
 
 import org.apache.pinot.segment.spi.index.creator.VectorBackendType;
+import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
@@ -28,6 +29,33 @@ import static org.testng.Assert.assertTrue;
 /// Tests for [VectorSearchStrategy] adaptive planner.
 public class VectorSearchStrategyTest {
 
+  @DataProvider(name = "strategyCases")
+  public Object[][] strategyCases() {
+    return new Object[][]{
+        {100000, 100000, false, false, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {100, 100, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {100000, 100000, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {100000, 50, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {1000000, 5000, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {1000000, 5000, true, false, false, VectorBackendType.IVF_FLAT, 
VectorSearchParams.DEFAULT},
+        {100000, 50000, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {1000000, 5000, true, true, true, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {100000, 5000, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT},
+        {100000, 15000, true, true, false, VectorBackendType.HNSW, 
VectorSearchParams.DEFAULT}
+    };
+  }
+
+  @Test(dataProvider = "strategyCases")
+  public void testModeOnlyDecisionMatchesDetailedDecision(int numDocs, int 
estimatedFilteredDocs,
+      boolean hasVectorIndex, boolean indexSupportsPreFilter, boolean 
isMutableSegment,
+      VectorBackendType backendType, VectorSearchParams searchParams) {
+    assertEquals(
+        VectorSearchStrategy.decideMode(numDocs, estimatedFilteredDocs, 
hasVectorIndex, indexSupportsPreFilter,
+            isMutableSegment, backendType, searchParams),
+        VectorSearchStrategy.decide(numDocs, estimatedFilteredDocs, 
hasVectorIndex, indexSupportsPreFilter,
+            isMutableSegment, backendType, searchParams).getMode());
+  }
+
   @Test
   public void testNoVectorIndex() {
     VectorSearchStrategy.Decision decision = VectorSearchStrategy.decide(
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 a74e72d28b5..896e9b24588 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
@@ -35,6 +35,7 @@ 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.VectorSimilarityFilterOperator;
 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;
@@ -390,6 +391,133 @@ public class FilterPlanNodeTest {
     Assert.assertTrue(explain.contains("requiredDocIdFilterCardinality:2"), 
explain);
   }
 
+  @Test
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  public void testMutableSparseRequiredScopeSelectsExactScan() {
+    int numDocs = 100_000;
+    float[] queryVector = {1.0f, 0.0f};
+    FilterAwareVectorIndexReader vectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    when(vectorReader.supportsPreFilter()).thenReturn(true);
+    ForwardIndexReader forwardIndexReader = mock(ForwardIndexReader.class);
+    ForwardIndexReaderContext context = mock(ForwardIndexReaderContext.class);
+    when(forwardIndexReader.createContext()).thenReturn(context);
+    when(forwardIndexReader.getFloatMV(Mockito.anyInt(), 
any())).thenAnswer(invocation -> {
+      int docId = invocation.getArgument(0);
+      if (docId == 50_003) {
+        return new float[]{1.0f, 0.0f};
+      }
+      if (docId == 99_991) {
+        return new float[]{0.9f, 0.0f};
+      }
+      return new float[]{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(numDocs);
+    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(17, 50_003, 99_991));
+
+    BaseFilterOperator operator = new FilterPlanNode(segmentContext, 
queryContext).run();
+    Assert.assertEquals(getFilteredDocIds(operator), bitmapOf(50_003, 99_991));
+    verify(vectorReader, never()).getDocIds(any(float[].class), 
Mockito.anyInt());
+    verify(vectorReader, never()).getDocIds(any(float[].class), 
Mockito.anyInt(),
+        any(ImmutableRoaringBitmap.class));
+    verify(forwardIndexReader).getFloatMV(eq(17), any());
+    verify(forwardIndexReader).getFloatMV(eq(50_003), any());
+    verify(forwardIndexReader).getFloatMV(eq(99_991), any());
+    verify(forwardIndexReader, never()).getFloatMV(eq(0), any());
+    ExactVectorScanFilterOperator exactScan = findOperator(operator, 
ExactVectorScanFilterOperator.class);
+    Assert.assertNotNull(exactScan);
+    String explain = exactScan.toExplainString();
+    
Assert.assertTrue(explain.contains("fallbackReason:required_doc_ids_strategy_exact_scan"),
 explain);
+    Assert.assertTrue(explain.contains("requiredDocIdFilterCardinality:3"), 
explain);
+  }
+
+  @Test
+  public void 
testMutableHighCardinalityRequiredScopeUsesPreFilterInsteadOfPostFilter() {
+    int numDocs = 10_000;
+    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(4_998, 4_999));
+    ForwardIndexReader<?> forwardIndexReader = mock(ForwardIndexReader.class);
+    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(numDocs);
+    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)));
+    MutableRoaringBitmap requiredDocIds = new MutableRoaringBitmap();
+    requiredDocIds.add(0L, 5_000L);
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(requiredDocIds);
+
+    BaseFilterOperator operator = new FilterPlanNode(segmentContext, 
queryContext).run();
+    Assert.assertEquals(getFilteredDocIds(operator), bitmapOf(4_998, 4_999));
+    ArgumentCaptor<ImmutableRoaringBitmap> requiredScope =
+        ArgumentCaptor.forClass(ImmutableRoaringBitmap.class);
+    verify(vectorReader).getDocIds(eq(queryVector), eq(2), 
requiredScope.capture());
+    Assert.assertEquals(requiredScope.getValue().getCardinality(), 5_000);
+    Assert.assertTrue(requiredScope.getValue().contains(4_999));
+    verify(vectorReader, never()).getDocIds(queryVector, 2);
+    verifyNoInteractions(forwardIndexReader);
+    Assert.assertNull(findOperator(operator, 
ExactVectorScanFilterOperator.class));
+    VectorSimilarityFilterOperator filteredAnn = findOperator(operator, 
VectorSimilarityFilterOperator.class);
+    Assert.assertNotNull(filteredAnn);
+    
Assert.assertTrue(filteredAnn.toExplainString().contains("searchMode:FILTER_THEN_ANN"));
+  }
+
+  @Test
+  public void 
testMutableSparseRequiredScopeWithoutForwardIndexRetainsPreFilter() {
+    int numDocs = 100_000;
+    float[] queryVector = {1.0f, 0.0f};
+    FilterAwareVectorIndexReader vectorReader = 
mock(FilterAwareVectorIndexReader.class);
+    when(vectorReader.supportsPreFilter()).thenReturn(true);
+    when(vectorReader.getDocIds(eq(queryVector), eq(1), 
any(ImmutableRoaringBitmap.class)))
+        .thenReturn(bitmapOf(50_003));
+    DataSource dataSource = mock(DataSource.class);
+    when(dataSource.getVectorIndex()).thenReturn(vectorReader);
+    IndexSegment segment = mock(IndexSegment.class);
+    SegmentMetadata metadata = mock(SegmentMetadata.class);
+    when(metadata.getTotalDocs()).thenReturn(numDocs);
+    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, 1)));
+    SegmentContext segmentContext = new SegmentContext(segment);
+    segmentContext.setDocIdsSnapshot(bitmapOf(17, 50_003, 99_991));
+
+    BaseFilterOperator operator = new FilterPlanNode(segmentContext, 
queryContext).run();
+    Assert.assertEquals(getFilteredDocIds(operator), bitmapOf(50_003));
+    ArgumentCaptor<ImmutableRoaringBitmap> requiredScope =
+        ArgumentCaptor.forClass(ImmutableRoaringBitmap.class);
+    verify(vectorReader).getDocIds(eq(queryVector), eq(1), 
requiredScope.capture());
+    Assert.assertEquals(requiredScope.getValue(), bitmapOf(17, 50_003, 
99_991));
+    verify(vectorReader, never()).getDocIds(queryVector, 1);
+    Assert.assertNull(findOperator(operator, 
ExactVectorScanFilterOperator.class));
+    VectorSimilarityFilterOperator filteredAnn = findOperator(operator, 
VectorSimilarityFilterOperator.class);
+    Assert.assertNotNull(filteredAnn);
+    
Assert.assertTrue(filteredAnn.toExplainString().contains("searchMode:FILTER_THEN_ANN"));
+  }
+
   @Test(expectedExceptions = IllegalStateException.class,
       expectedExceptionsMessageRegExp = ".*required candidate doc IDs.*no 
forward index.*")
   public void testPlannerFailsWhenRequiredFilterCannotBeHonored() {
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/HnswVectorRealtimeTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/HnswVectorRealtimeTest.java
new file mode 100644
index 00000000000..e2e4b6142a9
--- /dev/null
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/HnswVectorRealtimeTest.java
@@ -0,0 +1,236 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.integration.tests.custom;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import org.apache.avro.file.DataFileWriter;
+import org.apache.avro.generic.GenericData;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.pinot.spi.config.table.FieldConfig;
+import org.apache.pinot.spi.config.table.TableConfig;
+import org.apache.pinot.spi.config.table.TableType;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.apache.pinot.spi.data.Schema;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+
+
+/// Realtime integration coverage for filtered HNSW search on a **consuming** 
segment, where the vector index is
+/// `MutableVectorIndex` rather than the offline reader.
+///
+/// This pins a behavior change that reaches plain realtime tables, not only 
upsert ones: because the consuming
+/// segment now advertises `supportsPreFilter()`, a `vectorSimilarity` 
predicate combined with a metadata filter
+/// plans as `FILTER_THEN_ANN` instead of running an unfiltered ANN and 
intersecting afterwards. Nothing else
+/// covers this -- the other HNSW integration tests are offline-only, and 
[IvfPqVectorRealtimeTest] asserts the
+/// IVF_PQ exact-scan fallback.
+///
+/// ## Why the fixture is sized the way it is
+///
+/// `VectorSearchStrategy.decide` only wires the optional pre-filter when the 
filter matches at least
+/// `EXACT_SCAN_THRESHOLD` (1000) documents *and* selectivity falls below the 
mid-range cutoff of 0.105; anything
+/// more selective is cheaper as an exact scan, anything less selective is 
left to post-filtering. A single
+/// consuming segment of [#getCountStarResult] rows over [#NUM_CATEGORIES] 
categories yields 1250 matches at
+/// selectivity 0.083, which clears both bounds with margin. One Kafka 
partition and a flush size above the row
+/// count keep every row in one consuming segment, so the per-segment counts 
the planner sees are the ones
+/// computed here. The category column carries an inverted index because the 
wiring additionally requires every
+/// non-vector filter to produce a bitmap -- a scan-based predicate silently 
leaves the vector operator
+/// post-filtering.
+@Test(suiteName = "CustomClusterIntegrationTest")
+public class HnswVectorRealtimeTest extends 
CustomDataQueryClusterIntegrationTest {
+  private static final String DEFAULT_TABLE_NAME = "HnswVectorRealtimeTest";
+  private static final String VECTOR_COL = "embedding";
+  private static final String CATEGORY = "category";
+  private static final int VECTOR_DIM_SIZE = 32;
+  private static final int NUM_CATEGORIES = 12;
+  private static final int NUM_ROWS = 15000;
+  private static final String TARGET_CATEGORY = "cat_3";
+
+  @Override
+  protected long getCountStarResult() {
+    return NUM_ROWS;
+  }
+
+  @Override
+  public String getTableName() {
+    return DEFAULT_TABLE_NAME;
+  }
+
+  @Override
+  public boolean isRealtimeTable() {
+    return true;
+  }
+
+  /// Keep every row in a single consuming segment: the planner reasons about 
per-segment counts, so a mid-stream
+  /// commit would shrink them below the pre-filter thresholds this test 
depends on.
+  @Override
+  protected int getRealtimeSegmentFlushSize() {
+    return NUM_ROWS * 10;
+  }
+
+  @Override
+  protected int getNumKafkaPartitions() {
+    return 1;
+  }
+
+  @Override
+  public Schema createSchema() {
+    return new Schema.SchemaBuilder().setSchemaName(getTableName())
+        .addMultiValueDimension(VECTOR_COL, DataType.FLOAT)
+        .addSingleValueDimension(CATEGORY, DataType.STRING)
+        .addDateTimeField(getTimeColumnName(), DataType.LONG, 
"1:MILLISECONDS:EPOCH", "1:MILLISECONDS")
+        .build();
+  }
+
+  /// The inherited realtime builder attaches no field configs, so the HNSW 
index is declared here -- without it the
+  /// consuming segment would hold no vector index and these tests would 
silently exercise a scan instead.
+  @Override
+  protected TableConfig createRealtimeTableConfig(File sampleAvroFile) {
+    AvroFileSchemaKafkaAvroMessageDecoder._avroFile = sampleAvroFile;
+    return getTableConfigBuilder(TableType.REALTIME)
+        // The pre-filter is only wired when every non-vector filter can 
produce a bitmap; without an index the
+        // category predicate plans as a full scan and the vector operator 
never receives the bitmap.
+        .setInvertedIndexColumns(List.of(CATEGORY))
+        .setFieldConfigList(List.of(
+            new FieldConfig.Builder(VECTOR_COL)
+                .withIndexTypes(List.of(FieldConfig.IndexType.VECTOR))
+                .withEncodingType(FieldConfig.EncodingType.RAW)
+                .withProperties(Map.of(
+                    "vectorIndexType", "HNSW",
+                    "vectorDimension", String.valueOf(VECTOR_DIM_SIZE),
+                    "vectorDistanceFunction", "COSINE",
+                    "version", "1"))
+                .build()
+        ))
+        .build();
+  }
+
+  @Override
+  public List<File> createAvroFiles()
+      throws Exception {
+    org.apache.avro.Schema avroSchema = 
org.apache.avro.Schema.createRecord("myRecord", null, null, false);
+    org.apache.avro.Schema floatArraySchema =
+        
org.apache.avro.Schema.createArray(org.apache.avro.Schema.create(org.apache.avro.Schema.Type.FLOAT));
+    avroSchema.setFields(List.of(
+        new org.apache.avro.Schema.Field(VECTOR_COL, floatArraySchema, null, 
null),
+        new org.apache.avro.Schema.Field(CATEGORY,
+            org.apache.avro.Schema.create(org.apache.avro.Schema.Type.STRING), 
null, null),
+        new org.apache.avro.Schema.Field(getTimeColumnName(),
+            org.apache.avro.Schema.create(org.apache.avro.Schema.Type.LONG), 
null, null)
+    ));
+
+    try (AvroFilesAndWriters avroFilesAndWriters = 
createAvroFilesAndWriters(avroSchema)) {
+      List<DataFileWriter<GenericData.Record>> writers = 
avroFilesAndWriters.getWriters();
+      Random random = new Random(42);
+      long baseTimestamp = System.currentTimeMillis();
+      for (int i = 0; i < NUM_ROWS; i++) {
+        GenericData.Record record = new GenericData.Record(avroSchema);
+        Collection<Float> vector = new ArrayList<>(VECTOR_DIM_SIZE);
+        for (int d = 0; d < VECTOR_DIM_SIZE; d++) {
+          vector.add(random.nextFloat());
+        }
+        record.put(VECTOR_COL, vector);
+        record.put(CATEGORY, "cat_" + (i % NUM_CATEGORIES));
+        record.put(getTimeColumnName(), baseTimestamp + i);
+        writers.get(i % getNumAvroFiles()).append(record);
+      }
+      return avroFilesAndWriters.getAvroFiles();
+    }
+  }
+
+  /// Pins that the planner actually hands the metadata bitmap to the 
consuming segment's vector operator, so a
+  /// regression that stops wiring the pre-filter fails here rather than 
surfacing only as a recall difference.
+  ///
+  /// The operator's `searchMode` is deliberately not asserted: it is 
initialized to `POST_FILTER_ANN` and only
+  /// reassigned to `FILTER_THEN_ANN` while the search executes, and `EXPLAIN` 
plans without executing, so it
+  /// always reports the initial value here. `filterSelectivity` is derived 
from the bitmap the operator received,
+  /// which makes it the field that actually distinguishes a wired pre-filter 
from an unwired one.
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testExplainShowsPreFilterReachesConsumingSegment(boolean 
useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    String explainQuery = String.format(
+        "set explainAskingServers=true; EXPLAIN PLAN FOR "
+            + "SELECT cosineDistance(%s, %s) AS dist FROM %s "
+            + "WHERE vectorSimilarity(%s, %s, %d) AND %s = '%s' ORDER BY dist 
ASC LIMIT %d",
+        VECTOR_COL, queryVector(), getTableName(), VECTOR_COL, queryVector(), 
10, CATEGORY, TARGET_CATEGORY, 10);
+
+    String explain = postQuery(explainQuery).get("resultTable").toString();
+    assertTrue(explain.contains("backend"), "Explain should describe the 
vector index: " + explain);
+    // NUM_ROWS / NUM_CATEGORIES matches out of NUM_ROWS -- the bitmap the 
operator received, not an estimate.
+    assertTrue(explain.contains("filterSelectivity"),
+        "The metadata bitmap should be wired into the consuming segment's 
vector operator: " + explain);
+    assertTrue(explain.contains("0.0833"),
+        "Pre-filter selectivity should reflect the " + (NUM_ROWS / 
NUM_CATEGORIES) + " matching rows: " + explain);
+  }
+
+  /// Every row a filtered vector search returns must satisfy the predicate. A 
disallowed row here would mean
+  /// candidate generation ignored the pre-filter bitmap.
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testFilteredAnnReturnsOnlyMatchingRows(boolean 
useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    int topK = 10;
+    String query = String.format(
+        "SELECT cosineDistance(%s, %s) AS dist, %s FROM %s "
+            + "WHERE vectorSimilarity(%s, %s, %d) AND %s = '%s' "
+            + "ORDER BY dist ASC LIMIT %d",
+        VECTOR_COL, queryVector(), CATEGORY, getTableName(),
+        VECTOR_COL, queryVector(), topK, CATEGORY, TARGET_CATEGORY, topK);
+
+    JsonNode rows = postQuery(query).get("resultTable").get("rows");
+    assertEquals(rows.size(), topK, "Filtered ANN on a consuming segment must 
return a full topK");
+    double prevDist = -1;
+    for (int i = 0; i < rows.size(); i++) {
+      assertEquals(rows.get(i).get(1).asText(), TARGET_CATEGORY, "All results 
must match the filter");
+      double dist = rows.get(i).get(0).asDouble();
+      assertTrue(dist >= prevDist, "Results must be ordered by distance");
+      prevDist = dist;
+    }
+  }
+
+  /// Candidate generation constrained by the filter yields a full topK drawn 
from the matching rows. Under the
+  /// previous unfiltered-ANN-then-intersect behavior only the matching share 
of an unfiltered top-K survived,
+  /// which for this fixture would be roughly `topK / NUM_CATEGORIES` rows.
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testFilteredAnnDrawsTopKFromTheFilteredSet(boolean 
useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    int topK = 50;
+    String filteredQuery = String.format(
+        "SELECT count(*) FROM %s WHERE vectorSimilarity(%s, %s, %d) AND %s = 
'%s'",
+        getTableName(), VECTOR_COL, queryVector(), topK, CATEGORY, 
TARGET_CATEGORY);
+
+    long filteredCount = 
postQuery(filteredQuery).get("resultTable").get("rows").get(0).get(0).asLong();
+    assertTrue(filteredCount >= topK,
+        "Filtered ANN must draw a full topK from the matching rows, got " + 
filteredCount + " for topK " + topK
+            + "; post-intersection would yield roughly " + (topK / 
NUM_CATEGORIES));
+  }
+
+  private static String queryVector() {
+    return "ARRAY[0.5" + StringUtils.repeat(", 0.5", VECTOR_DIM_SIZE - 1) + 
"]";
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorUpsertTableTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorUpsertTableTest.java
index f7679b15431..1ab4107b123 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorUpsertTableTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorUpsertTableTest.java
@@ -267,10 +267,12 @@ public class VectorUpsertTableTest extends 
CustomDataQueryClusterIntegrationTest
     String explain = GroupByOptionsTest.toExplainStr(explainResponse, 
useMultiStageQueryEngine);
     assertExplainContains(explain, "requiredDocIdFilterApplied", true);
     if (tableName.equals(CONSUMING_TABLE_NAME)) {
-      // The mutable vector index cannot restrict its search, so the planner 
selects the exact scan operator.
-      assertTrue(explain.contains("VECTOR_SIMILARITY_EXACT_SCAN") || 
explain.contains("VectorSimilarityExactScan"),
-          "Consuming segments must fall back to the exact scan operator: " + 
explain);
-      assertExplainContains(explain, "fallbackReason", 
"mutable_vector_index_not_filter_aware");
+      // The consuming segment is intentionally small, so scanning only the 
required upsert-visible rows is cheaper
+      // and exact. Larger required scopes retain filtered ANN, as covered by 
FilterPlanNodeTest.
+      assertTrue(explain.contains("VECTOR_SIMILARITY_EXACT_SCAN")
+              || explain.contains("VectorSimilarityExactScan"),
+          "Consuming segments with a sparse required scope must use an exact 
scan: " + explain);
+      assertExplainContains(explain, "fallbackReason", 
"required_doc_ids_strategy_exact_scan");
     } else {
       assertTrue(explain.contains("VECTOR_SIMILARITY_INDEX") || 
explain.contains("VectorSimilarityIndex"),
           "Sealed segments must use their vector index: " + explain);
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
index 27d77ad37cf..d7ecb1b5e04 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndex.java
@@ -18,27 +18,39 @@
  */
 package org.apache.pinot.segment.local.realtime.impl.vector;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 import java.io.File;
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import org.apache.lucene.document.Document;
-import org.apache.lucene.document.StoredField;
+import org.apache.lucene.document.NumericDocValuesField;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexWriterConfig;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.NumericDocValues;
+import org.apache.lucene.index.ReaderUtil;
 import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.search.DocIdSetIterator;
 import org.apache.lucene.search.IndexSearcher;
 import org.apache.lucene.search.KnnFloatVectorQuery;
-import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherManager;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.util.IOUtils;
 import 
org.apache.pinot.segment.local.realtime.impl.invertedindex.RealtimeLuceneTextIndexSearcherPool;
 import 
org.apache.pinot.segment.local.segment.creator.impl.vector.XKnnFloatVectorField;
+import 
org.apache.pinot.segment.local.segment.index.readers.vector.BaseFilterQuery;
 import 
org.apache.pinot.segment.local.segment.index.readers.vector.LuceneHnswRuntimeControlUtils;
 import org.apache.pinot.segment.local.segment.store.VectorIndexUtils;
 import org.apache.pinot.segment.spi.V1Constants;
@@ -46,18 +58,42 @@ import 
org.apache.pinot.segment.spi.index.VectorIndexConfigProvider;
 import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
 import org.apache.pinot.segment.spi.index.mutable.MutableIndex;
 import org.apache.pinot.segment.spi.index.reader.EfSearchAware;
-import org.apache.pinot.segment.spi.index.reader.VectorIndexReader;
+import org.apache.pinot.segment.spi.index.reader.FilterAwareVectorIndexReader;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
 import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 
-/// A Vector index reader for the real-time Vector index values on the fly.
-/// Since there is no good mutable vector index implementation for topK 
search, we just do brute force search.
+/// A mutable HNSW vector index for real-time (consuming) segments, backed by 
a Lucene [IndexWriter] over a
+/// temp-dir [FSDirectory].
+///
+/// Every added document stores the supplied Pinot doc id as a 
[NumericDocValuesField]. The same doc values
+/// drive both filtered traversal and the translation of search hits, so no 
assumption is made that
+/// `ScoreDoc.doc == Pinot docId` (Lucene may renumber on merges).
+
+///
+/// Filtered search ([#getDocIds(float[], int, ImmutableRoaringBitmap)]) 
restricts HNSW candidate generation
+/// to the given Pinot doc ids (used to enforce the upsert doc-ids snapshot). 
It searches a near-real-time
+/// reader obtained from the writer, so uncommitted rows are visible -- 
required for upsert correctness,
+/// where the newest version of a record is the most recently added and may 
not be committed yet. The
+/// unfiltered path keeps searching the last committed generation (cheaper; 
commit cadence is controlled by
+/// `commitIntervalMs` / `commitDocs`).
+///
+/// **Cost of that freshness.** A refresh flushes the writer's RAM buffer, 
which for a vector field writes out
+/// the HNSW graph for the pending rows as a new Lucene segment. While a 
segment is actively consuming, nearly
+/// every filtered query finds new rows and therefore triggers one, so segment 
creation is driven by query rate
+/// rather than ingestion rate, and the resulting merges rebuild graphs. 
Concurrent callers coalesce onto a
+/// single refresh rather than each forcing their own. Bounding this properly 
means reopening on a background
+/// thread and having queries wait on a sequence number instead of driving the 
flush themselves -- see
+/// `RealtimeLuceneIndexRefreshManager`, which does that for the text indexes, 
and Lucene's
+/// `ControlledRealTimeReopenThread`. Until then, correctness is preserved at 
the cost of that flush.
 ///
 /// This class is thread-safe for single writer multiple readers.
-public class MutableVectorIndex implements VectorIndexReader, MutableIndex, 
VectorIndexConfigProvider, EfSearchAware {
+public class MutableVectorIndex
+    implements FilterAwareVectorIndexReader, MutableIndex, 
VectorIndexConfigProvider, EfSearchAware {
   private static final Logger LOGGER = 
LoggerFactory.getLogger(MutableVectorIndex.class);
+  private static final Comparator<ScoreDoc> LUCENE_DOC_ID_ORDER = 
Comparator.comparingInt(scoreDoc -> scoreDoc.doc);
   private static final RealtimeLuceneTextIndexSearcherPool SEARCHER_POOL =
       RealtimeLuceneTextIndexSearcherPool.getInstance();
   public static final String VECTOR_INDEX_DOC_ID_COLUMN_NAME = "DocID";
@@ -73,7 +109,25 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
   private final File _indexDir;
   private final FSDirectory _indexDirectory;
   private final IndexWriter _indexWriter;
-  private int _nextDocId;
+  // Near-real-time searcher over the writer, used by the filtered search path 
(upsert doc-ids snapshot
+  // enforcement) so uncommitted rows are visible; refreshed on demand, reused 
across queries
+  private final SearcherManager _searcherManager;
+  // Coordinates callers that need the same near-real-time generation. One 
caller refreshes while the others wait
+  // for its published watermark instead of serializing on 
maybeRefreshBlocking or reopening the same generation.
+  private final Object _searcherRefreshMonitor = new Object();
+  // Guarded by _searcherRefreshMonitor.
+  private boolean _searcherRefreshInProgress;
+  private volatile long _searcherRefreshCount;
+  // Number of documents added so far; used only for the commit cadence, never 
as a doc id. Written by the indexing
+  // thread only; read cross-thread for debug output, where staleness is 
acceptable.
+  private volatile int _numDocsAdded;
+  /// Sequence number of the newest row handed to the writer, and the sequence 
number the shared searcher was
+  /// last refreshed through. Together they let a filtered search skip the 
refresh when nothing has been added
+  /// since. Doc ids cannot be used for this: [MutableIndex#add] allows rows 
in arbitrary doc-id order, so a
+  /// doc-id watermark would skip the refresh a lower-numbered but newer row 
needs. Writer sequence numbers are
+  /// monotonic by construction. Written by the single indexing thread, read 
by query threads.
+  private volatile long _lastAddedSequenceNumber = -1;
+  private volatile long _searcherRefreshedThroughSequenceNumber = -1;
 
   private long _lastCommitTime;
   private final ThreadLocal<Integer> _efSearchOverride = new ThreadLocal<>();
@@ -97,19 +151,25 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
 
     FSDirectory indexDirectory = null;
     IndexWriter indexWriter = null;
+    SearcherManager searcherManager = null;
     try {
       // segment generation is always in V1 and later we convert (as part of 
post creation processing)
       // to V3 if segmentVersion is set to V3 in SegmentGeneratorConfig.
       indexDirectory = FSDirectory.open(_indexDir.toPath());
       LOGGER.info("Creating mutable HNSW index for segment: {}, column: {} at 
path: {} with {}", segmentName,
           vectorColumn, _indexDir.getAbsolutePath(), 
vectorIndexConfig.getProperties());
-      indexWriter = new IndexWriter(indexDirectory, 
VectorIndexUtils.getIndexWriterConfig(vectorIndexConfig));
+      // Always start empty. The directory is temp-scoped but its name is 
stable across restarts, so an unclean
+      // shutdown can leave documents written by an older build. The index is 
rebuilt from the stream, so appending
+      // those stale rows would be both unnecessary and incorrect.
+      indexWriter = new IndexWriter(indexDirectory,
+          
VectorIndexUtils.getIndexWriterConfig(vectorIndexConfig).setOpenMode(IndexWriterConfig.OpenMode.CREATE));
       indexWriter.commit();
+      searcherManager = new SearcherManager(indexWriter, false, false, null);
       _lastCommitTime = System.currentTimeMillis();
     } catch (Exception e) {
       // IndexWriter does not close the Directory passed to it, so both need 
to be closed.
       try {
-        IOUtils.close(indexWriter, indexDirectory);
+        IOUtils.close(searcherManager, indexWriter, indexDirectory);
       } catch (Exception closeEx) {
         e.addSuppressed(closeEx);
       }
@@ -119,6 +179,7 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
     }
     _indexDirectory = indexDirectory;
     _indexWriter = indexWriter;
+    _searcherManager = searcherManager;
   }
 
   @Override
@@ -136,10 +197,13 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
     XKnnFloatVectorField xKnnFloatVectorField =
         new XKnnFloatVectorField(_vectorColumn, floatValues, 
_vectorSimilarityFunction);
     docToIndex.add(xKnnFloatVectorField);
-    docToIndex.add(new StoredField(VECTOR_INDEX_DOC_ID_COLUMN_NAME, 
_nextDocId++));
+    // Store the SUPPLIED Pinot doc id (not an internal counter): the doc 
value translates search hits
+    // back to Pinot doc ids, and the doc-values field lets filtered search 
test bitmap membership per doc
+    docToIndex.add(new NumericDocValuesField(VECTOR_INDEX_DOC_ID_COLUMN_NAME, 
docId));
     try {
-      _indexWriter.addDocument(docToIndex);
-      if ((_lastCommitTime + _commitIntervalMs < System.currentTimeMillis()) 
|| (_nextDocId % _commitDocs == 0)) {
+      _lastAddedSequenceNumber = _indexWriter.addDocument(docToIndex);
+      _numDocsAdded++;
+      if ((_lastCommitTime + _commitIntervalMs < System.currentTimeMillis()) 
|| (_numDocsAdded % _commitDocs == 0)) {
         _indexWriter.commit();
         _lastCommitTime = System.currentTimeMillis();
         LOGGER.debug("Committed index for column: {}, segment: {}", 
_vectorColumn, _segmentName);
@@ -152,6 +216,22 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
 
   @Override
   public MutableRoaringBitmap getDocIds(float[] vector, int topK) {
+    return submitSearch(vector, topK, null);
+  }
+
+  @Override
+  public ImmutableRoaringBitmap getDocIds(float[] vector, int topK, 
ImmutableRoaringBitmap preFilterBitmap) {
+    // A null bitmap would fall through to an unfiltered search, returning doc 
ids outside any filter -- the
+    // silent degradation this reader's contract forbids, so fail loudly 
instead of deep inside Lucene.
+    Preconditions.checkNotNull(preFilterBitmap, "Pre-filter bitmap must not be 
null for filtered vector search");
+    if (preFilterBitmap.isEmpty()) {
+      return new MutableRoaringBitmap();
+    }
+    return submitSearch(vector, topK, preFilterBitmap);
+  }
+
+  private MutableRoaringBitmap submitSearch(float[] vector, int topK,
+      @Nullable ImmutableRoaringBitmap preFilterBitmap) {
     int effectiveEfSearch = getEffectiveEfSearch();
     boolean effectiveUseRelativeDistance = getEffectiveUseRelativeDistance();
     boolean effectiveUseBoundedQueue = getEffectiveUseBoundedQueue();
@@ -159,9 +239,12 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
     // This propagates QueryThreadContext for CPU/memory tracking without 
registering the task for cancellation,
     // preventing Thread.interrupt() during Lucene search which could corrupt 
FSDirectory.
     // See https://github.com/apache/lucene/issues/3315 and 
https://github.com/apache/lucene/issues/9309
+    // The pre-filter bitmap is captured by the lambda; callers hand this 
reader a bitmap that is never
+    // mutated after submission (the planner passes a query-scoped defensive 
copy), so the transfer into the
+    // executor thread is safe.
     Future<MutableRoaringBitmap> searchFuture = 
SEARCHER_POOL.getExecutorService().submit(
         () -> executeVectorSearch(vector, topK, effectiveEfSearch, 
effectiveUseRelativeDistance,
-            effectiveUseBoundedQueue));
+            effectiveUseBoundedQueue, preFilterBitmap));
     try {
       return searchFuture.get();
     } catch (InterruptedException e) {
@@ -230,7 +313,7 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
     info.put("effectiveEfSearch", getEffectiveEfSearch());
     info.put("effectiveHnswUseRelativeDistance", 
getEffectiveUseRelativeDistance());
     info.put("effectiveHnswUseBoundedQueue", getEffectiveUseBoundedQueue());
-    info.put("supportsPreFilter", false);
+    info.put("supportsPreFilter", supportsPreFilter());
     try (DirectoryReader directoryReader = 
DirectoryReader.open(_indexDirectory)) {
       info.put("numDocs", directoryReader.numDocs());
       info.put("numDeletedDocs", directoryReader.numDeletedDocs());
@@ -238,7 +321,7 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
     } catch (IOException e) {
       LOGGER.warn("Failed to load mutable HNSW debug stats for segment: {}, 
column: {}", _segmentName, _vectorColumn,
           e);
-      info.put("numDocs", _nextDocId);
+      info.put("numDocs", _numDocsAdded);
       info.put("numDeletedDocs", 0);
       info.put("luceneSegments", 0);
     }
@@ -246,17 +329,185 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
   }
 
   private MutableRoaringBitmap executeVectorSearch(float[] vector, int topK, 
int efSearch,
-      boolean useRelativeDistance, boolean useBoundedQueue) throws IOException 
{
+      boolean useRelativeDistance, boolean useBoundedQueue, @Nullable 
ImmutableRoaringBitmap preFilterBitmap)
+      throws IOException {
+    if (preFilterBitmap != null) {
+      // Filtered search enforces the query's visible-document set, so it must 
see every row that set names --
+      // including rows still in the writer's RAM buffer. Refreshing flushes 
the writer, so only refresh when this
+      // query can actually see past the last refresh, and coalesce callers 
waiting for the same generation. The
+      // added-doc watermark is read BEFORE refreshing so rows arriving during 
the refresh are not wrongly claimed
+      // as visible.
+      long lastAdded = _lastAddedSequenceNumber;
+      if (lastAdded > _searcherRefreshedThroughSequenceNumber) {
+        refreshSearcherThrough(lastAdded);
+      }
+      IndexSearcher indexSearcher = _searcherManager.acquire();
+      try {
+        return search(indexSearcher, vector, topK, efSearch, 
useRelativeDistance, useBoundedQueue,
+            new NumericDocValuesBitmapFilterQuery(preFilterBitmap));
+      } finally {
+        _searcherManager.release(indexSearcher);
+      }
+    }
+    // The unfiltered path keeps the cheaper last-committed view (bounded by 
commitIntervalMs / commitDocs)
     try (DirectoryReader directoryReader = 
DirectoryReader.open(_indexDirectory)) {
-      IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
-      KnnFloatVectorQuery query =
-          LuceneHnswRuntimeControlUtils.createQuery(_vectorColumn, vector, 
topK, efSearch,
-              useRelativeDistance, useBoundedQueue, null);
-      MutableRoaringBitmap docIds = new MutableRoaringBitmap();
-      TopDocs search = indexSearcher.search(query, topK);
-      Arrays.stream(search.scoreDocs).map(scoreDoc -> 
scoreDoc.doc).forEach(docIds::add);
+      return search(new IndexSearcher(directoryReader), vector, topK, 
efSearch, useRelativeDistance,
+          useBoundedQueue, null);
+    }
+  }
+
+  /// Refreshes the shared near-real-time searcher through 
`targetSequenceNumber`, coalescing concurrent callers.
+  ///
+  /// The winning caller publishes the exact generation it captured before 
refreshing and wakes the waiters. A
+  /// waiter whose target is newer then performs the next refresh. This is 
necessary because rows added during a
+  /// refresh are not guaranteed to be visible in the reopened reader.
+  private void refreshSearcherThrough(long targetSequenceNumber)
+      throws IOException {
+    synchronized (_searcherRefreshMonitor) {
+      while (_searcherRefreshInProgress
+          && _searcherRefreshedThroughSequenceNumber < targetSequenceNumber) {
+        onSearcherRefreshWait();
+        try {
+          _searcherRefreshMonitor.wait();
+        } catch (InterruptedException e) {
+          throw new IOException("Interrupted while waiting to refresh vector 
searcher for segment " + _segmentName
+              + " column " + _vectorColumn, e);
+        }
+      }
+      if (_searcherRefreshedThroughSequenceNumber >= targetSequenceNumber) {
+        return;
+      }
+      _searcherRefreshInProgress = true;
+    }
+
+    try {
+      beforeSearcherRefresh();
+      // A false return means another caller already owns SearcherManager's 
refresh lock. This is benign: use the
+      // blocking form so this call still returns a searcher covering the 
requested writer generation.
+      if (!_searcherManager.maybeRefresh()) {
+        _searcherManager.maybeRefreshBlocking();
+      }
+      synchronized (_searcherRefreshMonitor) {
+        _searcherRefreshedThroughSequenceNumber =
+            Math.max(_searcherRefreshedThroughSequenceNumber, 
targetSequenceNumber);
+        _searcherRefreshCount++;
+      }
+    } finally {
+      synchronized (_searcherRefreshMonitor) {
+        _searcherRefreshInProgress = false;
+        _searcherRefreshMonitor.notifyAll();
+      }
+    }
+  }
+
+  /// Test hook invoked by the winning refresher after it publishes the 
in-progress state.
+  @VisibleForTesting
+  void beforeSearcherRefresh()
+      throws IOException {
+  }
+
+  /// Test hook invoked when another caller is about to wait for the winning 
refresher.
+  @VisibleForTesting
+  void onSearcherRefreshWait() {
+  }
+
+  @VisibleForTesting
+  long getSearcherRefreshCount() {
+    return _searcherRefreshCount;
+  }
+
+  private MutableRoaringBitmap search(IndexSearcher indexSearcher, float[] 
vector, int topK, int efSearch,
+      boolean useRelativeDistance, boolean useBoundedQueue, @Nullable Query 
filterQuery)
+      throws IOException {
+    KnnFloatVectorQuery query =
+        LuceneHnswRuntimeControlUtils.createQuery(_vectorColumn, vector, topK, 
efSearch,
+            useRelativeDistance, useBoundedQueue, filterQuery);
+    MutableRoaringBitmap docIds = new MutableRoaringBitmap();
+    ScoreDoc[] scoreDocs = indexSearcher.search(query, topK).scoreDocs;
+    if (scoreDocs.length == 0) {
+      // Nothing matched, which is also the shape of a reader with no leaves: 
an index that has not committed
+      // yet has no doc values to resolve, and asking for them would fail 
rather than return no results.
       return docIds;
     }
+    // Translate Lucene doc ids to Pinot doc ids through the same doc values 
that drive filtering. Lucene doc
+    // ids are NOT guaranteed to equal Pinot doc ids (merges can renumber), so 
ScoreDoc.doc must never be used
+    // directly. Doc values are resolved per leaf rather than through a merged 
view over every leaf, and hits
+    // are visited in Lucene doc id order because NumericDocValues only 
advances forward.
+    Arrays.sort(scoreDocs, LUCENE_DOC_ID_ORDER);
+    List<LeafReaderContext> leaves = indexSearcher.getIndexReader().leaves();
+    int leafIndex = -1;
+    NumericDocValues pinotDocIds = null;
+    int leafDocBase = 0;
+    for (ScoreDoc scoreDoc : scoreDocs) {
+      int hitLeafIndex = ReaderUtil.subIndex(scoreDoc.doc, leaves);
+      if (hitLeafIndex != leafIndex) {
+        LeafReaderContext leaf = leaves.get(hitLeafIndex);
+        leafIndex = hitLeafIndex;
+        leafDocBase = leaf.docBase;
+        pinotDocIds = 
leaf.reader().getNumericDocValues(VECTOR_INDEX_DOC_ID_COLUMN_NAME);
+        if (pinotDocIds == null) {
+          throw new IllegalStateException("Missing Pinot doc id doc values for 
column: " + _vectorColumn);
+        }
+      }
+      if (!pinotDocIds.advanceExact(scoreDoc.doc - leafDocBase)) {
+        throw new IllegalStateException("Missing Pinot doc id for Lucene 
document: " + scoreDoc.doc);
+      }
+      docIds.add(Math.toIntExact(pinotDocIds.longValue()));
+    }
+    return docIds;
+  }
+
+  /// A Lucene query accepting only documents whose stored Pinot doc id is 
present in the given bitmap.
+  /// Membership is tested through the [NumericDocValues] written by 
[#add(Object[], int[], int)], so it is
+  /// correct regardless of how Lucene numbers or renumbers its internal doc 
ids.
+  private static class NumericDocValuesBitmapFilterQuery extends 
BaseFilterQuery {
+
+    NumericDocValuesBitmapFilterQuery(ImmutableRoaringBitmap bitmap) {
+      super(bitmap);
+    }
+
+    @Override
+    protected DocIdSetIterator createLeafIterator(LeafReaderContext context)
+        throws IOException {
+      NumericDocValues docIdValues = 
context.reader().getNumericDocValues(VECTOR_INDEX_DOC_ID_COLUMN_NAME);
+      if (docIdValues == null) {
+        // Every indexed document carries this field. Its absence indicates a 
corrupt or incompatible index, not a
+        // leaf with no matches; returning null would silently discard the 
entire leaf.
+        throw new IllegalStateException(
+            "Missing Pinot doc id doc values for column: " + 
VECTOR_INDEX_DOC_ID_COLUMN_NAME);
+      }
+      return new DocIdSetIterator() {
+        @Override
+        public int docID() {
+          return docIdValues.docID();
+        }
+
+        @Override
+        public int nextDoc()
+            throws IOException {
+          return skipToAccepted(docIdValues.nextDoc());
+        }
+
+        @Override
+        public int advance(int target)
+            throws IOException {
+          return skipToAccepted(docIdValues.advance(target));
+        }
+
+        private int skipToAccepted(int doc)
+            throws IOException {
+          while (doc != NO_MORE_DOCS && !_docIds.contains((int) 
docIdValues.longValue())) {
+            doc = docIdValues.nextDoc();
+          }
+          return doc;
+        }
+
+        @Override
+        public long cost() {
+          return _docIds.getLongCardinality();
+        }
+      };
+    }
   }
 
   private int getEffectiveEfSearch() {
@@ -279,11 +530,13 @@ public class MutableVectorIndex implements 
VectorIndexReader, MutableIndex, Vect
     try {
       _indexWriter.commit();
       // IndexWriter does not close the Directory passed to it, so both need 
to be closed.
-      IOUtils.close(_indexWriter, _indexDirectory);
-    } catch (IOException e) {
-      // Both close() implementations are idempotent, so this is a no-op for 
whatever was already closed above.
-      IOUtils.closeWhileHandlingException(_indexWriter, _indexDirectory);
-      throw new RuntimeException(e);
+      IOUtils.close(_searcherManager, _indexWriter, _indexDirectory);
+    } catch (Exception e) {
+      // commit() can also fail unchecked (for example, after a tragic writer 
event). Close every remaining resource
+      // so the SearcherManager cannot pin an NRT reader and its file handles 
for the life of the process. All close()
+      // implementations are idempotent, so this is a no-op for resources that 
were already closed above.
+      IOUtils.closeWhileHandlingException(_searcherManager, _indexWriter, 
_indexDirectory);
+      throw e instanceof RuntimeException ? (RuntimeException) e : new 
RuntimeException(e);
     } finally {
       deleteIndexDir();
     }
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseFilterQuery.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseFilterQuery.java
new file mode 100644
index 00000000000..144db5c174d
--- /dev/null
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseFilterQuery.java
@@ -0,0 +1,149 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.segment.local.segment.index.readers.vector;
+
+import java.io.IOException;
+import javax.annotation.Nullable;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.search.ConstantScoreWeight;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryVisitor;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Scorer;
+import org.apache.lucene.search.Weight;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+
+
+/// Base class for Lucene [Query] implementations that accept only documents 
whose Pinot doc id is present
+/// in a [ImmutableRoaringBitmap]. Used to implement pre-filter ANN search by 
restricting HNSW graph
+/// traversal to the filtered document set.
+///
+/// Because Lucene uses its own internal doc ids (which differ from Pinot doc 
ids), subclasses supply the
+/// per-leaf iterator that maps Lucene doc ids to Pinot doc ids before testing 
membership in the bitmap
+/// (via a doc-id translator, doc values, etc.). This class owns the 
constant-score weight/scorer
+/// scaffolding, identity-based equality, and cache opt-out, so 
filter-correctness fixes apply to every
+/// implementation at once.
+///
+/// Instances are single-use per search and must never be cached by Lucene 
([Weight#isCacheable] returns
+/// false), since the accepted docs depend on the bitmap instance.
+///
+/// **Bitmap ownership.** The bitmap is retained by reference, not copied. 
[ImmutableRoaringBitmap] only
+/// promises that *this* type exposes no mutators -- a caller may pass a 
`MutableRoaringBitmap`, which is a
+/// subtype -- so the caller must not modify it once it has been handed over. 
Mutating it during a search
+/// changes which documents are accepted midway through traversal and yields 
results matching neither the old
+/// nor the new set. Callers that cannot promise that must pass a detached 
copy.
+///
+/// Given that, instances are safe to share across the threads of a single 
search: the bitmap is only read,
+/// and each leaf gets its own iterator.
+public abstract class BaseFilterQuery extends Query {
+  protected final ImmutableRoaringBitmap _docIds;
+
+  protected BaseFilterQuery(ImmutableRoaringBitmap docIds) {
+    _docIds = docIds;
+  }
+
+  /// Returns an iterator over the Lucene doc ids of the given leaf whose 
corresponding Pinot doc ids are
+  /// in the bitmap, in increasing Lucene doc id order; or null when the leaf 
cannot match any document.
+  @Nullable
+  protected abstract DocIdSetIterator createLeafIterator(LeafReaderContext 
context)
+      throws IOException;
+
+  @Override
+  public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, 
float boost) {
+    return new ConstantScoreWeight(this, boost) {
+      @Override
+      @Nullable
+      public Scorer scorer(LeafReaderContext context)
+          throws IOException {
+        DocIdSetIterator iterator = createLeafIterator(context);
+        if (iterator == null) {
+          return null;
+        }
+        float constScore = score();
+        return new Scorer(this) {
+          @Override
+          public DocIdSetIterator iterator() {
+            return iterator;
+          }
+
+          @Override
+          public float getMaxScore(int upTo) {
+            return constScore;
+          }
+
+          @Override
+          public float score() {
+            return constScore;
+          }
+
+          @Override
+          public int docID() {
+            return iterator.docID();
+          }
+        };
+      }
+
+      @Override
+      public boolean isCacheable(LeafReaderContext ctx) {
+        return false;
+      }
+    };
+  }
+
+  @Override
+  public String toString(String field) {
+    return getClass().getSimpleName() + "(cardinality=" + 
_docIds.getCardinality() + ")";
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+    // Identity semantics: the accepted docs depend on the bitmap instance, 
and these queries are
+    // single-use per search (never cached), so structural equality is neither 
needed nor meaningful
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+    BaseFilterQuery that = (BaseFilterQuery) other;
+    return _docIds == that._docIds && equalsDocIdSource(that);
+  }
+
+  /// Whether `other` resolves Pinot doc ids the same way this query does. 
Subclasses that read doc ids through
+  /// a collaborator must compare it here: two queries over one bitmap that 
resolve doc ids differently accept
+  /// different documents, and Lucene requires unequal queries in that case.
+  ///
+  /// Called only from [#equals] after the concrete classes have been 
confirmed identical, so `other` may be
+  /// cast to the subclass type without an `instanceof` check.
+  protected boolean equalsDocIdSource(BaseFilterQuery other) {
+    return true;
+  }
+
+  @Override
+  public int hashCode() {
+    return getClass().hashCode() * 31 + System.identityHashCode(_docIds);
+  }
+
+  @Override
+  public void visit(QueryVisitor visitor) {
+    visitor.visitLeaf(this);
+  }
+}
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/HnswVectorIndexReader.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/HnswVectorIndexReader.java
index f60d6a13a4c..53f5744f9bb 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/HnswVectorIndexReader.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/vector/HnswVectorIndexReader.java
@@ -30,16 +30,12 @@ import org.apache.lucene.document.Document;
 import org.apache.lucene.index.DirectoryReader;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.LeafReaderContext;
-import org.apache.lucene.search.ConstantScoreWeight;
 import org.apache.lucene.search.DocIdSetIterator;
 import org.apache.lucene.search.IndexSearcher;
 import org.apache.lucene.search.KnnFloatVectorQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
-import org.apache.lucene.search.ScoreMode;
-import org.apache.lucene.search.Scorer;
 import org.apache.lucene.search.TopDocs;
-import org.apache.lucene.search.Weight;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import 
org.apache.pinot.segment.local.segment.creator.impl.vector.HnswVectorIndexCreator;
@@ -200,7 +196,7 @@ public class HnswVectorIndexReader implements 
FilterAwareVectorIndexReader, EfSe
   @Override
   public ImmutableRoaringBitmap getDocIds(float[] searchQuery, int topK, 
ImmutableRoaringBitmap preFilterBitmap) {
     try {
-      Query filterQuery = new RoaringBitmapFilterQuery(preFilterBitmap, 
_docIdTranslator, _indexReader.numDocs());
+      Query filterQuery = new RoaringBitmapFilterQuery(preFilterBitmap, 
_docIdTranslator);
       return translateTopDocs(search(searchQuery, topK, filterQuery));
     } catch (RuntimeException e) {
       throw e;
@@ -372,81 +368,23 @@ public class HnswVectorIndexReader implements 
FilterAwareVectorIndexReader, EfSe
   /// Because Lucene uses its own internal doc IDs (which differ from Pinot 
doc IDs),
   /// this query translates Lucene doc IDs to Pinot doc IDs using the 
[DocIdTranslator]
   /// before checking membership in the bitmap.
-  static class RoaringBitmapFilterQuery extends Query {
-    private final ImmutableRoaringBitmap _bitmap;
+  static class RoaringBitmapFilterQuery extends BaseFilterQuery {
     private final DocIdTranslator _docIdTranslator;
-    private final int _maxDoc;
-
-    RoaringBitmapFilterQuery(ImmutableRoaringBitmap bitmap, DocIdTranslator 
docIdTranslator, int maxDoc) {
-      _bitmap = bitmap;
-      _docIdTranslator = docIdTranslator;
-      _maxDoc = maxDoc;
-    }
-
-    @Override
-    public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, 
float boost) {
-      return new ConstantScoreWeight(this, boost) {
-        @Override
-        public Scorer scorer(LeafReaderContext context) {
-          int docBase = context.docBase;
-          int maxDocInLeaf = context.reader().maxDoc();
-          DocIdSetIterator iterator = new BitmapDocIdSetIterator(docBase, 
maxDocInLeaf);
-          float constScore = score();
-          return new Scorer(this) {
-            @Override
-            public DocIdSetIterator iterator() {
-              return iterator;
-            }
-
-            @Override
-            public float getMaxScore(int upTo) {
-              return constScore;
-            }
-
-            @Override
-            public float score() {
-              return constScore;
-            }
-
-            @Override
-            public int docID() {
-              return iterator.docID();
-            }
-          };
-        }
-
-        @Override
-        public boolean isCacheable(LeafReaderContext ctx) {
-          return false;
-        }
-      };
-    }
-
-    @Override
-    public String toString(String field) {
-      return "RoaringBitmapFilterQuery(cardinality=" + 
_bitmap.getCardinality() + ")";
-    }
 
     @Override
-    public boolean equals(Object other) {
-      if (this == other) {
-        return true;
-      }
-      if (!(other instanceof RoaringBitmapFilterQuery)) {
-        return false;
-      }
-      RoaringBitmapFilterQuery that = (RoaringBitmapFilterQuery) other;
-      return _bitmap == that._bitmap && _docIdTranslator == 
that._docIdTranslator;
+    protected boolean equalsDocIdSource(BaseFilterQuery other) {
+      // Same bitmap but a different translator accepts different documents.
+      return _docIdTranslator == ((RoaringBitmapFilterQuery) 
other)._docIdTranslator;
     }
 
-    @Override
-    public int hashCode() {
-      return System.identityHashCode(_bitmap) * 31 + 
System.identityHashCode(_docIdTranslator);
+    RoaringBitmapFilterQuery(ImmutableRoaringBitmap bitmap, DocIdTranslator 
docIdTranslator) {
+      super(bitmap);
+      _docIdTranslator = docIdTranslator;
     }
 
     @Override
-    public void visit(org.apache.lucene.search.QueryVisitor visitor) {
-      visitor.visitLeaf(this);
+    protected DocIdSetIterator createLeafIterator(LeafReaderContext context) {
+      return new BitmapDocIdSetIterator(context.docBase, 
context.reader().maxDoc());
     }
 
     /// Iterates over Lucene doc IDs whose corresponding Pinot doc IDs are in 
the bitmap.
@@ -470,7 +408,7 @@ public class HnswVectorIndexReader implements 
FilterAwareVectorIndexReader, EfSe
         _doc++;
         while (_doc < _maxDocInLeaf) {
           int pinotDocId = _docIdTranslator.getPinotDocId(_docBase + _doc);
-          if (_bitmap.contains(pinotDocId)) {
+          if (_docIds.contains(pinotDocId)) {
             return _doc;
           }
           _doc++;
@@ -484,7 +422,7 @@ public class HnswVectorIndexReader implements 
FilterAwareVectorIndexReader, EfSe
         _doc = target;
         while (_doc < _maxDocInLeaf) {
           int pinotDocId = _docIdTranslator.getPinotDocId(_docBase + _doc);
-          if (_bitmap.contains(pinotDocId)) {
+          if (_docIds.contains(pinotDocId)) {
             return _doc;
           }
           _doc++;
@@ -495,7 +433,7 @@ public class HnswVectorIndexReader implements 
FilterAwareVectorIndexReader, EfSe
 
       @Override
       public long cost() {
-        return _bitmap.getLongCardinality();
+        return _docIds.getLongCardinality();
       }
     }
   }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
index 16b18b8509d..f077be3aff0 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/vector/MutableVectorIndexTest.java
@@ -18,10 +18,25 @@
  */
 package org.apache.pinot.segment.local.realtime.impl.vector;
 
+import java.io.IOException;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.Phaser;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import 
org.apache.pinot.segment.local.realtime.impl.invertedindex.RealtimeLuceneTextIndexSearcherPool;
 import org.apache.pinot.segment.spi.index.creator.VectorIndexConfig;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
 import org.testng.Assert;
 import org.testng.annotations.BeforeClass;
 import org.testng.annotations.Test;
@@ -30,10 +45,11 @@ import org.testng.annotations.Test;
 public class MutableVectorIndexTest {
   private static final String COLUMN_NAME = "embedding";
   private static final String OTHER_COLUMN_NAME = "otherEmbedding";
+  private static final int SEARCHER_POOL_SIZE = 8;
 
   @BeforeClass
   public void setUpSearcherPool() {
-    RealtimeLuceneTextIndexSearcherPool.init(1);
+    RealtimeLuceneTextIndexSearcherPool.init(SEARCHER_POOL_SIZE);
   }
 
   @Test
@@ -80,7 +96,271 @@ public class MutableVectorIndexTest {
       Assert.assertEquals(debugInfo.get("effectiveEfSearch"), 6);
       Assert.assertEquals(debugInfo.get("effectiveHnswUseRelativeDistance"), 
Boolean.FALSE);
       Assert.assertEquals(debugInfo.get("effectiveHnswUseBoundedQueue"), 
Boolean.FALSE);
-      Assert.assertEquals(debugInfo.get("supportsPreFilter"), Boolean.FALSE);
+      Assert.assertEquals(debugInfo.get("supportsPreFilter"), Boolean.TRUE);
+      Assert.assertTrue(index.supportsPreFilter(),
+          "The reader must advertise filtered search: that is what makes the 
planner choose it over an exact scan");
+    } finally {
+      index.close();
+    }
+  }
+
+  // -----------------------------------------------------------------------
+  // Filtered search (upsert doc-ids snapshot enforcement)
+  // -----------------------------------------------------------------------
+
+  /// 2-D corpus with distinct distances from the query vector {1, 0}:
+  /// docs 0 and 1 are nearest (the "upsert-obsoleted" rows), docs 2 and 3 are 
the valid rows.
+  private static MutableVectorIndex create2DIndex(long commitDocs, int 
docIdOffset) {
+    Map<String, String> properties = new HashMap<>();
+    properties.put("commitDocs", String.valueOf(commitDocs));
+    properties.put("vectorIndexType", "HNSW");
+    properties.put("vectorDimension", "2");
+    VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 2, 1,
+        VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+    MutableVectorIndex index =
+        new MutableVectorIndex("mutableVectorIndexFilterTest_" + 
System.nanoTime(), COLUMN_NAME, config);
+    addVector(index, new float[]{1.0F, 0.0F}, docIdOffset);
+    addVector(index, new float[]{0.99F, 0.01F}, docIdOffset + 1);
+    addVector(index, new float[]{0.0F, 1.0F}, docIdOffset + 2);
+    addVector(index, new float[]{0.0F, -1.0F}, docIdOffset + 3);
+    return index;
+  }
+
+  @Test
+  public void testFilteredSearchExcludesNearestDisallowedDocs() {
+    // commitDocs=4 commits on the 4th add, so the unfiltered committed-view 
sanity check below sees all rows
+    MutableVectorIndex index = create2DIndex(4, 0);
+    try {
+      // Sanity: unfiltered top-2 returns the physically nearest ("obsolete") 
docs 0 and 1
+      ImmutableRoaringBitmap unfiltered = index.getDocIds(new float[]{1.0F, 
0.0F}, 2);
+      Assert.assertEquals(unfiltered, ImmutableRoaringBitmap.bitmapOf(0, 1));
+
+      // Filtered top-2 restricted to docs 2 and 3 must return exactly those 
docs. A post-intersection
+      // implementation would return empty here (the unfiltered top-2 has no 
overlap with the allowed set),
+      // so this assertion genuinely discriminates filtered candidate 
generation.
+      ImmutableRoaringBitmap filtered =
+          index.getDocIds(new float[]{1.0F, 0.0F}, 2, 
ImmutableRoaringBitmap.bitmapOf(2, 3));
+      Assert.assertEquals(filtered, ImmutableRoaringBitmap.bitmapOf(2, 3),
+          "Filtered search must return the allowed docs, not the nearest 
disallowed ones");
+    } finally {
+      index.close();
+    }
+  }
+
+  @Test(timeOut = 60_000)
+  public void testConcurrentFilteredSearchWithLiveWriter()
+      throws Exception {
+    // Single writer, concurrent reader: each phase publishes one new allowed 
row, then makes the reader search for
+    // it while the writer continues adding disallowed rows. This proves both 
NRT visibility and filtered-search
+    // correctness while ingestion and commits are active.
+    Map<String, String> properties = new HashMap<>();
+    properties.put("commitDocs", "7");
+    properties.put("vectorIndexType", "HNSW");
+    properties.put("vectorDimension", "2");
+    VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 2, 1,
+        VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+    MutableVectorIndex index =
+        new MutableVectorIndex("mutableVectorIndexConcurrentTest_" + 
System.nanoTime(), COLUMN_NAME, config);
+    int numPhases = 8;
+    int docsPerPhase = 25;
+    Phaser phaser = new Phaser(2);
+    CountDownLatch[] queryEnteredFilter = new CountDownLatch[numPhases];
+    CountDownLatch[] concurrentWritesFinished = new CountDownLatch[numPhases];
+    for (int phase = 0; phase < numPhases; phase++) {
+      queryEnteredFilter[phase] = new CountDownLatch(1);
+      concurrentWritesFinished[phase] = new CountDownLatch(1);
+    }
+    AtomicReference<Throwable> failure = new AtomicReference<>();
+    Thread writer = null;
+    try {
+      addVector(index, new float[]{0.0F, 1.0F}, 0);
+      addVector(index, new float[]{0.0F, -1.0F}, 1);
+      addVector(index, new float[]{1.0F, 0.0F}, 2);
+      addVector(index, new float[]{0.99F, 0.01F}, 3);
+
+      writer = new Thread(() -> {
+        try {
+          for (int phase = 0; phase < numPhases; phase++) {
+            int firstDocId = 4 + phase * docsPerPhase;
+            // Publish the row this phase's filtered search must observe 
before releasing the reader.
+            addVector(index, new float[]{1.0F, 0.0F}, firstDocId);
+            if (!advancePhase(phaser)) {
+              return;
+            }
+            // Wait until the searcher is evaluating the filter, then write 
while that query is in progress.
+            // The test bitmap blocks the search at its first membership check 
until these writes finish.
+            if (!queryEnteredFilter[phase].await(10, TimeUnit.SECONDS)) {
+              throw new TimeoutException("Filtered query did not enter its 
bitmap in phase " + phase);
+            }
+            for (int docId = firstDocId + 1; docId < firstDocId + 
docsPerPhase; docId++) {
+              addVector(index, new float[]{-1.0F, 0.0F}, docId);
+            }
+            concurrentWritesFinished[phase].countDown();
+            if (!advancePhase(phaser)) {
+              return;
+            }
+          }
+        } catch (Throwable t) {
+          failure.compareAndSet(null, t);
+          for (CountDownLatch writesFinished : concurrentWritesFinished) {
+            writesFinished.countDown();
+          }
+          phaser.forceTermination();
+        }
+      }, "mutable-vector-index-test-writer");
+      writer.start();
+
+      for (int phase = 0; phase < numPhases; phase++) {
+        Assert.assertTrue(advancePhase(phaser), "Writer terminated before 
publishing phase " + phase);
+        Assert.assertNull(failure.get(), "Writer thread failed: " + 
failure.get());
+        int allowedDocId = 4 + phase * docsPerPhase;
+        ImmutableRoaringBitmap allowed = new 
ConcurrentWriteCoordinatingBitmap(allowedDocId,
+            queryEnteredFilter[phase], concurrentWritesFinished[phase]);
+        ImmutableRoaringBitmap filtered = index.getDocIds(new float[]{1.0F, 
0.0F}, 1, allowed);
+        Assert.assertEquals(filtered, allowed,
+            "Filtered search must see the newly added allowed row while the 
writer remains active");
+        Assert.assertTrue(advancePhase(phaser), "Writer terminated while 
completing phase " + phase);
+      }
+
+      writer.join();
+      Assert.assertNull(failure.get(), "Writer thread failed: " + 
failure.get());
+    } finally {
+      phaser.forceTermination();
+      if (writer != null) {
+        writer.interrupt();
+        writer.join(TimeUnit.SECONDS.toMillis(10));
+        Assert.assertFalse(writer.isAlive(), "Writer thread did not terminate 
during test cleanup");
+      }
+      index.close();
+    }
+  }
+
+  @Test
+  public void testFilteredSearchSeesUncommittedDocs() {
+    // commitDocs is far larger than the number of added docs and 
commitIntervalMs defaults to 10s, so no
+    // commit has happened: NRT visibility is what makes the filtered search 
see the rows at all
+    MutableVectorIndex index = create2DIndex(1_000_000, 0);
+    try {
+      ImmutableRoaringBitmap filtered =
+          index.getDocIds(new float[]{1.0F, 0.0F}, 4, 
ImmutableRoaringBitmap.bitmapOf(0, 1, 2, 3));
+      Assert.assertEquals(filtered.toArray(), new int[]{0, 1, 2, 3},
+          "Filtered search must see uncommitted rows through the NRT reader, 
translated back to their Pinot "
+              + "doc ids");
+    } finally {
+      index.close();
+    }
+  }
+
+  @Test
+  public void testFilteredSearchTranslatesSuppliedPinotDocIds() {
+    // Pinot doc ids offset by 100: results must be the SUPPLIED doc ids, 
proving no reliance on
+    // ScoreDoc.doc == Pinot docId
+    MutableVectorIndex index = create2DIndex(1000, 100);
+    try {
+      ImmutableRoaringBitmap filtered =
+          index.getDocIds(new float[]{1.0F, 0.0F}, 2, 
ImmutableRoaringBitmap.bitmapOf(102, 103));
+      Assert.assertEquals(filtered, ImmutableRoaringBitmap.bitmapOf(102, 103));
+    } finally {
+      index.close();
+    }
+  }
+
+  @Test(timeOut = 60_000)
+  public void testFilteredSearchWithEmptyBitmapReturnsEmpty()
+      throws Exception {
+    MutableVectorIndex index = create2DIndex(1000, 0);
+    RealtimeLuceneTextIndexSearcherPool searcherPool = 
RealtimeLuceneTextIndexSearcherPool.getInstance();
+    searcherPool.resize(1);
+    ExecutorService searcherExecutor = searcherPool.getExecutorService();
+    CountDownLatch blockerStarted = new CountDownLatch(1);
+    CountDownLatch releaseBlocker = new CountDownLatch(1);
+    ExecutorService caller = Executors.newSingleThreadExecutor();
+    Future<?> blocker = searcherExecutor.submit(() -> {
+      blockerStarted.countDown();
+      if (!releaseBlocker.await(30, TimeUnit.SECONDS)) {
+        throw new TimeoutException("Timed out waiting to release blocked 
search worker");
+      }
+      return null;
+    });
+    try {
+      Assert.assertTrue(blockerStarted.await(10, TimeUnit.SECONDS), "Failed to 
occupy the search worker");
+
+      Future<ImmutableRoaringBitmap> emptySearch = caller.submit(
+          () -> index.getDocIds(new float[]{1.0F, 0.0F}, 2, 
ImmutableRoaringBitmap.bitmapOf()));
+      ImmutableRoaringBitmap filtered = emptySearch.get(10, TimeUnit.SECONDS);
+      Assert.assertEquals(filtered.getCardinality(), 0);
+      Assert.assertEquals(index.getSearcherRefreshCount(), 0L,
+          "An empty filter must return before search submission or NRT 
refresh");
+    } finally {
+      releaseBlocker.countDown();
+      try {
+        blocker.get(10, TimeUnit.SECONDS);
+      } catch (Exception e) {
+        Assert.fail("Failed to clean up blocked search worker", e);
+      } finally {
+        searcherPool.resize(SEARCHER_POOL_SIZE);
+      }
+      caller.shutdownNow();
+      try {
+        Assert.assertTrue(caller.awaitTermination(10, TimeUnit.SECONDS), 
"Empty-filter caller did not terminate");
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        Assert.fail("Interrupted while cleaning up empty-filter caller", e);
+      }
+      index.close();
+    }
+  }
+
+  @Test(timeOut = 60_000)
+  public void testConcurrentFilteredSearchesCoalesceRefresh()
+      throws Exception {
+    int numCallers = SEARCHER_POOL_SIZE;
+    ExecutorService callers = Executors.newFixedThreadPool(numCallers);
+    CoordinatedRefreshMutableVectorIndex index = 
createCoordinatedIndexWithoutCommits(1);
+    try (index) {
+      float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+      Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(0)).toArray(), 
new int[]{0});
+      long initialRefreshCount = index.getSearcherRefreshCount();
+      addVector(index, query, 10);
+      index.coordinateNextRefresh();
+
+      CyclicBarrier startTogether = new CyclicBarrier(numCallers);
+      List<Future<ImmutableRoaringBitmap>> searches = new 
ArrayList<>(numCallers);
+      for (int i = 0; i < numCallers; i++) {
+        searches.add(callers.submit(() -> {
+          startTogether.await(10, TimeUnit.SECONDS);
+          return index.getDocIds(query, 1, bitmapOf(10));
+        }));
+      }
+      index.awaitWinningRefresher();
+      index.awaitWaitingCallers();
+      index.releaseWinningRefresher();
+      for (Future<ImmutableRoaringBitmap> search : searches) {
+        Assert.assertEquals(search.get(10, TimeUnit.SECONDS).toArray(), new 
int[]{10});
+      }
+      Assert.assertEquals(index.getSearcherRefreshCount(), initialRefreshCount 
+ 1,
+          "Concurrent readers targeting one writer generation must share 
exactly one refresh");
+      // The refresh count alone does not prove coalescing: a caller arriving 
after publication would skip refresh
+      // and still leave the count at one. Require actual participation in the 
production waiter path.
+      Assert.assertTrue(index.getObservedRefreshWaiters() > 0,
+          "Expected concurrent callers to coalesce onto the in-flight refresh, 
but none entered the waiter path");
+    } finally {
+      index.releaseWinningRefresher();
+      callers.shutdownNow();
+      Assert.assertTrue(callers.awaitTermination(10, TimeUnit.SECONDS), 
"Concurrent callers did not terminate");
+    }
+  }
+
+  @Test
+  public void testUnfilteredSearchTranslatesSuppliedPinotDocIds() {
+    // commitDocs=4 commits on the 4th add, so the committed-view unfiltered 
path sees all rows; with doc ids
+    // offset by 100 the results must be the supplied ids
+    MutableVectorIndex index = create2DIndex(4, 100);
+    try {
+      int[] matches = index.getDocIds(new float[]{1.0F, 0.0F}, 2).toArray();
+      Assert.assertEquals(matches.length, 2);
+      Assert.assertEquals(matches[0], 100);
+      Assert.assertEquals(matches[1], 101);
     } finally {
       index.close();
     }
@@ -100,6 +380,223 @@ public class MutableVectorIndexTest {
     }
   }
 
+  /// A consuming segment spends its first seconds with nothing committed: the 
constructor commits an empty
+  /// index and the next commit only fires on commitDocs/commitIntervalMs. The 
unfiltered path reads that
+  /// committed view, which has no leaves and therefore no doc values, so it 
must return no results rather than
+  /// fail while trying to translate them. The filtered path reads a 
near-real-time view and does see the rows,
+  /// which is the difference that makes it usable for enforcing the query's 
visible-document set.
+  @Test
+  public void testSearchOnIndexWithNothingCommitted() {
+    try (MutableVectorIndex index = createIndexWithoutCommits()) {
+      float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+      Assert.assertEquals(index.getDocIds(query, 2).toArray(), new int[0],
+          "The committed view has no leaves yet, so an unfiltered search finds 
nothing");
+      Assert.assertEquals(index.getDocIds(query, 2, bitmapOf(0, 1)).toArray(), 
new int[]{0, 1},
+          "The near-real-time view must see rows that have not been committed 
yet");
+    }
+  }
+
+  @Test
+  public void testSearchOnEmptyIndexReturnsEmpty() {
+    try (MutableVectorIndex index =
+        new MutableVectorIndex("mutableVectorIndexTest_" + System.nanoTime(), 
COLUMN_NAME, createConfig())) {
+      float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+      Assert.assertEquals(index.getDocIds(query, 2).toArray(), new int[0]);
+      Assert.assertEquals(index.getDocIds(query, 2, bitmapOf(0, 1)).toArray(), 
new int[0]);
+    }
+  }
+
+  /// Lucene short-circuits to an exact scan when the accepted-doc count is at 
most topK, so a filtered test
+  /// with |allowed| <= topK never walks the HNSW graph. This one keeps the 
allowed set larger than topK so the
+  /// graph traversal itself is exercised against acceptDocs.
+  @Test
+  public void testFilteredApproximateSearchWithMoreAllowedDocsThanTopK() {
+    try (MutableVectorIndex index = createIndex()) {
+      for (int docId = 4; docId < 40; docId++) {
+        addVector(index, new float[]{1.0F, docId, 0.0F, 0.0F, 0.0F}, docId);
+      }
+      MutableRoaringBitmap allowed = new MutableRoaringBitmap();
+      for (int docId = 10; docId < 40; docId++) {
+        allowed.add(docId);
+      }
+
+      int[] matches = index.getDocIds(new float[]{1.0F, 10.0F, 0.0F, 0.0F, 
0.0F}, 3, allowed).toArray();
+      Assert.assertEquals(matches.length, 3);
+      for (int match : matches) {
+        Assert.assertTrue(allowed.contains(match), "Filtered search returned a 
disallowed doc: " + match);
+      }
+    }
+  }
+
+  private static MutableRoaringBitmap bitmapOf(int... docIds) {
+    MutableRoaringBitmap bitmap = new MutableRoaringBitmap();
+    bitmap.add(docIds);
+    return bitmap;
+  }
+
+  private static boolean advancePhase(Phaser phaser)
+      throws InterruptedException, TimeoutException {
+    int phase = phaser.arrive();
+    return phaser.awaitAdvanceInterruptibly(phase, 10, TimeUnit.SECONDS) >= 0;
+  }
+
+  /// Holds the winning refresher after it publishes 
`_searcherRefreshInProgress`, so at least one concurrent search
+  /// is proven to enter the production waiter path before the refresh is 
allowed to finish.
+  private static class CoordinatedRefreshMutableVectorIndex extends 
MutableVectorIndex {
+    private final CountDownLatch _winningRefresherEntered = new 
CountDownLatch(1);
+    private final CountDownLatch _releaseWinningRefresher = new 
CountDownLatch(1);
+    private final AtomicInteger _observedRefreshWaiters = new AtomicInteger();
+    private final CountDownLatch _waitingCallers;
+    private volatile boolean _coordinateRefresh;
+
+    CoordinatedRefreshMutableVectorIndex(String segmentName, VectorIndexConfig 
config, int expectedWaitingCallers) {
+      super(segmentName, COLUMN_NAME, config);
+      _waitingCallers = new CountDownLatch(expectedWaitingCallers);
+    }
+
+    void coordinateNextRefresh() {
+      _coordinateRefresh = true;
+    }
+
+    void awaitWinningRefresher()
+        throws InterruptedException, TimeoutException {
+      if (!_winningRefresherEntered.await(10, TimeUnit.SECONDS)) {
+        throw new TimeoutException("No filtered-search caller became the 
winning refresher");
+      }
+    }
+
+    void awaitWaitingCallers()
+        throws InterruptedException, TimeoutException {
+      if (!_waitingCallers.await(10, TimeUnit.SECONDS)) {
+        throw new TimeoutException("No concurrent caller entered the refresh 
waiter path");
+      }
+    }
+
+    void releaseWinningRefresher() {
+      _releaseWinningRefresher.countDown();
+    }
+
+    @Override
+    void beforeSearcherRefresh()
+        throws IOException {
+      if (!_coordinateRefresh) {
+        return;
+      }
+      _winningRefresherEntered.countDown();
+      try {
+        if (!_releaseWinningRefresher.await(10, TimeUnit.SECONDS)) {
+          throw new IOException("Timed out waiting to release the winning 
refresher");
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new IOException("Interrupted while holding the winning 
refresher", e);
+      } finally {
+        _coordinateRefresh = false;
+      }
+    }
+
+    @Override
+    void onSearcherRefreshWait() {
+      _observedRefreshWaiters.incrementAndGet();
+      if (_coordinateRefresh) {
+        _waitingCallers.countDown();
+      }
+    }
+
+    int getObservedRefreshWaiters() {
+      return _observedRefreshWaiters.get();
+    }
+  }
+
+  /// Blocks the first filter membership check until the writer has added its 
concurrent rows. This puts the
+  /// synchronization point inside Lucene's actual filtered search, rather 
than merely racing two caller threads.
+  private static class ConcurrentWriteCoordinatingBitmap extends 
MutableRoaringBitmap {
+    private final CountDownLatch _queryEnteredFilter;
+    private final CountDownLatch _concurrentWritesFinished;
+
+    ConcurrentWriteCoordinatingBitmap(int allowedDocId, CountDownLatch 
queryEnteredFilter,
+        CountDownLatch concurrentWritesFinished) {
+      add(allowedDocId);
+      _queryEnteredFilter = queryEnteredFilter;
+      _concurrentWritesFinished = concurrentWritesFinished;
+    }
+
+    @Override
+    public boolean contains(int docId) {
+      _queryEnteredFilter.countDown();
+      try {
+        if (!_concurrentWritesFinished.await(10, TimeUnit.SECONDS)) {
+          throw new AssertionError("Concurrent writer did not finish while the 
filtered query was active");
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new AssertionError("Interrupted while coordinating the filtered 
query and writer", e);
+      }
+      return super.contains(docId);
+    }
+  }
+
+  /// Config whose commit thresholds are high enough that no commit fires 
during the test.
+  private static MutableVectorIndex createIndexWithoutCommits() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put("commitDocs", String.valueOf(Integer.MAX_VALUE));
+    properties.put("commitIntervalMs", 
String.valueOf(TimeUnit.DAYS.toMillis(1)));
+    properties.put("vectorIndexType", "HNSW");
+    properties.put("vectorDimension", "5");
+    MutableVectorIndex index = new 
MutableVectorIndex("mutableVectorIndexTest_" + System.nanoTime(), COLUMN_NAME,
+        new VectorIndexConfig(false, "HNSW", 5, 1, 
VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties));
+    addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 0);
+    addVector(index, new float[]{0.0F, 1.0F, 0.0F, 0.0F, 0.0F}, 1);
+    return index;
+  }
+
+  private static CoordinatedRefreshMutableVectorIndex 
createCoordinatedIndexWithoutCommits(int expectedWaiters) {
+    Map<String, String> properties = new HashMap<>();
+    properties.put("commitDocs", String.valueOf(Integer.MAX_VALUE));
+    properties.put("commitIntervalMs", 
String.valueOf(TimeUnit.DAYS.toMillis(1)));
+    properties.put("vectorIndexType", "HNSW");
+    properties.put("vectorDimension", "5");
+    VectorIndexConfig config = new VectorIndexConfig(false, "HNSW", 5, 1,
+        VectorIndexConfig.VectorDistanceFunction.EUCLIDEAN, properties);
+    CoordinatedRefreshMutableVectorIndex index = new 
CoordinatedRefreshMutableVectorIndex(
+        "mutableVectorIndexCoalescingTest_" + System.nanoTime(), config, 
expectedWaiters);
+    addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 0);
+    addVector(index, new float[]{0.0F, 1.0F, 0.0F, 0.0F, 0.0F}, 1);
+    return index;
+  }
+
+  /// MutableIndex#add allows rows in arbitrary doc-id order, so freshness 
cannot be tracked by a doc-id
+  /// watermark: a row added later with a lower doc id would be judged already 
visible and the refresh skipped,
+  /// silently omitting it. Adds a high doc id, searches (refreshing through 
it), then adds a lower one.
+  @Test
+  public void testFilteredSearchSeesOutOfOrderUncommittedDoc() {
+    try (MutableVectorIndex index = createIndexWithoutCommits()) {
+      float[] query = {1.0F, 0.0F, 0.0F, 0.0F, 0.0F};
+      addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 10);
+      Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(10)).toArray(), 
new int[]{10},
+          "The searcher must be refreshed through the row just added");
+
+      addVector(index, new float[]{1.0F, 0.0F, 0.0F, 0.0F, 0.0F}, 5);
+      Assert.assertEquals(index.getDocIds(query, 1, bitmapOf(5)).toArray(), 
new int[]{5},
+          "A row added out of doc-id order must still be visible to a later 
filtered search");
+    }
+  }
+
+  /// Hit translation walks doc values forward, so hits must be visited in 
Lucene doc-id order. Querying nearest
+  /// to the last-added row makes score order the reverse of doc-id order, 
which exercises that sort.
+  @Test
+  public void testFilteredSearchWithHitsInDescendingScoreOrder() {
+    try (MutableVectorIndex index = createIndexWithoutCommits()) {
+      addVector(index, new float[]{0.0F, 0.0F, 0.0F, 0.0F, 1.0F}, 20);
+      addVector(index, new float[]{0.0F, 0.0F, 0.0F, 1.0F, 0.0F}, 21);
+      addVector(index, new float[]{0.0F, 0.0F, 1.0F, 0.0F, 0.0F}, 22);
+      int[] matches = index.getDocIds(new float[]{0.0F, 0.0F, 1.0F, 0.0F, 
0.0F}, 3,
+          bitmapOf(20, 21, 22)).toArray();
+      Assert.assertEquals(matches, new int[]{20, 21, 22},
+          "Every allowed doc must translate to its supplied Pinot doc id 
regardless of score order");
+    }
+  }
+
   private static MutableVectorIndex createIndex() {
     return createIndex("mutableVectorIndexTest_" + System.nanoTime(), 
COLUMN_NAME);
   }
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseFilterQueryTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseFilterQueryTest.java
new file mode 100644
index 00000000000..b535bdcdf46
--- /dev/null
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/segment/index/readers/vector/BaseFilterQueryTest.java
@@ -0,0 +1,130 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.segment.local.segment.index.readers.vector;
+
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.QueryVisitor;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Scorer;
+import org.apache.lucene.search.Weight;
+import org.roaringbitmap.buffer.ImmutableRoaringBitmap;
+import org.roaringbitmap.buffer.MutableRoaringBitmap;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+
+
+/// Tests the shared Lucene constant-score scaffolding and identity contract 
for Pinot doc-id bitmap filters.
+public class BaseFilterQueryTest {
+
+  @Test
+  public void testConstantScoreScaffoldingAndCacheOptOut()
+      throws Exception {
+    TestQuery query = new TestQuery(MutableRoaringBitmap.bitmapOf(2, 4), new 
Object(),
+        DocIdSetIterator.range(1, 3));
+    Weight weight = query.createWeight(null, ScoreMode.COMPLETE, 2.5F);
+
+    assertFalse(weight.isCacheable(null));
+    Scorer scorer = weight.scorer(null);
+    assertNotNull(scorer);
+    assertSame(scorer.iterator(), query._iterator);
+    assertEquals(scorer.docID(), -1);
+    assertEquals(scorer.iterator().nextDoc(), 1);
+    assertEquals(scorer.docID(), 1);
+    assertEquals(scorer.score(), 2.5F);
+    assertEquals(scorer.getMaxScore(DocIdSetIterator.NO_MORE_DOCS), 2.5F);
+  }
+
+  @Test
+  public void testLeafWithoutMatchesReturnsNoScorer()
+      throws Exception {
+    TestQuery query = new TestQuery(MutableRoaringBitmap.bitmapOf(1), new 
Object(), null);
+    assertNull(query.createWeight(null, ScoreMode.COMPLETE_NO_SCORES, 
1.0F).scorer(null));
+  }
+
+  @Test
+  public void testIdentityEqualityIncludesBitmapClassAndDocIdSource() {
+    MutableRoaringBitmap docIds = MutableRoaringBitmap.bitmapOf(1, 3);
+    Object source = new Object();
+    TestQuery query = new TestQuery(docIds, source, DocIdSetIterator.empty());
+    TestQuery equivalent = new TestQuery(docIds, source, 
DocIdSetIterator.empty());
+
+    assertEquals(query, query);
+    assertEquals(query, equivalent);
+    assertEquals(query.hashCode(), equivalent.hashCode());
+    assertNotEquals(query, new TestQuery(docIds, new Object(), 
DocIdSetIterator.empty()));
+    assertNotEquals(query, new TestQuery(docIds.clone(), source, 
DocIdSetIterator.empty()));
+    assertNotEquals(query, new OtherQuery(docIds));
+    assertNotEquals(query, null);
+    assertEquals(query.toString("ignored"), "TestQuery(cardinality=2)");
+  }
+
+  @Test
+  public void testVisitDelegatesToLeafVisitor() {
+    TestQuery query = new TestQuery(MutableRoaringBitmap.bitmapOf(1), new 
Object(), DocIdSetIterator.empty());
+    AtomicReference<Query> visited = new AtomicReference<>();
+    query.visit(new QueryVisitor() {
+      @Override
+      public void visitLeaf(Query leafQuery) {
+        visited.set(leafQuery);
+      }
+    });
+    assertSame(visited.get(), query);
+  }
+
+  private static class TestQuery extends BaseFilterQuery {
+    private final Object _source;
+    private final DocIdSetIterator _iterator;
+
+    TestQuery(ImmutableRoaringBitmap docIds, Object source, DocIdSetIterator 
iterator) {
+      super(docIds);
+      _source = source;
+      _iterator = iterator;
+    }
+
+    @Override
+    protected DocIdSetIterator createLeafIterator(LeafReaderContext context) {
+      return _iterator;
+    }
+
+    @Override
+    protected boolean equalsDocIdSource(BaseFilterQuery other) {
+      return _source == ((TestQuery) other)._source;
+    }
+  }
+
+  private static class OtherQuery extends BaseFilterQuery {
+    OtherQuery(ImmutableRoaringBitmap docIds) {
+      super(docIds);
+    }
+
+    @Override
+    protected DocIdSetIterator createLeafIterator(LeafReaderContext context) {
+      return DocIdSetIterator.empty();
+    }
+  }
+}


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

Reply via email to