Copilot commented on code in PR #18114:
URL: https://github.com/apache/pinot/pull/18114#discussion_r3042636237


##########
pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/VectorTest.java:
##########
@@ -208,6 +211,85 @@ public void testVectorSimilarity(boolean 
useMultiStageQueryEngine)
     }
   }
 
+  /**
+   * Tests filtered ANN: VECTOR_SIMILARITY combined with a metadata filter.
+   * All returned rows must match the filter predicate and be ordered by 
distance.
+   */
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testFilteredAnn(boolean useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    int topK = 5;
+    String queryVector = "ARRAY[1.1" + StringUtils.repeat(", 1.1", 
VECTOR_DIM_SIZE - 1) + "]";
+    String targetCategory = "cat_0";
+
+    String filteredQuery = 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_1, queryVector, CATEGORY, getTableName(),
+        VECTOR_1, queryVector, topK * 10, CATEGORY, targetCategory, topK);
+
+    JsonNode result = postQuery(filteredQuery);
+    JsonNode rows = result.get("resultTable").get("rows");
+    assertTrue(rows.size() > 0, "Filtered ANN should return results");
+
+    double prevDist = -1;
+    for (int i = 0; i < rows.size(); i++) {
+      double dist = rows.get(i).get(0).asDouble();
+      String cat = rows.get(i).get(1).asText();
+      assertEquals(cat, targetCategory, "All results must match the filter");
+      assertTrue(dist >= prevDist, "Results must be ordered by distance");
+      prevDist = dist;
+    }
+  }
+
+  /**
+   * Tests that filtered ANN returns fewer or equal results compared to 
unfiltered ANN.
+   */
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testFilteredAnnReducesResults(boolean useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    int topK = 20;
+    String queryVector = "ARRAY[1.1" + StringUtils.repeat(", 1.1", 
VECTOR_DIM_SIZE - 1) + "]";
+
+    String unfilteredQuery = String.format(
+        "SELECT count(*) FROM %s WHERE vectorSimilarity(%s, %s, %d)",
+        getTableName(), VECTOR_1, queryVector, topK);
+    String filteredQuery = String.format(
+        "SELECT count(*) FROM %s WHERE vectorSimilarity(%s, %s, %d) AND %s = 
'cat_0'",
+        getTableName(), VECTOR_1, queryVector, topK, CATEGORY);
+
+    long unfilteredCount = 
postQuery(unfilteredQuery).get("resultTable").get("rows").get(0).get(0).asLong();
+    long filteredCount = 
postQuery(filteredQuery).get("resultTable").get("rows").get(0).get(0).asLong();
+
+    assertTrue(filteredCount <= unfilteredCount,
+        "Filtered (" + filteredCount + ") should be <= unfiltered (" + 
unfilteredCount + ")");
+    assertTrue(filteredCount > 0, "Filtered ANN should return at least 1 
result");
+  }
+
+  /**
+   * Tests that EXPLAIN output includes execution mode for vector queries.
+   */
+  @Test(dataProvider = "useBothQueryEngines")
+  public void testExplainShowsExecutionMode(boolean useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    String queryVector = "ARRAY[1.1" + StringUtils.repeat(", 1.1", 
VECTOR_DIM_SIZE - 1) + "]";
+
+    String explainQuery = String.format(
+        "set explainAskingServers=true; EXPLAIN PLAN FOR "
+            + "SELECT cosineDistance(%s, %s) AS dist FROM %s "
+            + "WHERE vectorSimilarity(%s, %s, %d) ORDER BY dist ASC LIMIT %d",
+        VECTOR_1, queryVector, getTableName(), VECTOR_1, queryVector, 5, 5);
+
+    JsonNode result = postQuery(explainQuery);
+    String explain = result.get("resultTable").toString();
+    assertTrue(explain.contains("executionMode") || 
explain.contains("VECTOR_SIMILARITY"),
+        "EXPLAIN should contain execution mode info: " + explain);

Review Comment:
   This EXPLAIN assertion is too weak to validate the new behavior: 
`explain.contains("executionMode") || explain.contains("VECTOR_SIMILARITY")` 
will pass even if execution mode is never emitted (because existing plans 
already include `VECTOR_SIMILARITY`).
   
   To actually guard the Phase 3 change, assert that `executionMode` is present 
(and ideally that it matches one of the expected mode names) in both query 
engines' explain output.
   ```suggestion
       assertTrue(explain.contains("executionMode"),
           "EXPLAIN should contain executionMode: " + explain);
       
assertTrue(explain.matches("(?s).*\\\"executionMode\\\"\\s*:\\s*\\\"[A-Z_]+\\\".*"),
           "EXPLAIN should contain an executionMode value: " + explain);
   ```



##########
pinot-segment-spi/src/test/java/org/apache/pinot/segment/spi/index/creator/VectorExecutionModeTest.java:
##########
@@ -0,0 +1,95 @@
+/**
+ * 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.spi.index.creator;
+
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertTrue;
+
+
+/**
+ * Tests for the {@link VectorExecutionMode} enum.
+ */
+public class VectorExecutionModeTest {
+
+  @Test
+  public void testAllModesHaveDescription() {
+    for (VectorExecutionMode mode : VectorExecutionMode.values()) {
+      assertNotNull(mode.getDescription());
+      assertFalse(mode.getDescription().isEmpty(), "Description must not be 
empty for " + mode);
+    }
+  }
+
+  @Test
+  public void testIsApproximate() {
+    assertTrue(VectorExecutionMode.ANN_TOP_K.isApproximate());
+    assertTrue(VectorExecutionMode.ANN_TOP_K_WITH_RERANK.isApproximate());
+    assertTrue(VectorExecutionMode.ANN_THEN_FILTER.isApproximate());
+    
assertTrue(VectorExecutionMode.ANN_THEN_FILTER_THEN_RERANK.isApproximate());
+    assertTrue(VectorExecutionMode.FILTER_THEN_ANN.isApproximate());
+    assertTrue(VectorExecutionMode.ANN_THRESHOLD_SCAN.isApproximate());
+    assertTrue(VectorExecutionMode.ANN_THRESHOLD_THEN_FILTER.isApproximate());
+    assertFalse(VectorExecutionMode.EXACT_SCAN.isApproximate());
+  }
+
+  @Test
+  public void testHasMetadataFilter() {
+    assertFalse(VectorExecutionMode.ANN_TOP_K.hasMetadataFilter());
+    assertFalse(VectorExecutionMode.ANN_TOP_K_WITH_RERANK.hasMetadataFilter());
+    assertTrue(VectorExecutionMode.ANN_THEN_FILTER.hasMetadataFilter());
+    
assertTrue(VectorExecutionMode.ANN_THEN_FILTER_THEN_RERANK.hasMetadataFilter());
+    assertTrue(VectorExecutionMode.FILTER_THEN_ANN.hasMetadataFilter());
+    assertFalse(VectorExecutionMode.ANN_THRESHOLD_SCAN.hasMetadataFilter());
+    
assertTrue(VectorExecutionMode.ANN_THRESHOLD_THEN_FILTER.hasMetadataFilter());
+    assertFalse(VectorExecutionMode.EXACT_SCAN.hasMetadataFilter());
+  }
+
+  @Test
+  public void testHasExactRefinement() {
+    assertFalse(VectorExecutionMode.ANN_TOP_K.hasExactRefinement());
+    assertTrue(VectorExecutionMode.ANN_TOP_K_WITH_RERANK.hasExactRefinement());
+    assertFalse(VectorExecutionMode.ANN_THEN_FILTER.hasExactRefinement());
+    
assertTrue(VectorExecutionMode.ANN_THEN_FILTER_THEN_RERANK.hasExactRefinement());
+    assertFalse(VectorExecutionMode.FILTER_THEN_ANN.hasExactRefinement());
+    assertTrue(VectorExecutionMode.ANN_THRESHOLD_SCAN.hasExactRefinement());
+    
assertTrue(VectorExecutionMode.ANN_THRESHOLD_THEN_FILTER.hasExactRefinement());
+    assertTrue(VectorExecutionMode.EXACT_SCAN.hasExactRefinement());
+  }
+
+  @Test
+  public void testIsThresholdMode() {
+    assertFalse(VectorExecutionMode.ANN_TOP_K.isThresholdMode());
+    assertFalse(VectorExecutionMode.ANN_TOP_K_WITH_RERANK.isThresholdMode());
+    assertFalse(VectorExecutionMode.ANN_THEN_FILTER.isThresholdMode());
+    
assertFalse(VectorExecutionMode.ANN_THEN_FILTER_THEN_RERANK.isThresholdMode());
+    assertFalse(VectorExecutionMode.FILTER_THEN_ANN.isThresholdMode());
+    assertTrue(VectorExecutionMode.ANN_THRESHOLD_SCAN.isThresholdMode());
+    
assertTrue(VectorExecutionMode.ANN_THRESHOLD_THEN_FILTER.isThresholdMode());
+    assertFalse(VectorExecutionMode.EXACT_SCAN.isThresholdMode());
+  }
+
+  @Test
+  public void testModeCount() {
+    assertEquals(VectorExecutionMode.values().length, 8,
+        "Phase 3 defines exactly 8 execution modes");

Review Comment:
   `testModeCount()` hard-codes that there must be exactly 8 execution modes. 
This makes the test brittle and will fail any future extension of 
`VectorExecutionMode` even if it is fully backward compatible.
   
   Consider asserting required properties per-mode (which you already do) 
and/or asserting presence of specific expected modes rather than the total 
count.



##########
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorSimilarityFilterOperator.java:
##########
@@ -120,9 +143,25 @@ public VectorSimilarityFilterOperator(VectorIndexReader 
vectorIndexReader, Vecto
     _distanceFunction = 
VectorDistanceUtils.resolveDistanceFunction(vectorIndexConfig);
     _requestedExactRerank = searchParams.isExactRerank(_backendType);
     _effectiveExactRerank = _requestedExactRerank && forwardIndexReader != 
null;
-    _effectiveSearchCount = _effectiveExactRerank
-        ? searchParams.getEffectiveMaxCandidates(predicate.getTopK(), numDocs)
-        : predicate.getTopK();
+    _hasMetadataFilter = hasMetadataFilter;
+    _hasThresholdPredicate = searchParams.hasDistanceThreshold();
+    _distanceThreshold = searchParams.getDistanceThreshold();
+    // When metadata filter is present, over-fetch ANN candidates to 
compensate for filter loss.
+    // Default over-fetch factor is 2x topK for filtered queries without 
explicit maxCandidates.
+    // For threshold queries, use a larger candidate pool since we need 
distance refinement.
+    int baseSearchCount;
+    if (_hasThresholdPredicate && !searchParams.isMaxCandidatesExplicit()) {
+      // Threshold queries need a larger candidate pool for exact distance 
refinement.
+      // Use topK * 10 by default, similar to rerank mode.
+      baseSearchCount = Math.min(predicate.getTopK() * 10, numDocs);
+    } else if (_effectiveExactRerank) {
+      baseSearchCount = 
searchParams.getEffectiveMaxCandidates(predicate.getTopK(), numDocs);
+    } else if (hasMetadataFilter && !searchParams.isMaxCandidatesExplicit()) {
+      baseSearchCount = Math.min(predicate.getTopK() * 
FILTERED_OVERFETCH_FACTOR, numDocs);
+    } else {
+      baseSearchCount = predicate.getTopK();
+    }

Review Comment:
   For threshold/radius queries, `vectorMaxCandidates` is effectively ignored 
when it is explicitly set: when `_hasThresholdPredicate` is true and 
`isMaxCandidatesExplicit()` is true, the logic falls through to 
`baseSearchCount = predicate.getTopK()`. This prevents users from increasing 
the candidate pool for threshold refinement and can significantly reduce recall 
for threshold searches.
   
   Consider computing the ANN candidate count for threshold mode via 
`searchParams.getEffectiveMaxCandidates(topK, numDocs)` (honoring explicit 
`vectorMaxCandidates`, otherwise defaulting to `topK*10`), and then applying 
any additional filtered over-fetching on top if needed.



##########
pinot-core/src/main/java/org/apache/pinot/core/operator/filter/VectorQueryExecutionContext.java:
##########
@@ -0,0 +1,234 @@
+/**
+ * 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.core.operator.filter;
+
+import javax.annotation.Nullable;
+import org.apache.pinot.segment.spi.index.creator.VectorBackendCapabilities;
+import org.apache.pinot.segment.spi.index.creator.VectorBackendType;
+import org.apache.pinot.segment.spi.index.creator.VectorExecutionMode;
+
+
+/**
+ * Captures the runtime execution plan for a vector query within a single 
segment.
+ *
+ * <p>This context is populated by the query planner (in {@code 
FilterPlanNode}) and carried
+ * through to the filter operator so that execution decisions are explicit and 
reportable.
+ * It is also surfaced in explain/debug output.</p>
+ *
+ * <p>The context is immutable once created. Use the {@link Builder} to 
construct instances.</p>
+ */
+public final class VectorQueryExecutionContext {
+
+  private final VectorExecutionMode _executionMode;
+  private final VectorBackendType _backendType;
+  private final VectorBackendCapabilities _capabilities;
+  private final boolean _exactRerank;
+  private final boolean _hasMetadataFilter;
+  private final boolean _hasThresholdPredicate;
+  private final int _topK;
+  private final int _candidateBudget;
+  private final float _distanceThreshold;
+  @Nullable
+  private final String _fallbackReason;
+
+  private VectorQueryExecutionContext(Builder builder) {
+    _executionMode = builder._executionMode;
+    _backendType = builder._backendType;
+    _capabilities = builder._capabilities;
+    _exactRerank = builder._exactRerank;
+    _hasMetadataFilter = builder._hasMetadataFilter;
+    _hasThresholdPredicate = builder._hasThresholdPredicate;
+    _topK = builder._topK;
+    _candidateBudget = builder._candidateBudget;
+    _distanceThreshold = builder._distanceThreshold;
+    _fallbackReason = builder._fallbackReason;
+  }
+
+  public VectorExecutionMode getExecutionMode() {
+    return _executionMode;
+  }
+
+  public VectorBackendType getBackendType() {
+    return _backendType;
+  }
+
+  public VectorBackendCapabilities getCapabilities() {
+    return _capabilities;
+  }
+
+  public boolean isExactRerank() {
+    return _exactRerank;
+  }
+
+  public boolean hasMetadataFilter() {
+    return _hasMetadataFilter;
+  }
+
+  public boolean hasThresholdPredicate() {
+    return _hasThresholdPredicate;
+  }
+
+  public int getTopK() {
+    return _topK;
+  }
+
+  public int getCandidateBudget() {
+    return _candidateBudget;
+  }
+
+  public float getDistanceThreshold() {
+    return _distanceThreshold;
+  }
+
+  @Nullable
+  public String getFallbackReason() {
+    return _fallbackReason;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder("VectorQueryExecutionContext{");
+    sb.append("mode=").append(_executionMode);
+    sb.append(", backend=").append(_backendType);
+    sb.append(", topK=").append(_topK);
+    if (_hasMetadataFilter) {
+      sb.append(", hasFilter=true");
+    }
+    if (_hasThresholdPredicate) {
+      sb.append(", threshold=").append(_distanceThreshold);
+    }
+    if (_exactRerank) {
+      sb.append(", rerank=true, candidateBudget=").append(_candidateBudget);
+    }
+    if (_fallbackReason != null) {
+      sb.append(", fallbackReason=").append(_fallbackReason);
+    }
+    sb.append('}');
+    return sb.toString();
+  }
+
+  /**
+   * Selects the execution mode for a vector query based on backend 
capabilities and query shape.
+   *
+   * <p>This is the centralized decision point. The rules are conservative and 
explicit:</p>
+   * <ol>
+   *   <li>No vector index -> EXACT_SCAN</li>
+   *   <li>Threshold query + filter -> ANN_THRESHOLD_THEN_FILTER</li>
+   *   <li>Threshold query (no filter) -> ANN_THRESHOLD_SCAN</li>
+   *   <li>Filter + rerank -> ANN_THEN_FILTER_THEN_RERANK</li>
+   *   <li>Filter (no rerank) -> ANN_THEN_FILTER</li>
+   *   <li>Rerank (no filter) -> ANN_TOP_K_WITH_RERANK</li>
+   *   <li>Plain top-K -> ANN_TOP_K</li>
+   * </ol>
+   */
+  public static VectorExecutionMode selectExecutionMode(boolean hasVectorIndex,
+      boolean hasMetadataFilter, boolean hasThresholdPredicate, boolean 
exactRerank,
+      @Nullable VectorBackendCapabilities capabilities) {

Review Comment:
   `selectExecutionMode(...)` takes a `capabilities` argument and its Javadoc 
says mode selection is based on backend capabilities, but the current 
implementation never uses `capabilities` (and therefore can never select 
capability-dependent modes like `FILTER_THEN_ANN`). This makes the API 
misleading and leaves dead/unreachable execution modes.
   
   Either incorporate capabilities into the decision rules (e.g., only choose 
modes the backend declares support for) or remove the unused parameter and 
update the Javadoc/mode set accordingly.



##########
pinot-core/src/main/java/org/apache/pinot/core/plan/FilterPlanNode.java:
##########
@@ -223,7 +223,13 @@ private BaseFilterOperator 
constructPhysicalOperator(FilterContext filter, int n
         childFilters = filter.getChildren();
         childFilterOperators = new ArrayList<>(childFilters.size());
         for (FilterContext childFilter : childFilters) {
-          BaseFilterOperator childFilterOperator = 
constructPhysicalOperator(childFilter, numDocs);
+          BaseFilterOperator childFilterOperator;
+          if (isVectorSimilarityFilter(childFilter) && childFilters.size() > 
1) {
+            // Pass filtered context so vector operator reports correct 
execution mode
+            childFilterOperator = constructFilteredVectorOperator(childFilter, 
numDocs);
+          } else {
+            childFilterOperator = constructPhysicalOperator(childFilter, 
numDocs);
+          }

Review Comment:
   `hasMetadataFilter` is set for a VECTOR_SIMILARITY child whenever the parent 
is an AND with more than one child, even if the other children are also 
VECTOR_SIMILARITY predicates. In that case there is no metadata filter, but the 
vector operator will still over-fetch (and report a filtered execution mode), 
which is both misleading and can change query behavior.
   
   Consider setting the flag only when the AND contains at least one non-vector 
predicate sibling (i.e., a real metadata filter) rather than relying on 
`childFilters.size() > 1`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to