Skip to content

Commit 85e2083

Browse files
xiangfu0claude
andauthored
Add vector search Phase 4: filter-aware ANN, SQL radius, HNSW efSearch, quantizers, IVF_ON_DISK, adaptive planner (#18119)
* Add vector search Phase 4: filter-aware ANN, SQL radius, HNSW efSearch, quantizers, IVF_ON_DISK, adaptive planner This completes the vector search roadmap with seven feature areas: 1. Filter-aware ANN (FILTER_THEN_ANN): Pre-filter bitmap passed to HNSW/IVF backends for improved recall on selective filters 2. SQL surface: VECTOR_SIMILARITY_RADIUS for threshold/radius search 3. HNSW runtime tuning: vectorEfSearch query option via EfSearchAware interface 4. Generic quantizer framework: VectorQuantizerType (FLAT/SQ8/SQ4/PQ), ScalarQuantizer with train/encode/decode/serialize 5. IVF_ON_DISK: Disk-backed IVF via FileChannel reads (no 2GB limit) 6. Adaptive planner: VectorSearchStrategy with selectivity-aware mode selection 7. Metrics: VectorSearchMetrics singleton for observability All existing configs, query options, and SQL are backward-compatible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix threshold+exactRerank ordering bug, remove dead code, improve observability comments - Fix CRITICAL: when both vectorExactRerank=true and vectorDistanceThreshold are set, the threshold-only branch fired first and returned early, bypassing exact rerank entirely. Now exact rerank takes priority and applies the threshold during the rerank step. - Remove dead code: second threshold block (lines 382-390) that was unreachable because the first threshold branch always returned before it was reached. - Remove duplicate setNumDocsMatchingAfterFilter call in record() (was called twice). - Add LOGGER.warn when vectorEfSearch is set, making it visible that the option currently only affects EXPLAIN output and does not change Lucene graph traversal. - Update VectorSimilarityFilterOperator Javadoc to list all 4 backends (was HNSW+IVF_FLAT only). - Add comment in FilterPlanNode explaining why backendType/searchParams are null at the pre-filter wiring stage. - Add comment in VectorIndexType noting IVF_ON_DISK reuses the IVF_FLAT file extension. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2766336 commit 85e2083

42 files changed

Lines changed: 5235 additions & 75 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pinot-common/src/main/java/org/apache/pinot/common/request/context/RequestContextUtils.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.apache.pinot.common.request.context.predicate.RegexpLikePredicate;
3939
import org.apache.pinot.common.request.context.predicate.TextMatchPredicate;
4040
import org.apache.pinot.common.request.context.predicate.VectorSimilarityPredicate;
41+
import org.apache.pinot.common.request.context.predicate.VectorSimilarityRadiusPredicate;
4142
import org.apache.pinot.common.utils.RegexpPatternConverterUtils;
4243
import org.apache.pinot.common.utils.request.RequestUtils;
4344
import org.apache.pinot.segment.spi.AggregationFunctionType;
@@ -268,6 +269,15 @@ private static FilterContext getFilterInner(Function thriftFunction) {
268269
topK = operands.get(2).getLiteral().getIntValue();
269270
}
270271
return FilterContext.forPredicate(new VectorSimilarityPredicate(lhs, vectorValue, topK));
272+
case VECTOR_SIMILARITY_RADIUS:
273+
ExpressionContext radiusLhs = getExpression(operands.get(0));
274+
float[] radiusVectorValue = getVectorValue(operands.get(1));
275+
float threshold = VectorSimilarityRadiusPredicate.DEFAULT_THRESHOLD;
276+
if (operands.size() == 3) {
277+
threshold = getFloatValue(operands.get(2));
278+
}
279+
return FilterContext.forPredicate(
280+
new VectorSimilarityRadiusPredicate(radiusLhs, radiusVectorValue, threshold));
271281
case IS_NULL:
272282
return FilterContext.forPredicate(new IsNullPredicate(getExpression(operands.get(0))));
273283
case IS_NOT_NULL:
@@ -449,6 +459,13 @@ private static FilterContext getFilterInner(FunctionContext filterFunction) {
449459
}
450460
return FilterContext.forPredicate(
451461
new VectorSimilarityPredicate(operands.get(0), getVectorValue(operands.get(1)), topK));
462+
case VECTOR_SIMILARITY_RADIUS:
463+
float radiusThreshold = VectorSimilarityRadiusPredicate.DEFAULT_THRESHOLD;
464+
if (operands.size() == 3) {
465+
radiusThreshold = Float.parseFloat(operands.get(2).getLiteral().getValue().toString());
466+
}
467+
return FilterContext.forPredicate(
468+
new VectorSimilarityRadiusPredicate(operands.get(0), getVectorValue(operands.get(1)), radiusThreshold));
452469
case IS_NULL:
453470
return FilterContext.forPredicate(new IsNullPredicate(operands.get(0)));
454471
case IS_NOT_NULL:

pinot-common/src/main/java/org/apache/pinot/common/request/context/predicate/Predicate.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ enum Type {
3838
JSON_MATCH,
3939
IS_NULL,
4040
IS_NOT_NULL(true),
41-
VECTOR_SIMILARITY;
41+
VECTOR_SIMILARITY,
42+
VECTOR_SIMILARITY_RADIUS;
4243

4344
private final boolean _exclusive;
4445

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.pinot.common.request.context.predicate;
20+
21+
import java.util.Arrays;
22+
import java.util.Objects;
23+
import org.apache.pinot.common.request.context.ExpressionContext;
24+
25+
26+
/**
27+
* Predicate for vector similarity radius/threshold search.
28+
* Returns all documents whose vector distance from the query vector is within the specified threshold.
29+
*
30+
* <p>Unlike {@link VectorSimilarityPredicate} which returns a fixed number of top-K results,
31+
* this predicate returns all documents that satisfy the distance threshold constraint.</p>
32+
*
33+
* <p>An internal safety limit caps the maximum number of candidates retrieved from the ANN index
34+
* before exact distance filtering is applied.</p>
35+
*
36+
* <p>Example SQL:</p>
37+
* <pre>
38+
* WHERE VECTOR_SIMILARITY_RADIUS(embedding, ARRAY[1.0, 2.0, 3.0], 0.5)
39+
* </pre>
40+
*
41+
* <p>NOTE: Currently, we only support vector similarity search on float array columns.</p>
42+
*/
43+
public class VectorSimilarityRadiusPredicate extends BasePredicate {
44+
public static final float DEFAULT_THRESHOLD = 0.5f;
45+
/**
46+
* Default internal candidate limit for index-assisted radius search. This is used as a cap
47+
* when retrieving ANN candidates before exact distance filtering. The effective limit is
48+
* {@code Math.min(DEFAULT_INTERNAL_LIMIT, numDocs)} so it adapts to small segments.
49+
* For large segments, consider increasing this via a query option if results appear truncated.
50+
*/
51+
public static final int DEFAULT_INTERNAL_LIMIT = 100_000;
52+
53+
private final float[] _value;
54+
private final float _threshold;
55+
56+
public VectorSimilarityRadiusPredicate(ExpressionContext lhs, float[] value, float threshold) {
57+
super(lhs);
58+
_value = value;
59+
_threshold = threshold;
60+
}
61+
62+
@Override
63+
public Type getType() {
64+
return Type.VECTOR_SIMILARITY_RADIUS;
65+
}
66+
67+
public float[] getValue() {
68+
return _value;
69+
}
70+
71+
public float getThreshold() {
72+
return _threshold;
73+
}
74+
75+
@Override
76+
public boolean equals(Object o) {
77+
if (this == o) {
78+
return true;
79+
}
80+
if (!(o instanceof VectorSimilarityRadiusPredicate)) {
81+
return false;
82+
}
83+
VectorSimilarityRadiusPredicate that = (VectorSimilarityRadiusPredicate) o;
84+
return Objects.equals(_lhs, that._lhs) && Arrays.equals(_value, that._value)
85+
&& Float.compare(_threshold, that._threshold) == 0;
86+
}
87+
88+
@Override
89+
public int hashCode() {
90+
return Objects.hash(_lhs, Arrays.hashCode(_value), _threshold);
91+
}
92+
93+
@Override
94+
public String toString() {
95+
return "vector_similarity_radius(" + _lhs + ",'" + Arrays.toString(_value) + "'," + _threshold + ")";
96+
}
97+
}

pinot-common/src/main/java/org/apache/pinot/common/utils/config/QueryOptionsUtils.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,16 @@ public static Float getVectorDistanceThreshold(Map<String, String> queryOptions)
729729
}
730730
}
731731

732+
/**
733+
* Returns the configured efSearch value for HNSW vector search, or {@code null} if not set.
734+
*/
735+
@Nullable
736+
public static Integer getVectorEfSearch(Map<String, String> queryOptions) {
737+
String efSearch = queryOptions.get(QueryOptionKey.VECTOR_EF_SEARCH);
738+
return checkedParseIntPositive(QueryOptionKey.VECTOR_EF_SEARCH, efSearch);
739+
}
740+
741+
732742
public static int getSortExchangeCopyThreshold(Map<String, String> options, int i) {
733743
String sortExchangeCopyThreshold = options.get(QueryOptionKey.SORT_EXCHANGE_COPY_THRESHOLD);
734744
if (sortExchangeCopyThreshold != null) {

pinot-common/src/main/java/org/apache/pinot/sql/FilterKind.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ public enum FilterKind {
3838
JSON_MATCH,
3939
IS_NULL,
4040
IS_NOT_NULL,
41-
VECTOR_SIMILARITY;
41+
VECTOR_SIMILARITY,
42+
VECTOR_SIMILARITY_RADIUS;
4243

4344
/**
4445
* Helper method that returns true if the enum maps to a Range.

pinot-common/src/main/java/org/apache/pinot/sql/parsers/CalciteSqlParser.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,30 @@ private static void validateFilter(Expression filterExpression) {
299299
+ "the signature is VECTOR_SIMILARITY(float[], float[], int)");
300300
}
301301
}
302+
} else if (operator.equals(FilterKind.VECTOR_SIMILARITY_RADIUS.name())) {
303+
Expression vectorIdentifier = filterExpression.getFunctionCall().getOperands().get(0);
304+
if (!vectorIdentifier.isSetIdentifier()) {
305+
throw new IllegalStateException(
306+
"The first argument of VECTOR_SIMILARITY_RADIUS must be an identifier of float array, "
307+
+ "the signature is VECTOR_SIMILARITY_RADIUS(float[], float[], float).");
308+
}
309+
Expression vectorLiteral = filterExpression.getFunctionCall().getOperands().get(1);
310+
if ((vectorLiteral.isSetFunctionCall() && !vectorLiteral.getFunctionCall().getOperator().equalsIgnoreCase(
311+
"arrayvalueconstructor"))
312+
|| (vectorLiteral.isSetLiteral() && !vectorLiteral.getLiteral().isSetFloatArrayValue()
313+
&& !vectorLiteral.getLiteral().isSetDoubleArrayValue())) {
314+
throw new IllegalStateException(
315+
"The second argument of VECTOR_SIMILARITY_RADIUS must be a float/double array "
316+
+ "literal, the signature is VECTOR_SIMILARITY_RADIUS(float[], float[], float)");
317+
}
318+
if (filterExpression.getFunctionCall().getOperands().size() == 3) {
319+
Expression threshold = filterExpression.getFunctionCall().getOperands().get(2);
320+
if (!threshold.isSetLiteral()) {
321+
throw new IllegalStateException(
322+
"The third argument of VECTOR_SIMILARITY_RADIUS must be a numeric literal, "
323+
+ "the signature is VECTOR_SIMILARITY_RADIUS(float[], float[], float)");
324+
}
325+
}
302326
} else {
303327
List<Expression> operands = filterExpression.getFunctionCall().getOperands();
304328
for (int i = 1; i < operands.size(); i++) {

pinot-common/src/main/java/org/apache/pinot/sql/parsers/rewriter/PredicateComparisonRewriter.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,25 @@ private static Expression updateFunctionExpression(Expression expression) {
148148
}
149149
break;
150150
}
151+
case VECTOR_SIMILARITY_RADIUS: {
152+
Preconditions.checkArgument(operands.size() >= 2 && operands.size() <= 3,
153+
"For %s predicate, the number of operands must be at either 2 or 3, got: %s", filterKind, expression);
154+
if ((operands.get(1).getFunctionCall() != null && !operands.get(1).getFunctionCall().getOperator()
155+
.equalsIgnoreCase("arrayvalueconstructor"))
156+
|| (operands.get(1).getLiteral() != null && !operands.get(1).getLiteral().isSetFloatArrayValue()
157+
&& !operands.get(1).getLiteral().isSetDoubleArrayValue())) {
158+
throw new SqlCompilationException(
159+
String.format("For %s predicate, the second operand must be a float/double array literal, got: %s",
160+
filterKind,
161+
expression));
162+
}
163+
if (operands.size() == 3 && operands.get(2).getLiteral() == null) {
164+
throw new SqlCompilationException(
165+
String.format("For %s predicate, the third operand must be a literal, got: %s", filterKind,
166+
expression));
167+
}
168+
break;
169+
}
151170
default:
152171
int numOperands = operands.size();
153172
for (int i = 1; i < numOperands; i++) {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.pinot.common.request.context.predicate;
20+
21+
import org.apache.pinot.common.request.context.ExpressionContext;
22+
import org.testng.Assert;
23+
import org.testng.annotations.Test;
24+
25+
26+
/**
27+
* Tests for {@link VectorSimilarityRadiusPredicate}.
28+
*/
29+
public class VectorSimilarityRadiusPredicateTest {
30+
31+
@Test
32+
public void testConstructionAndGetters() {
33+
ExpressionContext lhs = ExpressionContext.forIdentifier("embedding");
34+
float[] value = {1.0f, 2.0f, 3.0f};
35+
float threshold = 0.75f;
36+
37+
VectorSimilarityRadiusPredicate predicate = new VectorSimilarityRadiusPredicate(lhs, value, threshold);
38+
39+
Assert.assertEquals(predicate.getType(), Predicate.Type.VECTOR_SIMILARITY_RADIUS);
40+
Assert.assertEquals(predicate.getLhs(), lhs);
41+
Assert.assertEquals(predicate.getValue(), value);
42+
Assert.assertEquals(predicate.getThreshold(), threshold);
43+
}
44+
45+
@Test
46+
public void testDefaultThreshold() {
47+
Assert.assertEquals(VectorSimilarityRadiusPredicate.DEFAULT_THRESHOLD, 0.5f);
48+
}
49+
50+
@Test
51+
public void testDefaultInternalLimit() {
52+
Assert.assertEquals(VectorSimilarityRadiusPredicate.DEFAULT_INTERNAL_LIMIT, 100_000);
53+
}
54+
55+
@Test
56+
public void testEquality() {
57+
ExpressionContext lhs1 = ExpressionContext.forIdentifier("embedding");
58+
ExpressionContext lhs2 = ExpressionContext.forIdentifier("embedding");
59+
float[] value1 = {1.0f, 2.0f};
60+
float[] value2 = {1.0f, 2.0f};
61+
62+
VectorSimilarityRadiusPredicate p1 = new VectorSimilarityRadiusPredicate(lhs1, value1, 0.5f);
63+
VectorSimilarityRadiusPredicate p2 = new VectorSimilarityRadiusPredicate(lhs2, value2, 0.5f);
64+
65+
Assert.assertEquals(p1, p2);
66+
Assert.assertEquals(p1.hashCode(), p2.hashCode());
67+
}
68+
69+
@Test
70+
public void testInequalityDifferentThreshold() {
71+
ExpressionContext lhs = ExpressionContext.forIdentifier("embedding");
72+
float[] value = {1.0f, 2.0f};
73+
74+
VectorSimilarityRadiusPredicate p1 = new VectorSimilarityRadiusPredicate(lhs, value, 0.5f);
75+
VectorSimilarityRadiusPredicate p2 = new VectorSimilarityRadiusPredicate(lhs, value, 1.0f);
76+
77+
Assert.assertNotEquals(p1, p2);
78+
}
79+
80+
@Test
81+
public void testInequalityDifferentVector() {
82+
ExpressionContext lhs = ExpressionContext.forIdentifier("embedding");
83+
float[] value1 = {1.0f, 2.0f};
84+
float[] value2 = {3.0f, 4.0f};
85+
86+
VectorSimilarityRadiusPredicate p1 = new VectorSimilarityRadiusPredicate(lhs, value1, 0.5f);
87+
VectorSimilarityRadiusPredicate p2 = new VectorSimilarityRadiusPredicate(lhs, value2, 0.5f);
88+
89+
Assert.assertNotEquals(p1, p2);
90+
}
91+
92+
@Test
93+
public void testInequalityDifferentColumn() {
94+
ExpressionContext lhs1 = ExpressionContext.forIdentifier("embedding1");
95+
ExpressionContext lhs2 = ExpressionContext.forIdentifier("embedding2");
96+
float[] value = {1.0f, 2.0f};
97+
98+
VectorSimilarityRadiusPredicate p1 = new VectorSimilarityRadiusPredicate(lhs1, value, 0.5f);
99+
VectorSimilarityRadiusPredicate p2 = new VectorSimilarityRadiusPredicate(lhs2, value, 0.5f);
100+
101+
Assert.assertNotEquals(p1, p2);
102+
}
103+
104+
@Test
105+
public void testNotEqualToVectorSimilarityPredicate() {
106+
ExpressionContext lhs = ExpressionContext.forIdentifier("embedding");
107+
float[] value = {1.0f, 2.0f};
108+
109+
VectorSimilarityRadiusPredicate radiusPredicate = new VectorSimilarityRadiusPredicate(lhs, value, 0.5f);
110+
VectorSimilarityPredicate topKPredicate = new VectorSimilarityPredicate(lhs, value, 10);
111+
112+
Assert.assertNotEquals(radiusPredicate, topKPredicate);
113+
}
114+
115+
@Test
116+
public void testToString() {
117+
ExpressionContext lhs = ExpressionContext.forIdentifier("embedding");
118+
float[] value = {1.0f, 2.0f};
119+
120+
VectorSimilarityRadiusPredicate predicate = new VectorSimilarityRadiusPredicate(lhs, value, 0.5f);
121+
String str = predicate.toString();
122+
123+
Assert.assertTrue(str.contains("vector_similarity_radius"));
124+
Assert.assertTrue(str.contains("embedding"));
125+
Assert.assertTrue(str.contains("0.5"));
126+
}
127+
}

0 commit comments

Comments
 (0)