Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,20 @@ private void executeVariantTaskAsync(VariantTaskParameters params, CompletableFu
final String evaluationId = UUID.randomUUID().toString();
SearchRequest searchRequest = buildSearchRequest(params, evaluationId);

// Instrumentation: log final serialized search request body for debugging (e.g., LTR rescore_query)
try {
String source = searchRequest.source() != null ? searchRequest.source().toString() : null;
log.debug(
"Experiment search request body (experimentId={}, variantId={}, evaluationId={}): {}",
params.getExperimentId(),
params.getExperimentVariant().getId(),
evaluationId,
source
);
} catch (Exception e) {
log.warn("Failed to serialize search request body for logging: {}", e.getMessage());
}

// Convert ActionListener to CompletableFuture
CompletableFuture<Void> searchFuture = new CompletableFuture<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,51 @@
import static org.opensearch.searchrelevance.experiment.QuerySourceUtil.validateHybridQuery;

import java.io.IOException;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;

import org.opensearch.action.search.SearchRequest;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.xcontent.json.JsonXContent;
import org.opensearch.core.xcontent.DeprecationHandler;
import org.opensearch.core.xcontent.NamedXContentRegistry;
import org.opensearch.core.xcontent.XContentBuilder;
import org.opensearch.core.xcontent.XContentParser;
import org.opensearch.index.query.QueryBuilders;
import org.opensearch.search.SearchModule;
import org.opensearch.search.builder.SearchSourceBuilder;

import lombok.extern.log4j.Log4j2;

@Log4j2
/**
* Common Search Request Builder for Search Configuration with placeholder with QueryText filled.
*
* This implementation parses the entire source using the real NamedXContentRegistry provided
* by the node/plugin wiring, so that any query type registered by any plugin can be parsed
* without special-casing (no wrapper hacks for query/rescore_query etc).
*/
public class SearchRequestBuilder {

private static final NamedXContentRegistry NAMED_CONTENT_REGISTRY;
private static final SearchModule SEARCH_MODULE;
private static final String QUERY_FIELD_NAME = "query";
private static volatile NamedXContentRegistry NAMED_XCONTENT_REGISTRY;
private static final String SIZE_FIELD_NAME = "size";
private static final String QUERY_FIELD_NAME = "query";

static {
SEARCH_MODULE = new SearchModule(Settings.EMPTY, Collections.emptyList());
NAMED_CONTENT_REGISTRY = new NamedXContentRegistry(SEARCH_MODULE.getNamedXContents());
/**
* Initialize the builder with the cluster's NamedXContentRegistry so that
* SearchSourceBuilder can parse all plugin-registered query types.
*/
public static void initialize(NamedXContentRegistry registry) {
NAMED_XCONTENT_REGISTRY = registry;
log.debug("SearchRequestBuilder initialized with NamedXContentRegistry");
}

private static XContentParser newParserWithRegistry(String json) throws IOException {
if (NAMED_XCONTENT_REGISTRY == null) {
throw new IllegalStateException(
"SearchRequestBuilder is not initialized with NamedXContentRegistry. "
+ "Ensure SearchRelevancePlugin.createComponents calls SearchRequestBuilder.initialize(xContentRegistry)."
);
}
return JsonXContent.jsonXContent.createParser(NAMED_XCONTENT_REGISTRY, DeprecationHandler.IGNORE_DEPRECATIONS, json);
}

/**
Expand All @@ -60,26 +74,23 @@ public static SearchRequest buildSearchRequest(String index, String query, Strin
// Replace placeholder with actual query text
String processedQuery = query.replace(WILDCARD_QUERY_TEXT, queryText);

// Parse the full query into a map
XContentParser parser = JsonXContent.jsonXContent.createParser(
// Parse to map (using EMPTY registry) for validation/log-only purposes such as size check
XContentParser tempParser = JsonXContent.jsonXContent.createParser(
NamedXContentRegistry.EMPTY,
DeprecationHandler.IGNORE_DEPRECATIONS,
processedQuery
);
Map<String, Object> fullQueryMap = parser.map();
Map<String, Object> fullQueryMap = tempParser.map();

// This implementation handles the 'query' field separately from other fields because:
// 1. Custom query types (like hybrid, neural) are not registered in the default QueryBuilders
// 2. Using WrapperQuery allows passing through any query structure without parsing
// 3. All other fields (aggregations, source filtering, etc.) can be parsed normally by SearchSourceBuilder
// Handle 'query' separately using WrapperQuery to support custom/unregistered query types
Object queryObject = fullQueryMap.remove(QUERY_FIELD_NAME);

// Parse everything except query using SearchSourceBuilder.fromXContent
// Parse everything except query using SearchSourceBuilder.fromXContent with real registry
XContentBuilder builder = JsonXContent.contentBuilder();
builder.map(fullQueryMap);

parser = JsonXContent.jsonXContent.createParser(
NAMED_CONTENT_REGISTRY,
XContentParser parser = JsonXContent.jsonXContent.createParser(
NAMED_XCONTENT_REGISTRY,
DeprecationHandler.IGNORE_DEPRECATIONS,
builder.toString()
);
Expand All @@ -105,7 +116,7 @@ public static SearchRequest buildSearchRequest(String index, String query, Strin
);
}
}
// Set size
// Set size override from configuration input
sourceBuilder.size(size);

// Set search pipeline if provided
Expand Down Expand Up @@ -134,34 +145,40 @@ public static SearchRequest buildRequestForHybridSearch(
// Replace placeholder with actual query text
String processedQuery = query.replace(WILDCARD_QUERY_TEXT, queryText);

// Parse the full query into a map
XContentParser parser = JsonXContent.jsonXContent.createParser(
// Parse to map (using EMPTY registry) for validation/log-only purposes (hybrid validation, size check)
XContentParser tempParser = JsonXContent.jsonXContent.createParser(
NamedXContentRegistry.EMPTY,
DeprecationHandler.IGNORE_DEPRECATIONS,
processedQuery
);
Map<String, Object> fullQueryMap = parser.map();
Map<String, Object> fullQueryMap = tempParser.map();

// Validate hybrid query
validateHybridQuery(fullQueryMap);

// This implementation handles the 'query' field separately from other fields because:
// 1. Custom query types (like hybrid, neural) are not registered in the default QueryBuilders
// 2. Using WrapperQuery allows passing through any query structure without parsing
// 3. All other fields (aggregations, source filtering, etc.) can be parsed normally by SearchSourceBuilder
// Handle 'query' separately using WrapperQuery to support custom/unregistered query types
Object queryObject = fullQueryMap.remove(QUERY_FIELD_NAME);

// Parse everything except query using SearchSourceBuilder.fromXContent
// Parse everything except query using SearchSourceBuilder.fromXContent with real registry
XContentBuilder builder = JsonXContent.contentBuilder();
builder.map(fullQueryMap);

parser = JsonXContent.jsonXContent.createParser(
NAMED_CONTENT_REGISTRY,
XContentParser parser = JsonXContent.jsonXContent.createParser(
NAMED_XCONTENT_REGISTRY,
DeprecationHandler.IGNORE_DEPRECATIONS,
builder.toString()
);

SearchSourceBuilder sourceBuilder = SearchSourceBuilder.fromXContent(parser);

// Handle query separately using WrapperQuery
if (queryObject != null) {
builder = JsonXContent.contentBuilder();
builder.value(queryObject);
String queryBody = builder.toString();
sourceBuilder.query(QueryBuilders.wrapperQuery(queryBody));
}

// validate that query does not have internal temporary pipeline definition
if (Objects.nonNull(sourceBuilder.searchPipelineSource()) && !sourceBuilder.searchPipelineSource().isEmpty()) {
log.error("query in search configuration does have temporary search pipeline in its source");
Expand All @@ -174,14 +191,6 @@ public static SearchRequest buildRequestForHybridSearch(
log.debug("no temporary search pipeline");
}

// Handle query separately using WrapperQuery
if (queryObject != null) {
builder = JsonXContent.contentBuilder();
builder.value(queryObject);
String queryBody = builder.toString();
sourceBuilder.query(QueryBuilders.wrapperQuery(queryBody));
}

// Precheck if query contains a different size value
if (fullQueryMap.containsKey(SIZE_FIELD_NAME)) {
int querySize = ((Number) fullQueryMap.get(SIZE_FIELD_NAME)).intValue();
Expand All @@ -193,7 +202,7 @@ public static SearchRequest buildRequestForHybridSearch(
);
}
}
// Set size
// Set size override from configuration input
sourceBuilder.size(size);

searchRequest.source(sourceBuilder);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import org.opensearch.searchrelevance.indices.SearchRelevanceIndicesManager;
import org.opensearch.searchrelevance.metrics.MetricsHelper;
import org.opensearch.searchrelevance.ml.MLAccessor;
import org.opensearch.searchrelevance.model.builder.SearchRequestBuilder;
import org.opensearch.searchrelevance.rest.RestCreateQuerySetAction;
import org.opensearch.searchrelevance.rest.RestDeleteExperimentAction;
import org.opensearch.searchrelevance.rest.RestDeleteJudgmentAction;
Expand Down Expand Up @@ -172,6 +173,8 @@ public Collection<Object> createComponents(
this.clusterUtil = new ClusterUtil(clusterService);
this.infoStatsManager = new InfoStatsManager(settingsAccessor);
EventStatsManager.instance().initialize(settingsAccessor);
// Initialize SearchRequestBuilder with the real NamedXContentRegistry so it can parse all plugin-registered queries
SearchRequestBuilder.initialize(xContentRegistry);

return List.of(
searchRelevanceIndicesManager,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.searchrelevance.integration;

import java.util.Locale;

import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.opensearch.client.Request;
import org.opensearch.client.Response;
import org.opensearch.client.ResponseException;
import org.opensearch.searchrelevance.BaseSearchRelevanceIT;

/**
* Integration test that verifies SearchSource parsing of rescore with the `sltr` clause.
* This ensures that when the LTR module/plugin is present, the NamedXContentRegistry
* includes the LTR parsers and the request does not fail with an "unknown [sltr]" parsing error.
* <p>
* Note:
* - This test does NOT require a fully trained LTR model. It is valid for the request to fail
* with model-not-found or runtime execution errors. The key validation is that parsing
* recognizes the "sltr" rescorer rather than failing with an unknown query/rescorer error.
*/
public class LtrSltrRescoreIT extends BaseSearchRelevanceIT {

public void testRescoreParsingWithSltr() throws Exception {
final String index = "ltr-sltr-it";

final String indexConfig = "{"
+ " \"settings\": {"
+ " \"number_of_shards\": 1,"
+ " \"number_of_replicas\": 0"
+ " },"
+ " \"mappings\": {"
+ " \"properties\": {"
+ " \"title\": {\"type\": \"text\"},"
+ " \"body\": {\"type\": \"text\"}"
+ " }"
+ " }"
+ "}";

createIndexWithConfiguration(index, indexConfig);

final String bulk = "{ \"index\": {\"_index\":\""
+ index
+ "\", \"_id\":\"1\"} }\n"
+ "{ \"title\":\"alpha\", \"body\":\"foo\" }\n"
+ "{ \"index\": {\"_index\":\""
+ index
+ "\", \"_id\":\"2\"} }\n"
+ "{ \"title\":\"beta\", \"body\":\"bar\" }\n";

bulkIngest(index, bulk);

// Build a search request that includes rescore with sltr. We do not require the model to exist;
// we only validate that the parser recognizes "sltr" and does not throw "unknown [sltr]" errors.
final String searchBody = "{"
+ " \"query\": {\"match_all\": {}},"
+ " \"rescore\": ["
+ " {"
+ " \"window_size\": 10,"
+ " \"rescore_query\": {"
+ " \"sltr\": {"
+ " \"params\": {\"keywords\": \"foo\"},"
+ " \"model\": \"my_test_model\""
+ " }"
+ " }"
+ " }"
+ " ]"
+ "}";

final Request search = new Request("GET", "/" + index + "/_search");
search.setJsonEntity(searchBody);

try {
final Response ok = client().performRequest(search);
assertEquals(200, ok.getStatusLine().getStatusCode());
} catch (ResponseException re) {
final Response r = re.getResponse();
final int code = r.getStatusLine().getStatusCode();
// Accept any response; the important part is that the error is NOT "unknown [sltr]".
assertTrue("Expected an HTTP status (>=200). Got: " + code, code >= 200 && code < 600);
final String msg = EntityUtils.toString(r.getEntity());
final boolean unknownSltr = msg != null
&& msg.toLowerCase(Locale.ROOT).contains("unknown")
&& msg.toLowerCase(Locale.ROOT).contains("sltr");
assertFalse("Cluster did not recognize 'sltr' rescore; LTR module may not be loaded. Message: " + msg, unknownSltr);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,20 @@
import org.opensearch.action.search.SearchRequest;
import org.opensearch.action.search.SearchResponse;
import org.opensearch.cluster.service.ClusterService;
import org.opensearch.common.settings.Settings;
import org.opensearch.common.xcontent.XContentFactory;
import org.opensearch.core.action.ActionListener;
import org.opensearch.core.common.bytes.BytesArray;
import org.opensearch.core.common.bytes.BytesReference;
import org.opensearch.core.xcontent.NamedXContentRegistry;
import org.opensearch.search.SearchHit;
import org.opensearch.search.SearchHits;
import org.opensearch.search.SearchModule;
import org.opensearch.searchrelevance.dao.EvaluationResultDao;
import org.opensearch.searchrelevance.dao.ExperimentVariantDao;
import org.opensearch.searchrelevance.dao.JudgmentDao;
import org.opensearch.searchrelevance.model.SearchConfigurationDetails;
import org.opensearch.searchrelevance.model.builder.SearchRequestBuilder;
import org.opensearch.test.OpenSearchTestCase;
import org.opensearch.transport.client.Client;

Expand All @@ -56,6 +60,12 @@ public void setUp() throws Exception {
evaluationResultDao = mock(EvaluationResultDao.class);
experimentVariantDao = mock(ExperimentVariantDao.class);
metricsHelper = new MetricsHelper(clusterService, client, judgmentDao, evaluationResultDao, experimentVariantDao);

// Initialize SearchRequestBuilder NamedXContentRegistry for tests
NamedXContentRegistry reg = new NamedXContentRegistry(
new SearchModule(Settings.EMPTY, java.util.Collections.emptyList()).getNamedXContents()
);
SearchRequestBuilder.initialize(reg);
}

public void testProcessPairwiseMetricsWithPipeline() {
Expand Down
Loading