1313import java .util .Map ;
1414import java .util .Objects ;
1515
16- import lombok .extern .log4j .Log4j2 ;
1716import org .opensearch .action .search .MultiSearchRequest ;
1817import org .opensearch .action .search .MultiSearchResponse ;
1918import org .opensearch .action .search .SearchRequest ;
4342 * normalization + {@code arithmetic_mean} combination (the caller rejects other techniques at rewrite for now).
4443 */
4544@ NoArgsConstructor (access = AccessLevel .PRIVATE )
46- @ Log4j2
4745final class HybridFusionOrchestrator {
4846
4947 private static final ScoreCombinationFactory SCORE_COMBINATION_FACTORY = new ScoreCombinationFactory ();
@@ -57,6 +55,11 @@ final class HybridFusionOrchestrator {
5755 * request/response processors once per leg (redundant, and incorrect for processors like {@code rerank} that expect
5856 * request context absent from an id-only leg). The outer fused request still carries the pipeline, so top-level
5957 * processors run exactly once.
58+ *
59+ * <p>Legs disable partial results ({@code allowPartialSearchResults(false)}): fused relevance is computed across a
60+ * leg's full window, so a leg silently truncated by a down shard would be as corrupting as a fully failed leg.
61+ * Failing the leg instead lets {@link #groupLegHits} fail the whole request fast, rather than fusing over incomplete
62+ * data — this is the leg-level half of the fail-fast contract.
6063 */
6164 static MultiSearchRequest buildLegMultiSearch (SearchRequest request , List <QueryBuilder > legs , int windowSize ) {
6265 MultiSearchRequest multiSearchRequest = new MultiSearchRequest ();
@@ -70,6 +73,7 @@ static MultiSearchRequest buildLegMultiSearch(SearchRequest request, List<QueryB
7073 new SearchRequest (request .indices ()).indicesOptions (request .indicesOptions ())
7174 .source (legSource )
7275 .pipeline (SearchPipelineService .NOOP_PIPELINE_ID )
76+ .allowPartialSearchResults (false )
7377 );
7478 }
7579 return multiSearchRequest ;
@@ -109,13 +113,15 @@ static QueryBuilder buildFusedQuery(
109113 } else {
110114 topOnly = true ; // track_total_hits:false -> plain top-K, no Tail
111115 }
112- List <QueryBuilder > tail = topOnly ? List .of () : survivingLegQueries (legs , legHits );
116+ List <QueryBuilder > tail = topOnly ? List .of () : legQueriesForTail (legs , legHits );
113117 return new HybridFusionQuery (ranked .ids (), ranked .scores (), tail );
114118 }
115119
116120 /**
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.
121+ * Reduce the raw MultiSearch items into a per-leg array of hits (one item per leg). Fail fast on ANY leg failure:
122+ * fused relevance is computed across all legs (min_max normalization + combination), so a dropped leg would silently
123+ * change the ranking function — a partial fused result is semantically different, not just smaller. So a single
124+ * failed sub-search fails the whole request rather than degrading to the surviving legs.
119125 */
120126 private static SearchHit [][] groupLegHits (MultiSearchResponse .Item [] items , int legCount ) {
121127 if (items .length != legCount ) {
@@ -124,50 +130,31 @@ private static SearchHit[][] groupLegHits(MultiSearchResponse.Item[] items, int
124130 );
125131 }
126132 SearchHit [][] legHits = new SearchHit [legCount ][];
127- int survivingLegs = 0 ;
128133 for (int leg = 0 ; leg < legCount ; leg ++) {
129134 MultiSearchResponse .Item item = items [leg ];
130135 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+ throw new IllegalStateException (
137+ String .format (Locale .ROOT , "[hybrid] fused-mode sub-query %d failed: %s" , leg , item .getFailureMessage ()),
138+ item .getFailure ()
139+ );
136140 }
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- );
141+ legHits [leg ] = item .getResponse ().getHits ().getHits ();
145142 }
146143 return legHits ;
147144 }
148145
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-
158146 /**
159147 * Fuse via the shared {@link CoordinatorScoreFusion} core (min_max + arithmetic_mean), then rank by fused score and
160148 * 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.
149+ * the shared core consumes; a leg that matched nothing contributes an empty map (groupLegHits fails fast on failures,
150+ * so every slot is non-null).
162151 */
163152 private static RankedDocs computeRankedDocs (SearchHit [][] legHits , FusionSpec fusion , int windowSize ) {
164153 List <Map <String , Float >> legRawScores = new ArrayList <>(legHits .length );
165154 for (SearchHit [] hits : legHits ) {
166155 Map <String , Float > byId = new LinkedHashMap <>();
167- if (Objects .nonNull (hits )) {
168- for (SearchHit hit : hits ) {
169- byId .put (hit .getId (), hit .getScore ());
170- }
156+ for (SearchHit hit : hits ) {
157+ byId .put (hit .getId (), hit .getScore ());
171158 }
172159 legRawScores .add (byId );
173160 }
@@ -231,28 +218,25 @@ private static RankedDocs toRankedDocs(Map<String, Float> scoresById, int window
231218 return new RankedDocs (ids , scores );
232219 }
233220
234- /** The sub-query legs restricted to those that survived (non-null hits slot); used for the Tail so a failed leg is
235- * not re-executed in the self-erased query (graceful degradation). */
236- private static List <QueryBuilder > survivingLegQueries (List <QueryBuilder > legs , SearchHit [][] legHits ) {
237- // groupLegHits guarantees legHits.length == legs.size(), so a surviving leg is exactly a non-null hits slot.
238- List <QueryBuilder > surviving = new ArrayList <>(legs .size ());
221+ /** The sub-query legs in their Tail form. groupLegHits fails fast on any leg failure, so every leg is present here
222+ * (legHits.length == legs.size(), no null slots). A kNN/neural leg's match set IS its returned top-k, so it is
223+ * materialized as an {@link IdsQueryBuilder} of its already-retrieved ids rather than re-walking the HNSW graph in
224+ * the Tail purely to count; other legs are used as-is. */
225+ private static List <QueryBuilder > legQueriesForTail (List <QueryBuilder > legs , SearchHit [][] legHits ) {
226+ List <QueryBuilder > tail = new ArrayList <>(legs .size ());
239227 for (int legIndex = 0 ; legIndex < legs .size (); legIndex ++) {
240- if (Objects .nonNull (legHits [legIndex ])) {
241- QueryBuilder leg = legs .get (legIndex );
242- // A kNN/neural leg's match set IS its returned top-k — re-running it in the Tail would walk the HNSW
243- // graph again purely to count. Materialize such legs as their already-retrieved ids instead.
244- if (isMaterializableLeg (leg )) {
245- IdsQueryBuilder ids = new IdsQueryBuilder ();
246- for (SearchHit hit : legHits [legIndex ]) {
247- ids .addIds (hit .getId ());
248- }
249- surviving .add (ids );
250- } else {
251- surviving .add (leg );
228+ QueryBuilder leg = legs .get (legIndex );
229+ if (isMaterializableLeg (leg )) {
230+ IdsQueryBuilder ids = new IdsQueryBuilder ();
231+ for (SearchHit hit : legHits [legIndex ]) {
232+ ids .addIds (hit .getId ());
252233 }
234+ tail .add (ids );
235+ } else {
236+ tail .add (leg );
253237 }
254238 }
255- return surviving ;
239+ return tail ;
256240 }
257241
258242 /** Legs whose Lucene match set is their own top-k (re-running them in the Tail = a redundant ANN pass). */
0 commit comments