Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Bug Fixes
* [SemanticHighlighter] Fix SemanticHighlighterExtBuilder.toXContent ([#1906](https://github.com/opensearch-project/neural-search/issues/1906)) (query-insights [#651](https://github.com/opensearch-project/query-insights/issues/651))
* [HybridQuery] Block hybrid query when no search pipeline is configured ([#1922](https://github.com/opensearch-project/neural-search/issues/1922))

### Infrastructure

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,31 @@
import org.opensearch.action.search.SearchType;
import org.opensearch.action.support.ActionFilter;
import org.opensearch.action.support.ActionFilterChain;
import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.core.action.ActionResponse;
import org.opensearch.index.IndexSettings;
import org.opensearch.index.query.QueryBuilder;
import org.opensearch.neuralsearch.query.HybridQueryBuilder;
import org.opensearch.neuralsearch.util.HybridQueryUtil;
import org.opensearch.neuralsearch.util.NeuralSearchClusterUtil;
import org.opensearch.search.pipeline.SearchPipelineService;
import org.opensearch.tasks.Task;

import java.util.List;

import lombok.extern.log4j.Log4j2;

/**
* An ActionFilter that automatically disables batched reduction for hybrid queries.
* An ActionFilter that validates hybrid query requests and disables batched reduction for them.
*
* This filter intercepts all search requests and checks if they contain a hybrid query.
* If a hybrid query is detected with search_type=dfs_query_then_fetch, the request is rejected.
* If a hybrid query is detected, it unconditionally sets batchedReduceSize to Integer.MAX_VALUE
* to disable batched reduction, regardless of any user-specified value.
* If a hybrid query is detected but no search pipeline can be resolved for it (neither inline,
* via the search_pipeline request parameter, nor as every target index's default search
* pipeline), the request is rejected, since without a pipeline the normalization processor
* never runs and the response would otherwise be returned in an unnormalized, internal format.
* Otherwise, it unconditionally sets batchedReduceSize to Integer.MAX_VALUE to disable batched
* reduction, regardless of any user-specified value.
*
* This prevents the "topDocs already consumed" error that occurs when:
* 1. Hybrid query is executed
Expand All @@ -40,8 +50,6 @@
* The NormalizationProcessor requires access to all shard results simultaneously
* to perform score normalization and combination.
*
* This filter works transparently without any pipeline or query configuration.
*
*/
@Log4j2
public class HybridQuerySearchRequestFilter implements ActionFilter {
Expand Down Expand Up @@ -82,6 +90,10 @@ public <Request extends org.opensearch.action.ActionRequest, Response extends Ac
listener.onFailure(new IllegalArgumentException(HybridQueryUtil.HYBRID_QUERY_DFS_SEARCH_TYPE_NOT_SUPPORTED_MESSAGE));
return;
}
if (hasResolvableSearchPipeline(searchRequest) == false) {
listener.onFailure(new IllegalArgumentException(HybridQueryUtil.HYBRID_QUERY_REQUIRES_SEARCH_PIPELINE_MESSAGE));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

provided error message is over promising - we are checking for a simple search pipeline presence, not normalization related processors. Either add check for processor or relax the message and drop mentions of processors (which is much easier fix I believe)

return;
}
if (searchRequest.getBatchedReduceSize() != DISABLE_BATCHED_REDUCE) {
log.debug(
String.format(
Expand Down Expand Up @@ -119,4 +131,33 @@ private boolean containsHybridQuery(SearchRequest searchRequest) {
// direct check for HybridQueryBuilder
return query instanceof HybridQueryBuilder;
}

/**
* Check whether a search pipeline can be resolved for this request, either from the request
* itself (inline or via the search_pipeline request parameter) or from the default search
* pipeline configured on every index the request targets.
*
* @param searchRequest the search request to check
* @return true if a non-noop pipeline can be resolved for this request
*/
private boolean hasResolvableSearchPipeline(SearchRequest searchRequest) {

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.

I think we should inspect the pipeline to confirm the normalization and combination processors are configured otherwise a search pipeline without those processors still cannot return the right hybrid query result.

@owaiskazi19 owaiskazi19 Aug 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

+1 here. Please add a validation to check the normalization and combination processors attached and an associated test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this logic diverges from SearchPipelineService. Core method resolvePipeline checks source().searchPipelineSource() first, before the named param.

When Core resolves an inline-body ad-hoc pipeline, request's searchPipelineSource map is not drained — meaning it is still intact when this ActionFilter runs (the filter runs before resolvePipeline). An inline-object body sets searchPipelineSource (SearchSourceBuilder.java) and leaves searchRequest.pipeline() null (RestSearchAction only populates pipeline() from the ?search_pipeline= param or the string form of source().pipeline()). So isConfiguredPipeline(null) is false, no index default exists, and the request is wrongly rejected with a 400, even core would build and run the inline normalization pipeline correctly.

Example of failing request, where index has no default pipeline, no ?search_pipeline= param:

POST /my-index/_search
{
  "search_pipeline": {
    "phase_results_processors": [
      { "normalization-processor": {
          "normalization": { "technique": "min_max" },
          "combination":  { "technique": "arithmetic_mean" } } }
    ]
  },
  "query": { "hybrid": { "queries": [ {"match":{"text":"hello"}}, {"term":{"text":"place"}} ] } }
}

You can fail fast on the inline body before the named/default checks, mirroring core branch.

String requestPipeline = searchRequest.pipeline();
if (isConfiguredPipeline(requestPipeline)) {
return true;
}

List<IndexMetadata> indexMetadataList = NeuralSearchClusterUtil.instance().getIndexMetadataList(searchRequest);
return indexMetadataList.isEmpty() == false && indexMetadataList.stream().allMatch(this::hasDefaultSearchPipeline);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this logic is different in core, and fails in multiple scenarios:

  • it collapses to _noneNO_OP_PIPELINE → no normalization runs → exact unnormalized/internal-format response we're trying to address. Intended behavior is to fail fast
  • multi-index mixed defaults → false rejection (over-strict vs. core). with your change we're skipping indices that have no explicit default rather than treating them as _none.

I suggest you don't allMatch over raw .get() values.
Most robust approach is to delegate to core so the filter cannot diverge.
Alternatively fold per-index defaults with core's exact semantics — use .exists() to skip indices with no default, take the first default, collapse to _none if a later index has a different pipeline, then accept only if the folded id is a configured (non-_none) pipeline.

}

private boolean hasDefaultSearchPipeline(IndexMetadata indexMetadata) {
String defaultPipeline = IndexSettings.DEFAULT_SEARCH_PIPELINE.get(indexMetadata.getSettings());
return isConfiguredPipeline(defaultPipeline);
}

private boolean isConfiguredPipeline(String pipelineId) {
return Objects.nonNull(pipelineId)
&& pipelineId.isBlank() == false
&& SearchPipelineService.NOOP_PIPELINE_ID.equals(pipelineId) == false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public class HybridQueryUtil {
public static final String HYBRID_QUERY_DFS_SEARCH_TYPE_NOT_SUPPORTED_MESSAGE =
"hybrid query does not support search_type [dfs_query_then_fetch]";

public static final String HYBRID_QUERY_REQUIRES_SEARCH_PIPELINE_MESSAGE =
"hybrid query requires a search pipeline with a normalization processor to be configured, "
+ "either via the search_pipeline request parameter or as the target index's default search pipeline";

/**
* This method validates whether the query object is an instance of hybrid query
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,7 @@ public void testProcessRequest_whenSortByScoreDescWithTrackScores_thenAddRescore
NeuralSparseTwoPhaseProcessor.Factory factory = new NeuralSparseTwoPhaseProcessor.Factory();
NeuralSparseQueryBuilder neuralQueryBuilder = new NeuralSparseQueryBuilder();
SearchRequest searchRequest = new SearchRequest();
searchRequest.source(
new SearchSourceBuilder().query(neuralQueryBuilder).sort(new ScoreSortBuilder()).trackScores(true)
);
searchRequest.source(new SearchSourceBuilder().query(neuralQueryBuilder).sort(new ScoreSortBuilder()).trackScores(true));
NeuralSparseTwoPhaseProcessor processor = createTestProcessor(factory, 0.5f, true, 4.0f, 10000);
processor.processRequest(searchRequest);
assertNotNull(searchRequest.source().rescores());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,69 @@
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.mockito.ArgumentCaptor;
import org.opensearch.Version;
import org.opensearch.action.bulk.BulkAction;
import org.opensearch.action.bulk.BulkRequest;
import org.opensearch.action.search.SearchAction;
import org.opensearch.action.search.SearchRequest;
import org.opensearch.action.search.SearchType;
import org.opensearch.action.support.ActionFilterChain;
import org.opensearch.cluster.ClusterName;
import org.opensearch.cluster.ClusterState;
import org.opensearch.cluster.metadata.IndexMetadata;
import org.opensearch.cluster.metadata.IndexNameExpressionResolver;
import org.opensearch.cluster.metadata.Metadata;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.util.concurrent.ThreadContext;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.action.ActionResponse;
import org.opensearch.index.IndexSettings;
import org.opensearch.index.query.MatchAllQueryBuilder;
import org.opensearch.index.query.MatchQueryBuilder;
import org.opensearch.neuralsearch.query.HybridQueryBuilder;
import org.opensearch.neuralsearch.query.OpenSearchQueryTestCase;
import org.opensearch.neuralsearch.util.HybridQueryUtil;
import org.opensearch.neuralsearch.util.NeuralSearchClusterUtil;
import org.opensearch.search.builder.SearchSourceBuilder;
import org.opensearch.search.pipeline.SearchPipelineService;
import org.opensearch.tasks.Task;

public class HybridQuerySearchRequestFilterTests extends OpenSearchQueryTestCase {

private static final String TEST_INDEX = "test_index";

private HybridQuerySearchRequestFilter filter;

@Override
public void setUp() throws Exception {
super.setUp();
filter = new HybridQuerySearchRequestFilter();
// by default, resolve a default search pipeline for TEST_INDEX so existing tests that
// don't care about pipeline resolution keep exercising the batched-reduce-size behavior
setUpDefaultSearchPipeline(TEST_INDEX, "test-pipeline");
}

private void setUpDefaultSearchPipeline(String indexName, String defaultSearchPipelineId) {
Settings.Builder settingsBuilder = Settings.builder()
.put(IndexMetadata.SETTING_VERSION_CREATED, Version.CURRENT)
.put(IndexMetadata.SETTING_NUMBER_OF_SHARDS, 1)
.put(IndexMetadata.SETTING_NUMBER_OF_REPLICAS, 0);
if (defaultSearchPipelineId != null) {
settingsBuilder.put(IndexSettings.DEFAULT_SEARCH_PIPELINE.getKey(), defaultSearchPipelineId);
}
IndexMetadata indexMetadata = IndexMetadata.builder(indexName).settings(settingsBuilder).build();
Metadata metadata = Metadata.builder().put(indexMetadata, false).build();
ClusterState clusterState = ClusterState.builder(ClusterName.DEFAULT).metadata(metadata).build();

ClusterService clusterService = mock(ClusterService.class);
when(clusterService.state()).thenReturn(clusterState);

IndexNameExpressionResolver indexNameExpressionResolver = new IndexNameExpressionResolver(new ThreadContext(Settings.EMPTY));
NeuralSearchClusterUtil.instance().initialize(clusterService, indexNameExpressionResolver);
}

public void testOrder_thenReturnsZero() {
Expand Down Expand Up @@ -301,4 +339,104 @@ public void testApply_whenEmptyHybridQuery_thenDisablesBatchedReduction() {
assertEquals(Integer.MAX_VALUE, searchRequest.getBatchedReduceSize());
verify(chain).proceed(eq(task), eq(SearchAction.NAME), eq(searchRequest), eq(listener));
}

@SuppressWarnings("unchecked")
public void testApply_whenHybridQueryWithNoResolvableSearchPipeline_thenFails() {
// no default search pipeline configured on the index, no request-level pipeline set
setUpDefaultSearchPipeline(TEST_INDEX, null);

HybridQueryBuilder hybridQuery = new HybridQueryBuilder();
hybridQuery.add(new MatchQueryBuilder("field", "value"));

SearchRequest searchRequest = new SearchRequest(TEST_INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(hybridQuery);
searchRequest.source(sourceBuilder);

Task task = mock(Task.class);
ActionListener<ActionResponse> listener = mock(ActionListener.class);
ActionFilterChain<SearchRequest, ActionResponse> chain = mock(ActionFilterChain.class);

filter.apply(task, SearchAction.NAME, searchRequest, listener, chain);

ArgumentCaptor<Exception> exceptionCaptor = ArgumentCaptor.forClass(Exception.class);
verify(listener).onFailure(exceptionCaptor.capture());
verify(chain, never()).proceed(eq(task), eq(SearchAction.NAME), eq(searchRequest), eq(listener));
assertTrue(exceptionCaptor.getValue() instanceof IllegalArgumentException);
assertThat(exceptionCaptor.getValue().getMessage(), containsString(HybridQueryUtil.HYBRID_QUERY_REQUIRES_SEARCH_PIPELINE_MESSAGE));
}

@SuppressWarnings("unchecked")
public void testApply_whenHybridQueryWithNoopRequestPipelineAndNoIndexDefault_thenFails() {
// request explicitly disables the pipeline (search_pipeline=_none), and index has no default either
setUpDefaultSearchPipeline(TEST_INDEX, null);

HybridQueryBuilder hybridQuery = new HybridQueryBuilder();
hybridQuery.add(new MatchQueryBuilder("field", "value"));

SearchRequest searchRequest = new SearchRequest(TEST_INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(hybridQuery);
searchRequest.source(sourceBuilder);
searchRequest.pipeline(SearchPipelineService.NOOP_PIPELINE_ID);

Task task = mock(Task.class);
ActionListener<ActionResponse> listener = mock(ActionListener.class);
ActionFilterChain<SearchRequest, ActionResponse> chain = mock(ActionFilterChain.class);

filter.apply(task, SearchAction.NAME, searchRequest, listener, chain);

ArgumentCaptor<Exception> exceptionCaptor = ArgumentCaptor.forClass(Exception.class);
verify(listener).onFailure(exceptionCaptor.capture());
verify(chain, never()).proceed(eq(task), eq(SearchAction.NAME), eq(searchRequest), eq(listener));
assertThat(exceptionCaptor.getValue().getMessage(), containsString(HybridQueryUtil.HYBRID_QUERY_REQUIRES_SEARCH_PIPELINE_MESSAGE));
}

@SuppressWarnings("unchecked")
public void testApply_whenHybridQueryWithRequestLevelPipelineAndNoIndexDefault_thenProceeds() {
// no default search pipeline on the index, but the request explicitly names one
setUpDefaultSearchPipeline(TEST_INDEX, null);

HybridQueryBuilder hybridQuery = new HybridQueryBuilder();
hybridQuery.add(new MatchQueryBuilder("field", "value"));

SearchRequest searchRequest = new SearchRequest(TEST_INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(hybridQuery);
searchRequest.source(sourceBuilder);
searchRequest.pipeline("my-normalization-pipeline");

Task task = mock(Task.class);
ActionListener<ActionResponse> listener = mock(ActionListener.class);
ActionFilterChain<SearchRequest, ActionResponse> chain = mock(ActionFilterChain.class);

filter.apply(task, SearchAction.NAME, searchRequest, listener, chain);

verify(listener, never()).onFailure(org.mockito.ArgumentMatchers.any());
verify(chain).proceed(eq(task), eq(SearchAction.NAME), eq(searchRequest), eq(listener));
assertEquals(Integer.MAX_VALUE, searchRequest.getBatchedReduceSize());
}

@SuppressWarnings("unchecked")
public void testApply_whenHybridQueryWithIndexDefaultSearchPipeline_thenProceeds() {
// index has a default search pipeline configured, request doesn't specify one
setUpDefaultSearchPipeline(TEST_INDEX, "index-default-pipeline");

HybridQueryBuilder hybridQuery = new HybridQueryBuilder();
hybridQuery.add(new MatchQueryBuilder("field", "value"));

SearchRequest searchRequest = new SearchRequest(TEST_INDEX);
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
sourceBuilder.query(hybridQuery);
searchRequest.source(sourceBuilder);

Task task = mock(Task.class);
ActionListener<ActionResponse> listener = mock(ActionListener.class);
ActionFilterChain<SearchRequest, ActionResponse> chain = mock(ActionFilterChain.class);

filter.apply(task, SearchAction.NAME, searchRequest, listener, chain);

verify(listener, never()).onFailure(org.mockito.ArgumentMatchers.any());
verify(chain).proceed(eq(task), eq(SearchAction.NAME), eq(searchRequest), eq(listener));
}
}
Loading