Skip to content

Commit db33b31

Browse files
authored
Add end-to-end remote dense model IT for semantic field mapping transformer (#1966)
* Add end-to-end remote dense model IT for semantic field mapping transformer Replace the mapping-only testTransformMappingWithRemoteDenseModel with a real end-to-end *RemoteModelIT* that exercises a remote dense (text embedding) model served by the existing TorchServe Docker mock. The new SemanticMappingTransformerRemoteModelIT: - deploys a 128-dim remote text embedding model (space_type l2) via the shared TorchServe connector, gated on TorchServe availability - asserts the semantic field mapping is transformed using the dimension and space type resolved from the deployed model - ingests a document and verifies the remote model actually generates 128-dim non-zero embeddings The semantic field uses the lucene knn engine (no native lib dependency), matching the pattern in SymmetricRemoteModelIT. Remove the old mapping-only SemanticMappingTransformerIT and its unused OpenAI-endpoint fixtures. Signed-off-by: Bo Zhang <bzhangam@amazon.com> * Update CHANGELOG with PR number #1966 Signed-off-by: Bo Zhang <bzhangam@amazon.com> --------- Signed-off-by: Bo Zhang <bzhangam@amazon.com>
1 parent 972d698 commit db33b31

8 files changed

Lines changed: 278 additions & 97 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1818
* [RRF] Reject a combination technique other than rrf when creating a score-ranker-processor, instead of accepting the pipeline and throwing NullPointerException on every query ([#1949](https://github.com/opensearch-project/neural-search/pull/1949))
1919

2020
### Infrastructure
21-
21+
* [Semantic Field] Add an end-to-end remote dense model IT for the semantic field mapping transformer using the TorchServe mock model ([#1966](https://github.com/opensearch-project/neural-search/pull/1966))
2222

2323
### Documentation
2424

src/test/java/org/opensearch/neuralsearch/mappingtransformer/SemanticMappingTransformerIT.java

Lines changed: 0 additions & 54 deletions
This file was deleted.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package org.opensearch.neuralsearch.mappingtransformer;
6+
7+
import java.io.IOException;
8+
import java.net.URISyntaxException;
9+
import java.nio.file.Files;
10+
import java.nio.file.Path;
11+
import java.util.List;
12+
import java.util.Locale;
13+
import java.util.Map;
14+
import java.util.Objects;
15+
16+
import org.apache.hc.core5.http.HttpHeaders;
17+
import org.apache.hc.core5.http.message.BasicHeader;
18+
import org.opensearch.client.Request;
19+
import org.opensearch.client.Response;
20+
import org.opensearch.common.xcontent.XContentHelper;
21+
import org.opensearch.common.xcontent.XContentType;
22+
import org.junit.After;
23+
import org.junit.Assume;
24+
import org.junit.Before;
25+
import org.opensearch.neuralsearch.BaseNeuralSearchIT;
26+
import org.opensearch.neuralsearch.util.RemoteModelTestUtils;
27+
28+
import com.google.common.collect.ImmutableList;
29+
30+
import lombok.SneakyThrows;
31+
import lombok.extern.log4j.Log4j2;
32+
33+
import static org.opensearch.neuralsearch.util.TestUtils.DEFAULT_USER_AGENT;
34+
35+
/**
36+
* End-to-end integration test for the semantic field mapping transformation with a remote dense
37+
* (text embedding) model served by the TorchServe Docker mock. It verifies two things:
38+
* <ol>
39+
* <li>The semantic field mapping is transformed correctly using the embedding dimension and
40+
* space type resolved from the deployed remote model.</li>
41+
* <li>The remote model actually generates embeddings during ingestion (real inference through
42+
* the ML Commons connector), not just a metadata-only mapping change.</li>
43+
* </ol>
44+
* This test is only exercised by the {@code remoteModelIntegTest} Gradle task (class name matches
45+
* the {@code *RemoteModelIT*} filter) and is skipped when TorchServe is not available.
46+
*/
47+
@Log4j2
48+
public class SemanticMappingTransformerRemoteModelIT extends BaseNeuralSearchIT {
49+
50+
private static final String INDEX_NAME = "semantic_field_remote_dense_model_index";
51+
private static final int EMBEDDING_DIMENSION = 128; // tiny BERT model served by TorchServe emits 128-dim embeddings
52+
53+
// Nested semantic field structure produced by mappingtransformer/SemanticIndexMappings.json
54+
private static final String LEVEL_1_FIELD = "products";
55+
private static final String SEMANTIC_INFO_FIELD = "product_description_semantic_info";
56+
private static final String EMBEDDING_FIELD = "embedding";
57+
58+
private final String createIndexRequestBody = Files.readString(
59+
Path.of(Objects.requireNonNull(classLoader.getResource("mappingtransformer/SemanticIndexMappings.json")).toURI())
60+
);
61+
private final String expectedIndexMappingTemplate = Files.readString(
62+
Path.of(Objects.requireNonNull(classLoader.getResource("mappingtransformer/expectedIndexMappingWithRemoteDenseModel.json")).toURI())
63+
);
64+
private final String ingestDoc = Files.readString(
65+
Path.of(Objects.requireNonNull(classLoader.getResource("mappingtransformer/ingest_doc_remote_dense_model.json")).toURI())
66+
);
67+
68+
private String connectorId;
69+
private String remoteModelId;
70+
private boolean isTorchServeAvailable = false;
71+
72+
public SemanticMappingTransformerRemoteModelIT() throws IOException, URISyntaxException {}
73+
74+
@Before
75+
@Override
76+
public void setUp() throws Exception {
77+
super.setUp();
78+
updateClusterSettings();
79+
80+
// Configure ML Commons to trust localhost endpoints for remote models
81+
updateClusterSettings("plugins.ml_commons.only_run_on_ml_node", false);
82+
updateClusterSettings("plugins.ml_commons.connector.private_ip_enabled", true);
83+
updateClusterSettings("plugins.ml_commons.allow_registering_model_via_url", true);
84+
updateClusterSettings(
85+
"plugins.ml_commons.trusted_connector_endpoints_regex",
86+
List.of("^http://localhost:.*", "^http://127\\.0\\.0\\.1:.*", "^http://torchserve:.*")
87+
);
88+
89+
String torchServeEndpoint = System.getenv("TORCHSERVE_ENDPOINT");
90+
if (torchServeEndpoint == null) {
91+
torchServeEndpoint = System.getProperty("tests.torchserve.endpoint");
92+
}
93+
94+
if (torchServeEndpoint == null || torchServeEndpoint.isEmpty()) {
95+
log.info("TorchServe endpoint not configured, tests will be skipped");
96+
return;
97+
}
98+
99+
isTorchServeAvailable = RemoteModelTestUtils.isRemoteEndpointAvailable(torchServeEndpoint);
100+
if (!isTorchServeAvailable) {
101+
log.info("TorchServe not available at {}, tests will be skipped", torchServeEndpoint);
102+
return;
103+
}
104+
105+
log.info("TorchServe endpoint available at: {}", torchServeEndpoint);
106+
try {
107+
connectorId = createRemoteModelConnector(torchServeEndpoint);
108+
log.info("Created connector with ID: {}", connectorId);
109+
110+
remoteModelId = deployRemoteModel(connectorId, "semantic-mapping-transformer-dense-remote");
111+
log.info("Deployed remote text embedding model with ID: {}", remoteModelId);
112+
} catch (Exception e) {
113+
log.error("Failed to set up remote model: ", e);
114+
isTorchServeAvailable = false;
115+
}
116+
}
117+
118+
@After
119+
@SneakyThrows
120+
public void tearDown() {
121+
super.tearDown();
122+
123+
try {
124+
deleteIndex(INDEX_NAME);
125+
} catch (Exception e) {
126+
log.debug("Index cleanup failed: {}", e.getMessage());
127+
}
128+
129+
if (remoteModelId != null || connectorId != null) {
130+
cleanupRemoteModelResources(connectorId, remoteModelId);
131+
}
132+
}
133+
134+
/**
135+
* Verifies that the semantic field mapping is transformed using the dimension/space type from the
136+
* deployed remote dense model, and that the remote model produces real embeddings during ingestion.
137+
*/
138+
@SneakyThrows
139+
public void testTransformMappingWithRemoteDenseModel() {
140+
Assume.assumeTrue("TorchServe is not available, skipping test", isTorchServeAvailable);
141+
142+
// 1. Create the semantic index. The mapping transformer resolves the embedding dimension and
143+
// space type from the deployed remote model to build the knn_vector sub-field.
144+
createSemanticIndexWithConfiguration(INDEX_NAME, createIndexRequestBody, remoteModelId);
145+
146+
// 2. Assert the transformed index mapping matches the expected 128-dim dense mapping.
147+
final Map<String, Object> indexMapping = getIndexMapping(INDEX_NAME);
148+
final String expectedIndexMappingStr = String.format(Locale.ROOT, expectedIndexMappingTemplate, remoteModelId);
149+
final Map<String, Object> expectedIndexMappingMap = createParser(XContentType.JSON.xContent(), expectedIndexMappingStr).map();
150+
org.assertj.core.api.Assertions.assertThat(indexMapping).isEqualTo(expectedIndexMappingMap);
151+
152+
// 3. Ingest a document and verify the remote model actually generated embeddings.
153+
ingestDocument(INDEX_NAME, ingestDoc, "1");
154+
assertEquals(1, getDocCount(INDEX_NAME));
155+
156+
@SuppressWarnings("unchecked")
157+
final Map<String, Object> source = (Map<String, Object>) getDocById(INDEX_NAME, "1").get("_source");
158+
assertEmbeddingsGenerated(source);
159+
}
160+
161+
/**
162+
* Verifies each nested product has a 128-dim, non-zero embedding produced by the remote model.
163+
*/
164+
@SuppressWarnings("unchecked")
165+
private void assertEmbeddingsGenerated(final Map<String, Object> source) {
166+
final List<Map<String, Object>> products = (List<Map<String, Object>>) source.get(LEVEL_1_FIELD);
167+
assertNotNull("Nested products should exist", products);
168+
assertEquals("Both nested products should be present", 2, products.size());
169+
170+
for (final Map<String, Object> product : products) {
171+
final Map<String, Object> semanticInfo = (Map<String, Object>) product.get(SEMANTIC_INFO_FIELD);
172+
assertNotNull("Semantic info should exist for each product", semanticInfo);
173+
174+
final List<Number> embedding = (List<Number>) semanticInfo.get(EMBEDDING_FIELD);
175+
assertNotNull("Remote model should generate an embedding", embedding);
176+
assertEquals("Embedding dimension should match the remote model", EMBEDDING_DIMENSION, embedding.size());
177+
178+
final boolean hasNonZeroValues = embedding.stream().anyMatch(value -> value.doubleValue() != 0.0);
179+
assertTrue("Embedding should contain non-zero values", hasNonZeroValues);
180+
}
181+
}
182+
183+
/**
184+
* Registers and deploys the remote model with a full text-embedding {@code model_config}. The
185+
* mapping transformer reads {@code embedding_dimension} and {@code additional_config.space_type}
186+
* from this config, so they must match the TorchServe dense handler (128-dim, l2).
187+
*/
188+
@Override
189+
protected String deployRemoteModel(final String connectorId, final String modelName) throws Exception {
190+
final String requestBody = String.format(Locale.ROOT, """
191+
{
192+
"name": "%s",
193+
"function_name": "remote",
194+
"description": "Remote dense text embedding model for semantic mapping transformer IT",
195+
"connector_id": "%s",
196+
"model_config": {
197+
"model_type": "TEXT_EMBEDDING",
198+
"embedding_dimension": 128,
199+
"framework_type": "sentence_transformers",
200+
"additional_config": {
201+
"space_type": "l2"
202+
}
203+
}
204+
}
205+
""", modelName, connectorId);
206+
207+
final Request registerRequest = new Request("POST", "/_plugins/_ml/models/_register");
208+
registerRequest.setJsonEntity(requestBody);
209+
final Response registerResponse = client().performRequest(registerRequest);
210+
final Map<String, Object> registerResponseMap = XContentHelper.convertToMap(
211+
XContentType.JSON.xContent(),
212+
registerResponse.getEntity().getContent(),
213+
false
214+
);
215+
final String modelId = (String) registerResponseMap.get("model_id");
216+
217+
final Request deployRequest = new Request("POST", "/_plugins/_ml/models/" + modelId + "/_deploy");
218+
client().performRequest(deployRequest);
219+
220+
waitForModelToBeReady(modelId);
221+
return modelId;
222+
}
223+
224+
/**
225+
* Creates a TorchServe connector for symmetric (dense) text embedding, reusing the shared
226+
* connector template used by {@link org.opensearch.neuralsearch.ml.SymmetricRemoteModelIT}.
227+
*/
228+
@Override
229+
protected String createRemoteModelConnector(final String endpoint) throws Exception {
230+
final String connectorName = "semantic-mapping-transformer-dense-connector-" + System.currentTimeMillis();
231+
final String connectorTemplate = Files.readString(
232+
Path.of(Objects.requireNonNull(classLoader.getResource("symmetric/RemoteTorchServeConnector.json")).toURI())
233+
);
234+
final String requestBody = String.format(Locale.ROOT, connectorTemplate, connectorName, endpoint);
235+
236+
final Response response = makeRequest(
237+
client(),
238+
"POST",
239+
"/_plugins/_ml/connectors/_create",
240+
null,
241+
toHttpEntity(requestBody),
242+
ImmutableList.of(new BasicHeader(HttpHeaders.USER_AGENT, DEFAULT_USER_AGENT))
243+
);
244+
final Map<String, Object> responseMap = XContentHelper.convertToMap(
245+
XContentType.JSON.xContent(),
246+
response.getEntity().getContent(),
247+
false
248+
);
249+
return (String) responseMap.get("connector_id");
250+
}
251+
}

src/test/resources/mappingtransformer/CreateConnectorRequestBody.json

Lines changed: 0 additions & 24 deletions
This file was deleted.

src/test/resources/mappingtransformer/RegisterRemoteDenseModelRequestBody.json

Lines changed: 0 additions & 14 deletions
This file was deleted.

src/test/resources/mappingtransformer/SemanticIndexMappings.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
},
1313
"product_description":{
1414
"type": "semantic",
15-
"model_id": "%s"
15+
"model_id": "%s",
16+
"dense_embedding_config": {
17+
"method": {
18+
"engine": "lucene"
19+
}
20+
}
1621
}
1722
}
1823
}

src/test/resources/mappingtransformer/expectedIndexMappingWithRemoteDenseModel.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,20 @@
1111
"product_description": {
1212
"type": "semantic",
1313
"model_id": "%s",
14-
"raw_field_type": "text"
14+
"raw_field_type": "text",
15+
"dense_embedding_config": {
16+
"method": {
17+
"engine": "lucene"
18+
}
19+
}
1520
},
1621
"product_description_semantic_info": {
1722
"properties": {
1823
"embedding": {
1924
"type": "knn_vector",
20-
"dimension": 384,
25+
"dimension": 128,
2126
"method": {
22-
"engine": "faiss",
27+
"engine": "lucene",
2328
"space_type": "l2",
2429
"name": "hnsw",
2530
"parameters": {}

0 commit comments

Comments
 (0)