Skip to content
Merged
318 changes: 318 additions & 0 deletions integ-test/src/test/java/org/opensearch/sql/sql/VectorSearchIT.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

package org.opensearch.sql.sql;

import static org.hamcrest.Matchers.containsString;

import java.io.IOException;
import org.junit.Test;
import org.opensearch.client.ResponseException;
import org.opensearch.sql.legacy.SQLIntegTestCase;
import org.opensearch.sql.legacy.TestsConstants;

/**
* Integration tests for vectorSearch SQL table function. These tests verify DSL push-down shape via
* _explain and validation error paths. They do NOT require the k-NN plugin since _explain only
* parses and plans the query without executing it against a knn index.
*/
public class VectorSearchIT extends SQLIntegTestCase {

@Override
protected void init() throws Exception {
// _explain needs the index to exist for field resolution.
loadIndex(Index.ACCOUNT);
}

private static final String TEST_INDEX = TestsConstants.TEST_INDEX_ACCOUNT;

// ── DSL shape verification via _explain ───────────────────────────────

@Test
public void testExplainTopKProducesKnnQuery() throws IOException {
String explain =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move the Explain test to a separate ExplainIT suite so that we can view the results in a yaml or json file directly
ref: CalciteExplainIT

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, done. The _explain cases now live in VectorSearchExplainIT, and VectorSearchIT keeps only behavior/validation tests. I kept the current SQL explain assertions for now; happy to follow up with file-backed expected outputs if we want to standardize that too.

explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0, 3.0]', option='k=5') AS v "
+ "LIMIT 5");

// WrapperQueryBuilder wraps the knn JSON — verify the wrapper is present
// and track_scores is enabled for score preservation.
assertTrue("Explain should contain wrapper query:\n" + explain, explain.contains("wrapper"));
assertTrue(
"Explain should contain track_scores:\n" + explain, explain.contains("track_scores"));
}

@Test
public void testExplainRadialMaxDistanceProducesKnnQuery() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='max_distance=10.5') AS v "
+ "LIMIT 100");

assertTrue("Explain should contain wrapper query:\n" + explain, explain.contains("wrapper"));
}

@Test
public void testExplainRadialMinScoreProducesKnnQuery() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='min_score=0.8') AS v "
+ "LIMIT 100");

assertTrue("Explain should contain wrapper query:\n" + explain, explain.contains("wrapper"));
}

@Test
public void testExplainPostFilterProducesBoolQuery() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0, 3.0]', option='k=10') AS v "
+ "WHERE v.state = 'TX' "
+ "LIMIT 10");

assertTrue("Explain should contain bool query:\n" + explain, explain.contains("bool"));
assertTrue(
"Explain should contain must clause (knn in scoring context):\n" + explain,
explain.contains("must"));
assertTrue(
"Explain should contain filter clause (WHERE in non-scoring context):\n" + explain,
explain.contains("filter"));
}

Comment thread
ahkcs marked this conversation as resolved.
Outdated
// ── Validation error paths ────────────────────────────────────────────

@Test
public void testMutualExclusivityRejectsKAndMaxDistance() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[1.0]', option='k=5,max_distance=10') AS v"));

assertThat(ex.getMessage(), containsString("Only one of"));
}

@Test
public void testMutualExclusivityRejectsKAndMinScore() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[1.0]', option='k=5,min_score=0.5') AS v"));

assertThat(ex.getMessage(), containsString("Only one of"));
}

@Test
public void testKTooLargeRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[1.0]', option='k=10001') AS v"));

assertThat(ex.getMessage(), containsString("k must be between 1 and 10000"));
}

@Test
public void testKZeroRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[1.0]', option='k=0') AS v"));

assertThat(ex.getMessage(), containsString("k must be between 1 and 10000"));
}

@Test
public void testUnknownOptionKeyRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[1.0]', option='k=5,method.ef_search=100') AS v"));

assertThat(ex.getMessage(), containsString("Unknown option key"));
}

@Test
public void testEmptyVectorRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[]', option='k=5') AS v"));

assertThat(ex.getMessage(), containsString("must not be empty"));
}

@Test
public void testInvalidFieldNameRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', "
+ "field='field\\\"injection', vector='[1.0]', option='k=5') AS v"));

assertThat(ex.getMessage(), containsString("Invalid field name"));
}

@Test
public void testMissingRequiredOptionRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='t', field='f', "
+ "vector='[1.0]', option='') AS v"));

assertThat(ex.getMessage(), containsString("Missing required option"));
}

// ── Sort restriction validation ─────────────────────────────────────────

@Test
public void testOrderByScoreDescExplainSucceeds() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='k=5') AS v "
+ "ORDER BY v._score DESC "
+ "LIMIT 5");

assertTrue(
"Explain should succeed with ORDER BY _score DESC:\n" + explain,
explain.contains("wrapper"));
}

@Test
public void testOrderByNonScoreFieldRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='k=5') AS v "
+ "ORDER BY v.firstname ASC "
+ "LIMIT 5"));

assertThat(ex.getMessage(), containsString("unsupported sort expression"));
}

@Test
public void testOrderByScoreAscRejects() throws IOException {
ResponseException ex =
expectThrows(
ResponseException.class,
() ->
executeQuery(
"SELECT v._id FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='k=5') AS v "
+ "ORDER BY v._score ASC "
+ "LIMIT 5"));

assertThat(ex.getMessage(), containsString("_score ASC is not supported"));
}

// ── Compound predicate and radial + WHERE ───────────────────────────────

@Test
public void testExplainCompoundPredicateProducesBoolQuery() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0, 3.0]', option='k=10') AS v "
+ "WHERE v.state = 'TX' AND v.age > 30 "
+ "LIMIT 10");

assertTrue("Explain should contain bool query:\n" + explain, explain.contains("bool"));
assertTrue(
"Explain should contain must clause (knn in scoring context):\n" + explain,
explain.contains("must"));
assertTrue(
"Explain should contain filter clause (compound WHERE in non-scoring context):\n" + explain,
explain.contains("filter"));
}

@Test
public void testExplainRadialWithWhereProducesBoolQuery() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='max_distance=10.5') AS v "
+ "WHERE v.state = 'TX' "
+ "LIMIT 100");

assertTrue("Explain should contain bool query:\n" + explain, explain.contains("bool"));
assertTrue(
"Explain should contain must clause (knn in scoring context):\n" + explain,
explain.contains("must"));
assertTrue(
"Explain should contain filter clause (WHERE in non-scoring context):\n" + explain,
explain.contains("filter"));
}

// ── LIMIT validation ───────────────────────────────────────────────────

@Test
public void testExplainLimitWithinKSucceeds() throws IOException {
String explain =
explainQuery(
"SELECT v._id, v._score "
+ "FROM vectorSearch(table='"
+ TEST_INDEX
+ "', field='embedding', "
+ "vector='[1.0, 2.0]', option='k=10') AS v "
+ "LIMIT 5");

assertTrue("Explain should succeed with LIMIT <= k:\n" + explain, explain.contains("wrapper"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ public TableScanBuilder createScanBuilder() {

// Use VectorSearchQueryBuilder to keep knn in must (scoring) context.
// WHERE filters will be placed in filter (non-scoring) context.
var queryBuilder = new VectorSearchQueryBuilder(requestBuilder, buildKnnQuery());
var queryBuilder = new VectorSearchQueryBuilder(requestBuilder, buildKnnQuery(), options);
requestBuilder.pushDownTrackedScore(true);

// Top-k mode: default size to k so queries without LIMIT return k results
// instead of falling into the generic large-scan path.
// LIMIT pushdown will further reduce this if present.
// Default size policy: LIMIT pushdown will further reduce if present.
if (options.containsKey("k")) {
// Top-k mode: default size to k so queries without LIMIT return k results.
requestBuilder.pushDownLimitToRequestTotal(Integer.parseInt(options.get("k")), 0);
} else {
// Radial mode (max_distance/min_score): cap at maxResultWindow.
// Without an explicit cap, radial queries could return unbounded results.
requestBuilder.pushDownLimitToRequestTotal(getMaxResultWindow(), 0);
}

Function<OpenSearchRequestBuilder, OpenSearchIndexScan> createScanOperator =
Expand All @@ -68,6 +71,11 @@ public TableScanBuilder createScanBuilder() {
}

private QueryBuilder buildKnnQuery() {
return new WrapperQueryBuilder(buildKnnQueryJson());
}

// Package-private for testing
String buildKnnQueryJson() {
StringBuilder vectorJson = new StringBuilder("[");
for (int i = 0; i < vector.length; i++) {
if (i > 0) vectorJson.append(",");
Expand All @@ -88,11 +96,9 @@ private QueryBuilder buildKnnQuery() {
}
}

String knnQueryJson =
String.format(
"{\"knn\":{\"%s\":{\"vector\":%s%s}}}",
field, vectorJson.toString(), optionsJson.toString());
return new WrapperQueryBuilder(knnQueryJson);
return String.format(
"{\"knn\":{\"%s\":{\"vector\":%s%s}}}",
field, vectorJson.toString(), optionsJson.toString());
}

private static boolean isNumeric(String str) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,20 @@ private void validateOptions(Map<String, String> options) {
throw new ExpressionEvaluationException(
"Missing required option: one of k, max_distance, or min_score");
}
// Mutual exclusivity: exactly one search mode allowed
int modeCount = (hasK ? 1 : 0) + (hasMaxDistance ? 1 : 0) + (hasMinScore ? 1 : 0);
if (modeCount > 1) {
throw new ExpressionEvaluationException(
"Only one of k, max_distance, or min_score may be specified");
}
// Parse and canonicalize numeric values — closes JSON injection via option values
if (hasK) {
parseIntOption(options, "k");
int k = Integer.parseInt(options.get("k"));
if (k < 1 || k > 10000) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throw new ExpressionEvaluationException(
String.format("k must be between 1 and 10000, got %d", k));
}
}
if (hasMaxDistance) {
parseDoubleOption(options, "max_distance");
Expand Down
Loading
Loading