Skip to content
Merged
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 @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Enhancements

### Bug Fixes
* [Hybrid Query] Fix NoSuchElementException in hybrid query with sort/search_after when a shard returns no results ([#1939](https://github.com/opensearch-project/neural-search/pull/1939))
* [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))
* [Sparse ANN] Fold sparse vector tokens into the signed-short range (modulus 32768) so folded tokens are never sign-extended to a negative value when stored in short[] ([#1926](https://github.com/opensearch-project/neural-search/pull/1926))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,12 @@ private Map<Integer, Object[]> getDocIdSortFieldsMap(
// we're merging docs with normalized and combined scores. we need to have only maxHits results
Map<Integer, Object[]> docIdSortFieldMap = new HashMap<>();
final List<TopDocs> topFieldDocs = compoundTopDocs.getTopDocs();
// A shard that returned no results (e.g. one exhausted past a search_after cursor while other shards
// still have hits) arrives here with an empty top docs list but a non-zero total hits count. There are
// no sort fields to collect, so return the empty map and avoid getFirst() on an empty list. See issue #1934.
if (topFieldDocs.isEmpty()) {
return docIdSortFieldMap;
}
final boolean isSortByScore = isSortOrderByScore(sort);
final boolean isCollapseEnabled = topFieldDocs.getFirst() instanceof CollapseTopFieldDocs;
for (TopDocs topDocs : topFieldDocs) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,60 @@ public void testCombination_whenMultipleSubqueriesWithSortingEnabled_thenScoresN
}
}

public void testCombination_whenSortEnabledAndShardHasNoResults_thenNoException() {
// Reproduces https://github.com/opensearch-project/neural-search/issues/1934:
// with sort enabled (as search_after requires), a shard that contributed no results past the cursor
// arrives at the coordinator with an empty top docs list but a non-zero total hits count (see
// CompoundTopDocs constructor, which preserves totalHits while initializing an empty list when
// scoreDocs.length < 2). Before the fix, getDocIdSortFieldsMap probed topFieldDocs.getFirst()
// unconditionally and threw a bare NoSuchElementException on that empty list.
ScoreCombiner scoreCombiner = new ScoreCombiner();

Object[] sortFields1 = new Object[] { new BytesRef("value1") };
Object[] sortFields2 = new Object[] { new BytesRef("value2") };
SortField[] sortFields = new SortField[] { new SortField("_id", SortField.Type.STRING, true) };
Sort sort = new Sort(sortFields);

// First shard has results, second shard is exhausted: empty top docs list but total hits > 0.
CompoundTopDocs shardWithResults = new CompoundTopDocs(
new TotalHits(2, TotalHits.Relation.EQUAL_TO),
List.of(
new TopFieldDocs(
new TotalHits(2, TotalHits.Relation.EQUAL_TO),
new FieldDoc[] { new FieldDoc(1, 0.5f, sortFields1), new FieldDoc(2, 0.3f, sortFields2) },
sortFields
)
),
true,
SEARCH_SHARD
);
CompoundTopDocs exhaustedShard = new CompoundTopDocs(
new TotalHits(3, TotalHits.Relation.EQUAL_TO),
Collections.emptyList(),
true,
SEARCH_SHARD
);
final List<CompoundTopDocs> queryTopDocs = List.of(shardWithResults, exhaustedShard);

// Must not throw NoSuchElementException.
scoreCombiner.combineScores(
CombineScoresDto.builder()
.queryTopDocs(queryTopDocs)
.scoreCombinationTechnique(ScoreCombinationFactory.DEFAULT_METHOD)
.querySearchResults(Collections.emptyList())
.sort(sort)
.isSingleShard(false)
.build()
);

// The shard with results is combined as usual.
assertEquals(2, queryTopDocs.get(0).getScoreDocs().size());
// The exhausted shard yields no score docs but keeps its total hit count (search_after semantics:
// total_hits is pagination-independent, so a shard past its cursor still reports its full match count).
assertTrue(queryTopDocs.get(1).getScoreDocs().isEmpty());
assertEquals(3, queryTopDocs.get(1).getTotalHits().value());
}

public void testCombination_whenMultipleSubqueriesResultsWithMinScore_thenScoresCombined() {
ScoreCombiner scoreCombiner = new ScoreCombiner();

Expand Down
Loading