Skip to content

Commit e16cfa3

Browse files
Enabling fusion end-to-end for min_max and am
Signed-off-by: Martin Gaievski <gaievski@amazon.com>
1 parent 49ee53f commit e16cfa3

12 files changed

Lines changed: 1659 additions & 22 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88
### Features
99

1010
### Enhancements
11+
* In-query fusion in hybrid search. Implement base classes and enable fusion (min_max and arithmetic mean) ([#1933](https://github.com/opensearch-project/neural-search/pull/1933))
1112

1213
### Bug Fixes
1314
* [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))

src/main/java/org/opensearch/neuralsearch/common/MinClusterVersionUtil.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public final class MinClusterVersionUtil {
3232
public static final Version MINIMAL_SUPPORTED_VERSION_METRICS_STATS = Version.V_3_3_0;
3333
private static final Version MINIMAL_SUPPORTED_VERSION_NEURAL_KNN_QUERY_BUILDER = Version.V_3_0_0;
3434
private static final Version MINIMAL_SUPPORTED_VERSION_AGENTIC_EMBEDDING_MODEL_ID = Version.V_3_6_0;
35+
public static final Version MINIMAL_SUPPORTED_VERSION_FUSED_MODE_IN_HYBRID_QUERY = Version.V_3_8_0;
3536

3637
// Constant for neural_knn_query version check
3738
public static final String NEURAL_KNN_QUERY = "neural_knn_query";
@@ -85,6 +86,19 @@ public static boolean isVersionOnOrAfterMinReqVersionForNeuralKNNQueryText(Versi
8586
return version.onOrAfter(MINIMAL_SUPPORTED_VERSION_NEURAL_ORIGINAL_QUERY_TEXT);
8687
}
8788

89+
/**
90+
* Checks if the version from StreamInput/StreamOutput is on or after the minimum required version for the fused
91+
* (resolver) mode in the hybrid query. Use this (not the cluster-min-version variant) for wire read/write gating so
92+
* the format matches the negotiated version of the specific peer stream — see CR-290524846 for the mixed-version
93+
* bug the cluster-based check causes in a coordinator/worker split.
94+
*
95+
* @param version The version to check
96+
* @return true if the version is on or after the minimum required version
97+
*/
98+
public static boolean isVersionOnOrAfterMinReqVersionForFusedModeInHybridQuery(Version version) {
99+
return version.onOrAfter(MINIMAL_SUPPORTED_VERSION_FUSED_MODE_IN_HYBRID_QUERY);
100+
}
101+
88102
/**
89103
* Checks if the cluster min version is on or after the minimum required version for semantic field type
90104
*
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package org.opensearch.neuralsearch.query;
6+
7+
import java.util.ArrayList;
8+
import java.util.Comparator;
9+
import java.util.HashMap;
10+
import java.util.LinkedHashMap;
11+
import java.util.List;
12+
import java.util.Locale;
13+
import java.util.Map;
14+
import java.util.Objects;
15+
16+
import lombok.extern.log4j.Log4j2;
17+
import org.opensearch.action.search.MultiSearchRequest;
18+
import org.opensearch.action.search.MultiSearchResponse;
19+
import org.opensearch.action.search.SearchRequest;
20+
import org.opensearch.index.query.IdsQueryBuilder;
21+
import org.opensearch.index.query.InnerHitContextBuilder;
22+
import org.opensearch.index.query.MatchNoneQueryBuilder;
23+
import org.opensearch.index.query.QueryBuilder;
24+
import org.opensearch.neuralsearch.fusion.CoordinatorScoreFusion;
25+
import org.opensearch.neuralsearch.processor.combination.ScoreCombinationFactory;
26+
import org.opensearch.neuralsearch.processor.combination.ScoreCombinationTechnique;
27+
import org.opensearch.neuralsearch.processor.combination.ScoreCombinationUtil;
28+
import org.opensearch.search.SearchHit;
29+
import org.opensearch.search.builder.SearchSourceBuilder;
30+
import org.opensearch.search.pipeline.SearchPipelineService;
31+
32+
import lombok.AccessLevel;
33+
import lombok.NoArgsConstructor;
34+
35+
/**
36+
* Coordinator-side machinery for the resolver (fused) mode: fan the sub-query legs out as a parallel {@code MultiSearch},
37+
* then fuse the leg hits into the standard query the {@code hybrid} query self-erases into ({@link HybridFusionQuery},
38+
* or {@code match_none} when nothing fused). All methods are static and take the {@link SearchRequest} /
39+
* {@link MultiSearchResponse} explicitly so the class holds no state.
40+
*
41+
* <p>Fusion arithmetic is NOT reimplemented here — it delegates to {@link CoordinatorScoreFusion}, the shared core that
42+
* classic hybrid also calls, so fused-mode relevance matches classic for the same hit set. Current scope: {@code min_max}
43+
* normalization + {@code arithmetic_mean} combination (the caller rejects other techniques at rewrite for now).
44+
*/
45+
@NoArgsConstructor(access = AccessLevel.PRIVATE)
46+
@Log4j2
47+
final class HybridFusionOrchestrator {
48+
49+
private static final ScoreCombinationFactory SCORE_COMBINATION_FACTORY = new ScoreCombinationFactory();
50+
51+
/**
52+
* Build the leg MultiSearch: one standalone search per sub-query, each reduced to the global top-{@code windowSize}.
53+
* Id-only (no {@code _source}); totals disabled (the Tail supplies the full-match-set count when needed).
54+
*
55+
* <p>Each leg is pinned to the no-op search pipeline ({@code _none}). Otherwise a leg — a plain {@link SearchRequest}
56+
* with no explicit pipeline — would inherit the index's {@code index.search.default_pipeline} and re-run its
57+
* request/response processors once per leg (redundant, and incorrect for processors like {@code rerank} that expect
58+
* request context absent from an id-only leg). The outer fused request still carries the pipeline, so top-level
59+
* processors run exactly once.
60+
*/
61+
static MultiSearchRequest buildLegMultiSearch(SearchRequest request, List<QueryBuilder> legs, int windowSize) {
62+
MultiSearchRequest multiSearchRequest = new MultiSearchRequest();
63+
for (QueryBuilder leg : legs) {
64+
SearchSourceBuilder legSource = new SearchSourceBuilder().query(leg)
65+
.size(windowSize)
66+
.from(0)
67+
.fetchSource(false)
68+
.trackTotalHits(false);
69+
multiSearchRequest.add(
70+
new SearchRequest(request.indices()).indicesOptions(request.indicesOptions())
71+
.source(legSource)
72+
.pipeline(SearchPipelineService.NOOP_PIPELINE_ID)
73+
);
74+
}
75+
return multiSearchRequest;
76+
}
77+
78+
/**
79+
* Fuse the leg results into the standard query the fused-mode hybrid self-erases into — a {@link HybridFusionQuery}
80+
* (Top + conditional Tail), or a {@link MatchNoneQueryBuilder} when nothing fused. Pure: returns the query and
81+
* mutates nothing.
82+
*
83+
* <p>The Tail (non-scoring {@code bool{should: legs}} surfacing the full match set) is included only when the request
84+
* needs it (aggregations / explain / profile / highlight / leg inner_hits / totals beyond the window) and this
85+
* marker is the whole query. A nested fused query is always Top-only, so an enclosing filter intersects the fused
86+
* window at the query phase (fuse-then-filter).
87+
*/
88+
static QueryBuilder buildFusedQuery(
89+
SearchSourceBuilder source,
90+
MultiSearchResponse multiSearchResponse,
91+
List<QueryBuilder> legs,
92+
FusionSpec fusion,
93+
int windowSize,
94+
boolean topLevel
95+
) {
96+
MultiSearchResponse.Item[] items = multiSearchResponse.getResponses();
97+
SearchHit[][] legHits = groupLegHits(items, legs.size());
98+
RankedDocs ranked = computeRankedDocs(legHits, fusion, windowSize);
99+
if (ranked.ids().length == 0) {
100+
return new MatchNoneQueryBuilder();
101+
}
102+
boolean topOnly;
103+
if (topLevel == false) {
104+
topOnly = true; // nested: enclosing filter intersects at the query phase
105+
} else if (needsExecutionTail(source) || legsHaveInnerHits(legs)) {
106+
topOnly = false; // aggregations / explain / profile / highlight / leg inner_hits need the legs IN the query
107+
} else if (wantsTotalsBeyondWindow(source, ranked.ids().length)) {
108+
topOnly = false; // keep the Tail for an accurate index-wide count
109+
} else {
110+
topOnly = true; // track_total_hits:false -> plain top-K, no Tail
111+
}
112+
List<QueryBuilder> tail = topOnly ? List.of() : survivingLegQueries(legs, legHits);
113+
return new HybridFusionQuery(ranked.ids(), ranked.scores(), tail);
114+
}
115+
116+
/**
117+
* Reduce the raw MultiSearch items into a per-leg array of hits (one item per leg). Graceful per-leg failure: a
118+
* failed sub-search sets its slot to null and is skipped by fusion; only when ALL legs failed do we throw.
119+
*/
120+
private static SearchHit[][] groupLegHits(MultiSearchResponse.Item[] items, int legCount) {
121+
if (items.length != legCount) {
122+
throw new IllegalStateException(
123+
String.format(Locale.ROOT, "[hybrid] expected %d leg sub-search responses but got %d", legCount, items.length)
124+
);
125+
}
126+
SearchHit[][] legHits = new SearchHit[legCount][];
127+
int survivingLegs = 0;
128+
for (int leg = 0; leg < legCount; leg++) {
129+
MultiSearchResponse.Item item = items[leg];
130+
if (item.isFailure()) {
131+
log.warn("[hybrid] fused-mode sub-query {} dropped: {}", leg, item.getFailureMessage());
132+
legHits[leg] = null;
133+
} else {
134+
legHits[leg] = item.getResponse().getHits().getHits();
135+
survivingLegs++;
136+
}
137+
}
138+
if (survivingLegs == 0) {
139+
MultiSearchResponse.Item firstFailure = firstFailure(items);
140+
throw new IllegalStateException(
141+
"[hybrid] all fused-mode sub-queries failed"
142+
+ (Objects.isNull(firstFailure) ? "" : ": " + firstFailure.getFailureMessage()),
143+
Objects.isNull(firstFailure) ? null : firstFailure.getFailure()
144+
);
145+
}
146+
return legHits;
147+
}
148+
149+
private static MultiSearchResponse.Item firstFailure(MultiSearchResponse.Item[] items) {
150+
for (MultiSearchResponse.Item item : items) {
151+
if (item.isFailure()) {
152+
return item;
153+
}
154+
}
155+
return null;
156+
}
157+
158+
/**
159+
* Fuse via the shared {@link CoordinatorScoreFusion} core (min_max + arithmetic_mean), then rank by fused score and
160+
* cut to the window. Converts the coordinator's {@code SearchHit[][]} view into the {@code _id}-keyed per-leg maps
161+
* the shared core consumes; a dropped (null) leg contributes an empty map.
162+
*/
163+
private static RankedDocs computeRankedDocs(SearchHit[][] legHits, FusionSpec fusion, int windowSize) {
164+
List<Map<String, Float>> legRawScores = new ArrayList<>(legHits.length);
165+
for (SearchHit[] hits : legHits) {
166+
Map<String, Float> byId = new LinkedHashMap<>();
167+
if (Objects.nonNull(hits)) {
168+
for (SearchHit hit : hits) {
169+
byId.put(hit.getId(), hit.getScore());
170+
}
171+
}
172+
legRawScores.add(byId);
173+
}
174+
ScoreCombinationTechnique combination = SCORE_COMBINATION_FACTORY.createCombination(
175+
fusion.combinationTechnique(),
176+
weightsParams(fusion.weights())
177+
);
178+
Map<String, Float> combined = CoordinatorScoreFusion.fuseMinMax(legRawScores, combination);
179+
return toRankedDocs(combined, windowSize);
180+
}
181+
182+
private static Map<String, Object> weightsParams(float[] weights) {
183+
if (Objects.isNull(weights) || weights.length == 0) {
184+
return Map.of();
185+
}
186+
List<Double> weightsList = new ArrayList<>(weights.length);
187+
for (float weight : weights) {
188+
weightsList.add((double) weight);
189+
}
190+
return Map.of(ScoreCombinationUtil.PARAM_NAME_WEIGHTS, weightsList);
191+
}
192+
193+
private static RankedDocs toRankedDocs(Map<String, Float> scoresById, int windowSize) {
194+
List<Map.Entry<String, Float>> ranked = new ArrayList<>(scoresById.entrySet());
195+
ranked.sort(Comparator.<Map.Entry<String, Float>>comparingDouble(e -> -e.getValue()).thenComparing(Map.Entry::getKey));
196+
if (ranked.size() > windowSize) {
197+
ranked = ranked.subList(0, windowSize);
198+
}
199+
String[] ids = new String[ranked.size()];
200+
float[] scores = new float[ranked.size()];
201+
for (int i = 0; i < ranked.size(); i++) {
202+
ids[i] = ranked.get(i).getKey();
203+
scores[i] = ranked.get(i).getValue();
204+
}
205+
return new RankedDocs(ids, scores);
206+
}
207+
208+
/** The sub-query legs restricted to those that survived (non-null hits slot); used for the Tail so a failed leg is
209+
* not re-executed in the self-erased query (graceful degradation). */
210+
private static List<QueryBuilder> survivingLegQueries(List<QueryBuilder> legs, SearchHit[][] legHits) {
211+
List<QueryBuilder> surviving = new ArrayList<>(legs.size());
212+
for (int legIndex = 0; legIndex < legs.size(); legIndex++) {
213+
if (legIndex >= legHits.length || Objects.nonNull(legHits[legIndex])) {
214+
QueryBuilder leg = legs.get(legIndex);
215+
// A kNN/neural leg's match set IS its returned top-k — re-running it in the Tail would walk the HNSW
216+
// graph again purely to count. Materialize such legs as their already-retrieved ids instead.
217+
if (isMaterializableLeg(leg) && legIndex < legHits.length && Objects.nonNull(legHits[legIndex])) {
218+
IdsQueryBuilder ids = new IdsQueryBuilder();
219+
for (SearchHit hit : legHits[legIndex]) {
220+
ids.addIds(hit.getId());
221+
}
222+
surviving.add(ids);
223+
} else {
224+
surviving.add(leg);
225+
}
226+
}
227+
}
228+
return surviving;
229+
}
230+
231+
/** Legs whose Lucene match set is their own top-k (re-running them in the Tail = a redundant ANN pass). */
232+
private static boolean isMaterializableLeg(QueryBuilder leg) {
233+
String name = leg.getWriteableName();
234+
return "knn".equals(name) || "neural".equals(name) || "neural_knn".equals(name);
235+
}
236+
237+
private static boolean needsExecutionTail(SearchSourceBuilder source) {
238+
return Objects.nonNull(source)
239+
&& (Objects.nonNull(source.aggregations())
240+
|| Boolean.TRUE.equals(source.explain())
241+
|| source.profile()
242+
|| Objects.nonNull(source.highlighter()));
243+
}
244+
245+
private static boolean legsHaveInnerHits(List<QueryBuilder> legs) {
246+
Map<String, InnerHitContextBuilder> innerHits = new HashMap<>();
247+
for (QueryBuilder leg : legs) {
248+
InnerHitContextBuilder.extractInnerHits(leg, innerHits);
249+
}
250+
return innerHits.isEmpty() == false;
251+
}
252+
253+
private static boolean wantsTotalsBeyondWindow(SearchSourceBuilder source, int numRankedDocs) {
254+
if (Objects.isNull(source)) {
255+
return true;
256+
}
257+
Integer trackTotalHitsUpTo = source.trackTotalHitsUpTo();
258+
return Objects.isNull(trackTotalHitsUpTo) || trackTotalHitsUpTo > numRankedDocs;
259+
}
260+
261+
private record RankedDocs(String[] ids, float[] scores) {
262+
}
263+
}

0 commit comments

Comments
 (0)