From abb010112a4f531da1159cf6422cf3ceb175a6e2 Mon Sep 17 00:00:00 2001 From: Brian Flores Date: Tue, 15 Oct 2024 15:08:20 -0700 Subject: [PATCH] Adds Javadocs,renames methods, adds UTs for the remove_target_field functionality In this commit I added more JavaDocs on methods and the initial class to help developers understand more in depth how the ByField Processor works. I added four more UTs that interact with the sourceMap modifcation feature I also implemented the feedback given regarding naming and the uneeded singular parameter in the RerankProcessorFactory Signed-off-by: Brian Flores --- .../factory/RerankProcessorFactory.java | 12 +- .../rerank/ByFieldRerankProcessor.java | 120 +++++-- .../processor/rerank/RerankProcessor.java | 11 + .../rerank/ByFieldRerankProcessorTests.java | 300 ++++++++++++++++-- 4 files changed, 385 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/opensearch/neuralsearch/processor/factory/RerankProcessorFactory.java b/src/main/java/org/opensearch/neuralsearch/processor/factory/RerankProcessorFactory.java index 7f58d3d89..6caf4dce3 100644 --- a/src/main/java/org/opensearch/neuralsearch/processor/factory/RerankProcessorFactory.java +++ b/src/main/java/org/opensearch/neuralsearch/processor/factory/RerankProcessorFactory.java @@ -42,11 +42,6 @@ public class RerankProcessorFactory implements Processor.Factory> processorFactories, @@ -59,6 +54,8 @@ public SearchResponseProcessor create( RerankType type = findRerankType(config); boolean includeQueryContextFetcher = ContextFetcherFactory.shouldIncludeQueryContextFetcher(type); + // Currently the createFetchers method requires that you provide a context map, this branch makes sure we can ignore this on + // processors that don't need the context map List contextFetchers = processorRequiresContext(type) ? ContextFetcherFactory.createFetchers(config, includeQueryContextFetcher, tag, clusterService) : Collections.emptyList(); @@ -133,9 +130,8 @@ public static boolean shouldIncludeQueryContextFetcher(RerankType type) { /** * Create necessary queryContextFetchers for this processor - * - * @param config processor config object. Look for "context" field to find fetchers - * @param includeQueryContextFetcher should I include the queryContextFetcher? + * @param config Processor config object. Look for "context" field to find fetchers + * @param includeQueryContextFetcher Should I include the queryContextFetcher? * @return list of contextFetchers for the processor to use */ public static List createFetchers( diff --git a/src/main/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessor.java b/src/main/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessor.java index 8b2ff431a..89baf5e25 100644 --- a/src/main/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessor.java +++ b/src/main/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessor.java @@ -20,6 +20,42 @@ import java.util.Map; import java.util.Optional; +/** + * A reranking processor that reorders search results based on the content of a specified field. + *

+ * The ByFieldRerankProcessor extends the RescoringRerankProcessor to provide field-based reranking + * capabilities. It allows for reordering of search results by considering the content of a + * designated target field within each document. + *

+ * Key features: + *

    + *
  • Reranks search results based on a specified target field
  • + *
  • Optionally removes the target field from the final search results
  • + *
  • Supports nested field structures using dot notation
  • + *
+ *

+ * The processor uses the following configuration parameters: + *

    + *
  • {@code target_field}: The field to be used for reranking (required)
  • + *
  • {@code remove_target_field}: Whether to remove the target field from the final results (optional, default: false)
  • + *
+ *

+ * Usage example: + *

+ * {
+ *   "rerank": {
+ *     "by_field": {
+ *       "target_field": "document.relevance_score",
+ *       "remove_target_field": true
+ *     }
+ *   }
+ * }
+ * 
+ *

+ * This processor is particularly useful in scenarios where additional, document-specific + * information stored in a field can be used to improve the relevance of search results + * beyond the initial scoring. + */ public class ByFieldRerankProcessor extends RescoringRerankProcessor { public static final String TARGET_FIELD = "target_field"; @@ -29,14 +65,16 @@ public class ByFieldRerankProcessor extends RescoringRerankProcessor { protected final boolean removeTargetField; /** - * Constructor. pass through to RerankProcessor constructor. + * Constructor to pass values to the RerankProcessor constructor. + * + * @param description The description of the processor + * @param tag The processor's identifier + * @param ignoreFailure If true, OpenSearch ignores any failure of this processor and + * continues to run the remaining processors in the search pipeline. * - * @param description - * @param tag - * @param ignoreFailure - * @param targetField the field you want to replace your score with - * @param removeTargetField - * @param contextSourceFetchers + * @param targetField The field you want to replace your _score with + * @param removeTargetField A flag to let you delete the target_field for better visualization (i.e. removes a duplicate value) + * @param contextSourceFetchers Context from some source and puts it in a map for a reranking processor to use (Unused in ByFieldRerankProcessor) */ public ByFieldRerankProcessor( String description, @@ -55,30 +93,31 @@ public ByFieldRerankProcessor( public void rescoreSearchResponse(SearchResponse response, Map rerankingContext, ActionListener> listener) { SearchHit[] searchHits = response.getHits().getHits(); - if (!searchHitsHaveValidForm(searchHits, listener)) { + if (!validateSearchHits(searchHits, listener)) { return; } List scores = new ArrayList<>(searchHits.length); for (SearchHit hit : searchHits) { - Tuple> typeAndSourceMap = getMapTuple(hit); - Map sourceAsMap = typeAndSourceMap.v2(); + Tuple> mediaTypeAndSourceMapTuple = getMediaTypeAndSourceMapTuple(hit); + Map sourceAsMap = mediaTypeAndSourceMapTuple.v2(); - Object val = getValueFromMap(sourceAsMap, targetField).get(); + Object val = getValueFromSource(sourceAsMap, targetField).get(); scores.add(((Number) val).floatValue()); sourceAsMap.put("previous_score", hit.getScore()); if (removeTargetField) { - removeTargetFieldFromMap(sourceAsMap); + removeTargetFieldFromSource(sourceAsMap); } try { - XContentBuilder builder = XContentBuilder.builder(typeAndSourceMap.v1().xContent()); + XContentBuilder builder = XContentBuilder.builder(mediaTypeAndSourceMapTuple.v1().xContent()); builder.map(sourceAsMap); hit.sourceRef(BytesReference.bytes(builder)); } catch (IOException e) { - throw new RuntimeException(e); + listener.onFailure(new RuntimeException(e)); + return; } } @@ -90,11 +129,11 @@ public void rescoreSearchResponse(SearchResponse response, Map r * to remove. It is implemented recursively to delete empty maps as a result of removing the * targetField *


- * This method assumes that the path to the mapping exists as checked by {@link #searchHitsHaveValidForm(SearchHit[], ActionListener)} + * This method assumes that the path to the mapping exists as checked by {@link #validateSearchHits(SearchHit[], ActionListener)} * As such no error cehcking is done in the methods implementing this functionality * @param sourceAsMap the map of maps that contains the targetField */ - private void removeTargetFieldFromMap(Map sourceAsMap) { + private void removeTargetFieldFromSource(Map sourceAsMap) { String[] keys = targetField.split("\\."); exploreMapAndRemove(sourceAsMap, keys, 0); } @@ -105,7 +144,7 @@ private void removeTargetFieldFromMap(Map sourceAsMap) { * be deleted. The consequence of this, is having to delete all subsequent empty maps , this is * accounted for by the last check to see that the mapping should be removed. *
- * This method assumes that the path to the mapping exists as checked by {@link #searchHitsHaveValidForm(SearchHit[], ActionListener)} + * This method assumes that the path to the mapping exists as checked by {@link #validateSearchHits(SearchHit[], ActionListener)} * As such no error cehcking is done in the methods implementing this functionality * @param sourceAsMap the map of maps that contains the targetField * @param keys The keys used to traverse the nested map @@ -129,7 +168,21 @@ private void exploreMapAndRemove(Map sourceAsMap, String[] keys, } } - private boolean searchHitsHaveValidForm(SearchHit[] searchHits, ActionListener> listener) { + /** + * This is the preflight check for the ByField ReRank Processor. It checks that + * every Search Hit in the array from a given search Response has all the following + * for each SearchHit + *
    + *
  • Has a _source mapping
  • + *
  • Has a valid mapping for target_field
  • + *
  • That value for the mapping is a valid number
  • + *
+ * When just one of the conditions fail the exception will be thrown to the listener. + * @param searchHits from the ByField ReRank Processor + * @param listener returns an error to the listener in case on of the conditions fail + * @return The status indicating that the SearchHits are in correct form to perform the Rerank + */ + private boolean validateSearchHits(SearchHit[] searchHits, ActionListener> listener) { for (int i = 0; i < searchHits.length; i++) { SearchHit hit = searchHits[i]; @@ -140,15 +193,15 @@ private boolean searchHitsHaveValidForm(SearchHit[] searchHits, ActionListener sourceMap = getMapTuple(hit).v2(); - if (!containsMapping(sourceMap, targetField)) { + Map sourceMap = getMediaTypeAndSourceMapTuple(hit).v2(); + if (!mappingExistsInSource(sourceMap, targetField)) { listener.onFailure( new IllegalArgumentException("The field to rerank [" + targetField + "] is not found at hit [" + i + "]") ); return false; } - Optional val = getValueFromMap(sourceMap, targetField); + Optional val = getValueFromSource(sourceMap, targetField); if (val.isEmpty()) { listener.onFailure( new IllegalArgumentException("The field to rerank [" + targetField + "] is found to be null at hit [" + i + "]") @@ -172,13 +225,13 @@ private boolean searchHitsHaveValidForm(SearchHit[] searchHits, ActionListener getValueFromMap(Map map, String pathToValue) { + private Optional getValueFromSource(Map sourceAsMap, String pathToValue) { String[] keys = pathToValue.split("\\."); - Optional currentValue = Optional.of(map); + Optional currentValue = Optional.of(sourceAsMap); for (String key : keys) { currentValue = currentValue.flatMap(value -> { @@ -194,18 +247,25 @@ private Optional getValueFromMap(Map map, String pathToV return currentValue; } - private boolean containsMapping(Map map, String pathToValue) { - return getValueFromMap(map, pathToValue).isPresent(); + /** + * Determines whether there exists a value that has a mapping according to the pathToValue. This is particularly + * useful when the source map is a map of maps and when the pathToValue is of the form key[.key] + * @param sourceAsMap the source field converted to a map + * @param pathToValue A string of the form key[.key] indicating what keys to apply to the sourceMap + * @return Whether the mapping using the pathToValue exists + */ + private boolean mappingExistsInSource(Map sourceAsMap, String pathToValue) { + return getValueFromSource(sourceAsMap, pathToValue).isPresent(); } /** * This helper method is used to retrieve the _source mapping (via v2()) and - * any metadata associated in this mapping (via v2()). + * any metadata associated in this mapping (via v1()). * * @param hit The searchHit that is expected to have a _source mapping - * @return Object that contains metadata on the mapping v1() and the actual contents v2() + * @return Object that contains metadata (MediaType) on the mapping v1() and the actual contents (sourceMap) v2() */ - private static Tuple> getMapTuple(SearchHit hit) { + private static Tuple> getMediaTypeAndSourceMapTuple(SearchHit hit) { BytesReference sourceRef = hit.getSourceRef(); return XContentHelper.convertToMap(sourceRef, false, (MediaType) null); } diff --git a/src/main/java/org/opensearch/neuralsearch/processor/rerank/RerankProcessor.java b/src/main/java/org/opensearch/neuralsearch/processor/rerank/RerankProcessor.java index 4f0ee87f3..42d7d56ee 100644 --- a/src/main/java/org/opensearch/neuralsearch/processor/rerank/RerankProcessor.java +++ b/src/main/java/org/opensearch/neuralsearch/processor/rerank/RerankProcessor.java @@ -109,6 +109,17 @@ public void processResponseAsync( } } + /** + * There are scenarios where ranking occurs without needing context. Currently, these are the processors don't require + * the context mapping + *
    + *
  • + * ByFieldRerankProcessor - Uses the search response to get value to rescore by + *
  • + *
+ * @param subType The kind of rerank processor + * @return Whether a rerank subtype needs context to perform the rescore search response action. + */ public static boolean processorRequiresContext(RerankType subType) { return !processorsWithNoContext.contains(subType); } diff --git a/src/test/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessorTests.java b/src/test/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessorTests.java index 776436b52..33bc0d2d0 100644 --- a/src/test/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessorTests.java +++ b/src/test/java/org/opensearch/neuralsearch/processor/rerank/ByFieldRerankProcessorTests.java @@ -25,11 +25,13 @@ import org.opensearch.test.OpenSearchTestCase; import java.io.IOException; +import java.util.AbstractMap; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.stream.IntStream; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -65,17 +67,7 @@ public class ByFieldRerankProcessorTests extends OpenSearchTestCase { public void setup() { MockitoAnnotations.openMocks(this); doReturn(Settings.EMPTY).when(clusterService).getSettings(); - factory = new RerankProcessorFactory(clusterService); - // Map config = new HashMap<>(Map.of(RerankType.BY_FIELD.getLabel(), Map.of())); - // processor = (ByFieldRerankProcessor) factory.create( - // Map.of(), - // "rerank processor", - // "processor for 2nd level reranking based on provided field", - // false, - // config, - // pipelineContext - // ); - + factory = new RerankProcessorFactory(null, clusterService); } /** @@ -117,9 +109,6 @@ public void setUpValidSearchResultsWithNestedTargetValue() throws IOException { SearchHits searchHits = new SearchHits(hits, totalHits, 1.0f); SearchResponseSections internal = new SearchResponseSections(searchHits, null, null, false, false, null, 0); response = new SearchResponse(internal, null, 1, 1, 0, 1, new ShardSearchFailure[0], new SearchResponse.Clusters(1, 1, 0), null); - - // BytesReference sourceRefAsBytes = BytesReference.bytes(sourceContent); - // Map sourceMap = SourceLookup.sourceAsMap(sourceRefAsBytes); } /** @@ -149,7 +138,7 @@ public void setUpValidSearchResultsWithNonNestedTargetValueWithDenseSourceMappin String sourceMap = templateString.formatted(i, mlScore); - hits[i] = new SearchHit(i, docId + "", Collections.emptyMap(), Collections.emptyMap()); + hits[i] = new SearchHit(docId, docId + "", Collections.emptyMap(), Collections.emptyMap()); hits[i].sourceRef(new BytesArray(sourceMap)); hits[i].score(new Random().nextFloat()); } @@ -159,9 +148,6 @@ public void setUpValidSearchResultsWithNonNestedTargetValueWithDenseSourceMappin SearchHits searchHits = new SearchHits(hits, totalHits, 1.0f); SearchResponseSections internal = new SearchResponseSections(searchHits, null, null, false, false, null, 0); response = new SearchResponse(internal, null, 1, 1, 0, 1, new ShardSearchFailure[0], new SearchResponse.Clusters(1, 1, 0), null); - - // BytesReference sourceRefAsBytes = BytesReference.bytes(sourceContent); - // Map sourceMap = SourceLookup.sourceAsMap(sourceRefAsBytes); } /** @@ -242,8 +228,9 @@ public void testRescoreSearchResponse_returnsScoresSuccessfully_WhenResponseHasT } /** - * - * @throws IOException + * In this scenario the reRanking is being tested i.e. making sure that the search response has + * updated _score fields. This also tests that they are returned in sorted order as + * specified by sortedScoresDescending */ public void testReRank_SortsDescendingWithNewScores_WhenResponseHasNestedField() throws IOException { String targetField = "ml.info.score"; @@ -282,4 +269,277 @@ public void testReRank_SortsDescendingWithNewScores_WhenResponseHasNestedField() } } + /** + * This scenario adds the remove_target_field to be able to test that _source mapping + * has been modified. + *

+ * In this scenario the object will start off like this + *

+     * {
+     *    "my_field" : "%s",
+     *    "ml": {
+     *         "model" : "myModel",
+     *         "info"  : {
+     *          "score": %s
+     *         }
+     *    }
+     *  }
+     * 
+ * and then be transformed into + *
+     * {
+     *     "my_field" : "%s",
+     *     "ml": {
+     *         "model" : "myModel"
+     *      },
+     *      "previous_score" : float
+     * }
+     * 
+ * The reason for this was to delete any empty maps as the result of deleting score. + * This test also checks that previous score was added + */ + public void testReRank_deletesEmptyMaps_WhenResponseHasNestedField() throws IOException { + String targetField = "ml.info.score"; + boolean removeTargetField = true; + setUpValidSearchResultsWithNestedTargetValue(); + + Map config = new HashMap<>( + Map.of( + RerankType.BY_FIELD.getLabel(), + new HashMap<>( + Map.of(ByFieldRerankProcessor.TARGET_FIELD, targetField, ByFieldRerankProcessor.REMOVE_TARGET_FIELD, removeTargetField) + ) + ) + ); + processor = (ByFieldRerankProcessor) factory.create( + Map.of(), + "rerank processor", + "processor for 2nd level reranking based on provided field, This will check a nested field", + false, + config, + pipelineContext + ); + ActionListener listener = mock(ActionListener.class); + processor.rerank(response, Map.of(), listener); + + ArgumentCaptor argCaptor = ArgumentCaptor.forClass(SearchResponse.class); + + verify(listener, times(1)).onResponse(argCaptor.capture()); + SearchResponse searchResponse = argCaptor.getValue(); + + assertEquals(sampleIndexMLScorePairs.size(), searchResponse.getHits().getHits().length); + + for (int i = 0; i < searchResponse.getHits().getHits().length; i++) { + SearchHit searchHit = searchResponse.getHits().getAt(i); + Map sourceMap = searchHit.getSourceAsMap(); + + assertTrue("The source mapping now has `previous_score` entry", sourceMap.containsKey("previous_score")); + assertEquals("The first level of the map is the containing `my_field`, `ml`, and `previous_score`", 3, sourceMap.size()); + + @SuppressWarnings("unchecked") + Map innerMLMap = (Map) sourceMap.get("ml"); + + assertEquals("The ml map now only has 1 mapping `model` instead of 2", 1, innerMLMap.size()); + assertTrue("The ml map has `model` as a mapping", innerMLMap.containsKey("model")); + assertFalse("The ml map no longer has the score `info` mapping ", innerMLMap.containsKey("info")); + + } + } + + /** + * This scenario adds the remove_target_field to be able to test that _source mapping + * has been modified. + *

+ * In this scenario the object will start off like this + *

+     * {
+     *  "my_field" : "%s",
+     *  "ml_score" : %s,
+     *   "info"    : {
+     *          "model" : "myModel"
+     *    }
+     * }
+     * 
+ * and then be transformed into + *
+     * {
+     *  "my_field" : "%s",
+     *   "info"    : {
+     *          "model" : "myModel"
+     *    },
+     *    "previous_score" : float
+     * }
+     * 
+ * This test also checks that previous score was added + */ + public void testReRank_deletesEmptyMaps_WhenResponseHasNonNestedField() throws IOException { + String targetField = "ml_score"; + boolean removeTargetField = true; + setUpValidSearchResultsWithNonNestedTargetValueWithDenseSourceMapping(); + + Map config = new HashMap<>( + Map.of( + RerankType.BY_FIELD.getLabel(), + new HashMap<>( + Map.of(ByFieldRerankProcessor.TARGET_FIELD, targetField, ByFieldRerankProcessor.REMOVE_TARGET_FIELD, removeTargetField) + ) + ) + ); + processor = (ByFieldRerankProcessor) factory.create( + Map.of(), + "rerank processor", + "processor for 2nd level reranking based on provided field, This will check a nested field", + false, + config, + pipelineContext + ); + ActionListener listener = mock(ActionListener.class); + processor.rerank(response, Map.of(), listener); + + ArgumentCaptor argCaptor = ArgumentCaptor.forClass(SearchResponse.class); + + verify(listener, times(1)).onResponse(argCaptor.capture()); + SearchResponse searchResponse = argCaptor.getValue(); + + assertEquals(sampleIndexMLScorePairs.size(), searchResponse.getHits().getHits().length); + + for (int i = 0; i < searchResponse.getHits().getHits().length; i++) { + SearchHit searchHit = searchResponse.getHits().getAt(i); + Map sourceMap = searchHit.getSourceAsMap(); + + assertTrue("The source mapping now has `previous_score` entry", sourceMap.containsKey("previous_score")); + assertEquals("The first level of the map is the containing `my_field`, `info`, and `previous_score`", 3, sourceMap.size()); + + @SuppressWarnings("unchecked") + Map innerInfoMap = (Map) sourceMap.get("info"); + + assertEquals("The info map has 1 mapping", 1, innerInfoMap.size()); + assertTrue("The info map has the model as the only mapping", innerInfoMap.containsKey("model")); + + } + } + + /** + * This scenario makes sure the contents of the nested mapping have been updated by checking that a new field + * previous_score was added along with the correct values in which they came from + * and that the targetField has been deleted (along with other empty maps as a result of deleting this entry). + */ + public void testReRank_storesPreviousScoresInSourceMap_WhenResponseHasNestedField() throws IOException { + String targetField = "ml.info.score"; + boolean removeTargetField = true; + setUpValidSearchResultsWithNestedTargetValue(); + + List> previousDocIdScorePair = IntStream.range( + 0, + response.getHits().getHits().length + ) + .boxed() + .map(i -> new AbstractMap.SimpleImmutableEntry<>(response.getHits().getAt(i).docId(), response.getHits().getAt(i).getScore()) { + }) + .toList(); + + Map config = new HashMap<>( + Map.of( + RerankType.BY_FIELD.getLabel(), + new HashMap<>( + Map.of(ByFieldRerankProcessor.TARGET_FIELD, targetField, ByFieldRerankProcessor.REMOVE_TARGET_FIELD, removeTargetField) + ) + ) + ); + processor = (ByFieldRerankProcessor) factory.create( + Map.of(), + "rerank processor", + "processor for 2nd level reranking based on provided field, This will check a nested field", + false, + config, + pipelineContext + ); + ActionListener listener = mock(ActionListener.class); + processor.rerank(response, Map.of(), listener); + + ArgumentCaptor argCaptor = ArgumentCaptor.forClass(SearchResponse.class); + + verify(listener, times(1)).onResponse(argCaptor.capture()); + SearchResponse searchResponse = argCaptor.getValue(); + + for (int i = 0; i < searchResponse.getHits().getHits().length; i++) { + float currentPreviousScore = ((Number) searchResponse.getHits().getAt(i).getSourceAsMap().get("previous_score")).floatValue(); + int currentDocId = searchResponse.getHits().getAt(i).docId(); + + // to access the corresponding document id it does so by counting at 0 + float trackedPreviousScore = previousDocIdScorePair.get(currentDocId - 1).getValue(); + int trackedDocId = previousDocIdScorePair.get(currentDocId - 1).getKey(); + + assertEquals("The document Ids need to match to compare previous scores", trackedDocId, currentDocId); + assertEquals( + "The scores for the search response previoiusly need to match to the score in the source map", + trackedPreviousScore, + currentPreviousScore, + 0.01 + ); + + } + } + + /** + * This scenario makes sure the contents of the mapping have been updated by checking that a new field + * previous_score was added along with the correct values in which they came from + * and that the targetField has been deleted. + */ + public void testReRank_storesPreviousScoresInSourceMap_WhenResponseHasNonNestedField() throws IOException { + String targetField = "ml_score"; + boolean removeTargetField = true; + setUpValidSearchResultsWithNonNestedTargetValueWithDenseSourceMapping(); + + List> previousDocIdScorePair = IntStream.range( + 0, + response.getHits().getHits().length + ) + .boxed() + .map(i -> new AbstractMap.SimpleImmutableEntry<>(response.getHits().getAt(i).docId(), response.getHits().getAt(i).getScore()) { + }) + .toList(); + + Map config = new HashMap<>( + Map.of( + RerankType.BY_FIELD.getLabel(), + new HashMap<>( + Map.of(ByFieldRerankProcessor.TARGET_FIELD, targetField, ByFieldRerankProcessor.REMOVE_TARGET_FIELD, removeTargetField) + ) + ) + ); + processor = (ByFieldRerankProcessor) factory.create( + Map.of(), + "rerank processor", + "processor for 2nd level reranking based on provided field, This will check a nested field", + false, + config, + pipelineContext + ); + ActionListener listener = mock(ActionListener.class); + processor.rerank(response, Map.of(), listener); + + ArgumentCaptor argCaptor = ArgumentCaptor.forClass(SearchResponse.class); + + verify(listener, times(1)).onResponse(argCaptor.capture()); + SearchResponse searchResponse = argCaptor.getValue(); + + for (int i = 0; i < searchResponse.getHits().getHits().length; i++) { + float currentPreviousScore = ((Number) searchResponse.getHits().getAt(i).getSourceAsMap().get("previous_score")).floatValue(); + int currentDocId = searchResponse.getHits().getAt(i).docId(); + + // to access the corresponding document id it does so by counting at 0 + float trackedPreviousScore = previousDocIdScorePair.get(currentDocId - 1).getValue(); + int trackedDocId = previousDocIdScorePair.get(currentDocId - 1).getKey(); + + assertEquals("The document Ids need to match to compare previous scores", trackedDocId, currentDocId); + assertEquals( + "The scores for the search response previoiusly need to match to the score in the source map", + trackedPreviousScore, + currentPreviousScore, + 0.01 + ); + + } + } }