Skip to content

Implement base classes and enable fusion for hybrid query (limited to min_max and arithmetic mean) - #1933

Merged
martin-gaievski merged 7 commits into
opensearch-project:feature/fusion-hybrid-queryfrom
martin-gaievski:feature/fusion-hybrid-query
Aug 13, 2026
Merged

Implement base classes and enable fusion for hybrid query (limited to min_max and arithmetic mean)#1933
martin-gaievski merged 7 commits into
opensearch-project:feature/fusion-hybrid-queryfrom
martin-gaievski:feature/fusion-hybrid-query

Conversation

@martin-gaievski

@martin-gaievski martin-gaievski commented Aug 6, 2026

Copy link
Copy Markdown
Member

Description

Initial scope for resolver in hybrid query, targeting feature branch:

  • foundation: fusion mode entry point, fan-out (round 1) and tail (round 2), self-erase query, utility classes for fusion spec
  • refactor math classes for both classic and fusion into sharable utils
  • one end-to-end test, UT

After this PR following query will work:

  1. Inlined fusion configuration, including explicit window and weights params
  {
    "hybrid": {
      "queries": [ ... ],
      "fusion": {
        "normalization": { "technique": "min_max" },
        "combination": { "technique": "arithmetic_mean", "parameters": { "weights": [0.7, 0.3] } },
        "window_size": 100
      }
    }
  }
  1. Read config from pipeline (zero migration path).
  { "hybrid": { "queries": [ ... ], "fusion": {} } }
  { "hybrid": { "queries": [ ... ], "fusion": "pipeline" } }
  { "hybrid": { "queries": [ ... ], "fusion": { "source": "pipeline" } } }

these require an attached pipeline resolvable via inline body / ?search_pipeline= / index.search.default_pipeline

Intentional limitation:

  1. Rejected at parse (HTTP 400)
  • "fusion": true / any non-string, non-object
  • "fusion": "anything-but-pipeline" (string form must be "pipeline")
  • { "source": "pipeline", "normalization": {…} } — contradiction (read-from-pipeline vs inline)
  • unknown key: { "foo": 1 } (only source|normalization|combination|window_size allowed)
  • "window_size": 0 or negative
  • fusion together with pagination_depth
  1. Parses, but fails fast at rewrite (HTTP 400)
  • Any technique other than min_max + arithmetic_mean: z_score, l2, geometric_mean, harmonic_mean, RRF (combination.technique: rrf). These parse fine but requireSupportedTechniques rejects them until a later PR.
  • fusion present but no resolvable config (no inline techniques, no pipeline, no index default).
  • window_size > index.max_result_window (the check added this session).

There will be follow up PRs with:

  • more techniques under fusion + extended test coverage, this PR kept smaller intentionally to easier review.
  • hybrid{fusion} nested inside bool/dis_max/function_score. rewrite computes a topLevel flag and would self-erase Top-only when nested, but it's not validated end-to-end yet.
  • fetch-time features (aggregations correctness, explain/profiler, PIT, gRPC) are coming later.

Related Issues

Resolves #1930

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

Signed-off-by: Martin Gaievski <gaievski@amazon.com>
Signed-off-by: Martin Gaievski <gaievski@amazon.com>
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit c95d889)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
📝 TODO sections

🔀 Multiple PR themes

Sub-PR theme: Extract MinMaxScoreNormalizer shared core (refactor)

Relevant files:

  • src/main/java/org/opensearch/neuralsearch/processor/normalization/MinMaxScoreNormalizationTechnique.java
  • src/main/java/org/opensearch/neuralsearch/processor/normalization/MinMaxScoreNormalizer.java
  • src/test/java/org/opensearch/neuralsearch/processor/normalization/MinMaxScoreNormalizerTests.java

Sub-PR theme: Fusion config parsing and pipeline resolution

Relevant files:

  • src/main/java/org/opensearch/neuralsearch/query/FusionSpec.java
  • src/main/java/org/opensearch/neuralsearch/query/FusionConfigResolver.java
  • src/test/java/org/opensearch/neuralsearch/query/FusionSpecTests.java
  • src/test/java/org/opensearch/neuralsearch/query/FusionConfigResolverTests.java

Sub-PR theme: Coordinator fusion execution path (fan-out, self-erase, orchestrator)

Relevant files:

  • src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java
  • src/main/java/org/opensearch/neuralsearch/query/HybridFusionQueryBuilder.java
  • src/main/java/org/opensearch/neuralsearch/query/HybridFusionOrchestrator.java
  • src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java
  • src/test/java/org/opensearch/neuralsearch/query/HybridQueryBuilderTests.java
  • src/test/java/org/opensearch/neuralsearch/query/HybridFusionOrchestratorTests.java
  • src/test/java/org/opensearch/neuralsearch/query/HybridFusionQueryBuilderTests.java
  • src/test/java/org/opensearch/neuralsearch/query/HybridQueryFusedModeIT.java

⚡ Recommended focus areas for review

Incorrect max seed for negative scores

max is seeded with Float.MIN_VALUE (≈1.4e-45, a small positive), not -Float.MAX_VALUE. If a leg's raw scores are all negative (possible with function_score/script_score legs), max will remain at Float.MIN_VALUE, giving a wrong (positive) max and skewed normalization. The comment claims this mirrors classic seeding — verify that; if classic has the same issue, this at least perpetuates it, but the coordinator path should use -Float.MAX_VALUE for correctness with negative scores.

float min = Float.MAX_VALUE;
float max = Float.MIN_VALUE;
for (float raw : legRawScores.get(leg).values()) {
    min = Math.min(min, raw);
    max = Math.max(max, raw);
}
Reference-identity check can misclassify top-level query

The topLevel decision relies on searchRequest.source().query() == this (reference identity). Any request-rewrite layer that clones the top-level query before coordinator rewrite will cause a genuinely top-level fused query to be misclassified as nested, silently dropping the Tail — resulting in incorrect total_hits and aggregations (only fused-window docs counted). The code has a TODO acknowledging this, but this is a correctness footgun that depends on an invariant not enforced anywhere. Consider threading the top-level signal explicitly (e.g., via context) rather than shipping with a fragile identity check.

// reference-identity check: it holds only because the coordinator rewrite runs on the exact instance still parked
// at source().query(). If a request-rewrite layer ever clones the query before this point, a genuinely top-level
// query would compare != and be misclassified as nested -> forced Top-only -> the Tail is silently dropped, so
// scores and top hits stay correct but total_hits and aggregations would be computed over just the fused window.
// TODO: once nested fusion is wired and validated end-to-end, thread the top-level/nesting signal down explicitly
// (e.g. via the coordinator context) so it survives cloning instead of relying on this invariant.
boolean topLevel = Objects.nonNull(searchRequest.source()) && searchRequest.source().query() == this;
Zero score for non-matching legs biases arithmetic mean

computeRankedDocs fills a float[legCount] initialized to 0.0 and only sets values for legs that matched. Because ArithmeticMeanScoreCombinationTechnique counts any score >= 0.0 as participating, a doc matching only one of N legs is averaged with (N-1) zeros in the denominator, dragging its fused score down heavily vs. a doc that matched all legs. The javadoc claims parity with classic, but classic feeds normalized scores where MIN_SCORE = 0.001 for matched-but-clipped, so a 0.0 slot there truly means "unmatched" and is treated as such; here the same 0.0 can be an unmatched slot or a legitimate normalized value. Verify combiner semantics — if non-matching should be excluded, use a sentinel (e.g., -1.0f) instead of 0.0.

private static RankedDocs computeRankedDocs(SearchHit[][] legHits, FusionSpec fusion, int windowSize) {
    List<Map<String, Float>> legRawScores = new ArrayList<>(legHits.length);
    for (SearchHit[] hits : legHits) {
        Map<String, Float> byId = new LinkedHashMap<>();
        for (SearchHit hit : hits) {
            byId.put(hit.getId(), hit.getScore());
        }
        legRawScores.add(byId);
    }
    ScoreCombinationTechnique combination = SCORE_COMBINATION_FACTORY.createCombination(
        fusion.combinationTechnique(),
        weightsParams(fusion.weights())
    );
    Map<String, Float> combined = CoordinatorScoreFusion.fuseMinMax(legRawScores, combination);
    return toRankedDocs(combined, windowSize);
}

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR Code Suggestions ✨

Latest suggestions up to c95d889

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix incorrect seed for max score

Float.MIN_VALUE is the smallest positive value (~1.4e-45), not the most negative
float. If all raw scores for a leg are negative (possible with function_score,
script_score, or techniques that emit negative scores), max will incorrectly remain
at Float.MIN_VALUE instead of the actual maximum. Use -Float.MAX_VALUE (or
Float.NEGATIVE_INFINITY) as the seed for max.

src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java [63-71]

 for (int leg = 0; leg < legCount; leg++) {
     float min = Float.MAX_VALUE;
-    float max = Float.MIN_VALUE;
+    float max = -Float.MAX_VALUE;
     for (float raw : legRawScores.get(leg).values()) {
         min = Math.min(min, raw);
         max = Math.max(max, raw);
     }
     minPerLeg[leg] = min;
     maxPerLeg[leg] = max;
 }
Suggestion importance[1-10]: 5

__

Why: The suggestion correctly notes that Float.MIN_VALUE is the smallest positive float, which would produce incorrect max for all-negative scores. However, the PR's stated intent is to exactly mirror classic hybrid's seeding (see comment about classic's Float.MAX_VALUE/Float.MIN_VALUE seeding), so changing this diverges from the parity contract the PR is explicitly enforcing. Still a valid concern worth surfacing.

Low
General
Use safer descending score comparator

Sorting by -e.getValue() produces incorrect ordering when scores are NaN or when
negative zero is present, and negating floats before comparison can lose precision
at extremes. Use Comparator.comparingDouble in reverse (or Float::compare reversed)
to get correct descending order and NaN-safe behavior.

src/main/java/org/opensearch/neuralsearch/query/HybridFusionOrchestrator.java [207-208]

 List<Map.Entry<String, Float>> ranked = new ArrayList<>(scoresById.entrySet());
-ranked.sort(Comparator.<Map.Entry<String, Float>>comparingDouble(e -> -e.getValue()).thenComparing(Map.Entry::getKey));
+ranked.sort(Comparator.<Map.Entry<String, Float>>comparingDouble(Map.Entry::getValue).reversed().thenComparing(Map.Entry::getKey));
Suggestion importance[1-10]: 4

__

Why: Using reversed() is a cleaner, more idiomatic approach for descending sort and avoids potential edge cases with negation. Moderate readability/correctness improvement, though the practical impact for typical fused scores is small.

Low
Validate weight element types explicitly

A ClassCastException will be thrown with an unhelpful stack trace if a user supplies
non-numeric weight values (e.g. strings). Validate the element type explicitly and
throw a clear IllegalArgumentException describing the offending value so users get
an actionable error rather than an internal cast failure.

src/main/java/org/opensearch/neuralsearch/query/FusionSpec.java [179-184]

 List<Object> raw = (List<Object>) parameters.get(WEIGHTS_KEY);
 float[] weights = new float[raw.size()];
 for (int i = 0; i < raw.size(); i++) {
-    weights[i] = ((Number) raw.get(i)).floatValue();
+    Object value = raw.get(i);
+    if ((value instanceof Number) == false) {
+        throw new IllegalArgumentException(
+            String.format(Locale.ROOT, "weights[%d] must be a number, got [%s]", i, value)
+        );
+    }
+    weights[i] = ((Number) value).floatValue();
 }
 return weights;
Suggestion importance[1-10]: 3

__

Why: Minor error-handling improvement to produce clearer error messages for non-numeric weights instead of a bare ClassCastException. Low-to-moderate impact.

Low
Reject non-string scalar fusion values

Reading parser.text() unconditionally will accept any scalar token (numbers,
booleans) and coerce them to a string, producing a confusing error message like
"must be [pipeline], got [true]" for a boolean value. Guard on token == VALUE_STRING
first and throw a targeted parsing exception for non-string scalars to give clearer
feedback.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [330-349]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
     // `"fusion": "pipeline"` == `"fusion": { "source": "pipeline" }`. Presence still
     // enables the resolver; the config comes from the attached pipeline.
+    if (token != XContentParser.Token.VALUE_STRING) {
+        throw new ParsingException(
+            parser.getTokenLocation(),
+            String.format(Locale.ROOT, "[%s] query [%s] as a scalar must be a string", NAME, FUSION_FIELD.getPreferredName())
+        );
+    }
     String source = parser.text();
Suggestion importance[1-10]: 3

__

Why: Minor validation improvement for clearer error messages on non-string scalar fusion values. Low impact since the existing error already conveys the problem.

Low

Previous suggestions

Suggestions up to commit 4f82009
CategorySuggestion                                                                                                                                    Impact
General
Validate scalar fusion token type

This branch handles the fusion field as a scalar value, but it is inside the else if
(token.isValue())-style block reached from a non-object token. Calling parser.text()
on a non-string token (e.g. a number or boolean) can succeed silently and yield
unexpected content. Verify the token is a string first, or explicitly reject
non-string scalars, to avoid ambiguous parse errors.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [330-349]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
     // `"fusion": "pipeline"` == `"fusion": { "source": "pipeline" }`. Presence still
     // enables the resolver; the config comes from the attached pipeline.
+    if (token != XContentParser.Token.VALUE_STRING) {
+        throw new ParsingException(
+            parser.getTokenLocation(),
+            String.format(Locale.ROOT, "[%s] query [%s] must be a string or object", NAME, FUSION_FIELD.getPreferredName())
+        );
+    }
     String source = parser.text();
Suggestion importance[1-10]: 3

__

Why: Minor input validation improvement; parser.text() on non-string scalars may succeed by coercion. The impact is low since a non-"pipeline" value would be rejected downstream anyway with a slightly less specific error.

Low
Preserve fusion presence on wire read

When the presence boolean is read as false, fusion is left null (correct), but the
short-circuit combines version-gate and readBoolean() in one expression: if the
version gate is true but the boolean is false, no map is read (correct). However, if
in.readMap() returns null or an empty map, the resolver behavior differs from a
genuinely absent fusion. Consider explicitly normalizing an empty/null read map to
preserve semantics on the wire.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [118-122]

-// Gate the fused-mode field on the actual peer stream version (not the cluster-min-version singleton) so the
-// read format matches exactly what the writing node wrote, regardless of singleton state at this instant.
-if (isVersionOnOrAfterMinReqVersionForFusedModeInHybridQuery(in.getVersion()) && in.readBoolean()) {
-    fusion = in.readMap();
+if (isVersionOnOrAfterMinReqVersionForFusedModeInHybridQuery(in.getVersion())) {
+    if (in.readBoolean()) {
+        Map<String, Object> read = in.readMap();
+        fusion = Objects.isNull(read) ? new HashMap<>() : read;
+    }
 }
Suggestion importance[1-10]: 2

__

Why: The concern is speculative: in.readMap() symmetrically pairs with out.writeMap() and preserves the map contents. An empty map on write remains an empty map on read (non-null), preserving presence semantics.

Low
Possible issue
Fix max seed for negative scores

Float.MIN_VALUE is the smallest positive value, not the most negative, so any leg
containing only negative raw scores will produce max = Float.MIN_VALUE (a positive
number), which is incorrect for max tracking. Classic hybrid may seed differently;
use -Float.MAX_VALUE (or Float.NEGATIVE_INFINITY) to correctly track the maximum of
possibly-negative scores.

src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java [62-71]

 for (int leg = 0; leg < legCount; leg++) {
     float min = Float.MAX_VALUE;
-    float max = Float.MIN_VALUE;
+    float max = -Float.MAX_VALUE;
     for (float raw : legRawScores.get(leg).values()) {
         min = Math.min(min, raw);
         max = Math.max(max, raw);
     }
     minPerLeg[leg] = min;
     maxPerLeg[leg] = max;
 }
Suggestion importance[1-10]: 2

__

Why: The suggestion identifies that Float.MIN_VALUE is a small positive number, but the classic hybrid path (via MinMaxScoreNormalizer) uses the exact same seed values, and the PR explicitly documents that parity with classic is preserved bit-for-bit. Changing only the coordinator path would break the differential parity guarantee that is the load-bearing invariant of this PR. Additionally, Lucene scores are non-negative by convention.

Low
Suggestions up to commit b9cbd23
CategorySuggestion                                                                                                                                    Impact
General
Make top-level detection resilient to cloning

The reference-identity check (== this) is fragile: any request-rewrite layer that
clones the query before this rewrite will misclassify a genuinely top-level fused
query as nested, silently dropping the Tail and producing incorrect
total_hits/aggregations. Since your own TODO acknowledges this risk, consider a more
robust detection now (e.g. equals comparison or an explicit signal) rather than
depending on an unenforced invariant that can regress silently.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [480]

-boolean topLevel = Objects.nonNull(searchRequest.source()) && searchRequest.source().query() == this;
+boolean topLevel = Objects.nonNull(searchRequest.source())
+    && (searchRequest.source().query() == this || this.equals(searchRequest.source().query()));
Suggestion importance[1-10]: 4

__

Why: The concern is valid and the PR author explicitly acknowledges the fragility with a TODO. However, the proposed equals fix is not necessarily correct either (equal but distinct instances could exist elsewhere), and the author has documented the invariant intentionally. The suggestion raises a legitimate concern but the fix is questionable.

Low
Validate weight element types before casting

The unchecked cast to Number will throw an opaque ClassCastException if a user
supplies a non-numeric weight (e.g. a string). Validate the element type and throw a
descriptive IllegalArgumentException so the caller gets a clear 400-style message
instead of an internal error.

src/main/java/org/opensearch/neuralsearch/query/FusionSpec.java [179-184]

 List<Object> raw = (List<Object>) parameters.get(WEIGHTS_KEY);
 float[] weights = new float[raw.size()];
 for (int i = 0; i < raw.size(); i++) {
-    weights[i] = ((Number) raw.get(i)).floatValue();
+    Object element = raw.get(i);
+    if ((element instanceof Number) == false) {
+        throw new IllegalArgumentException(
+            String.format(Locale.ROOT, "fusion weights must be numeric; got [%s] at index [%d]", element, i)
+        );
+    }
+    weights[i] = ((Number) element).floatValue();
 }
 return weights;
Suggestion importance[1-10]: 3

__

Why: Adds defensive type checking for weight elements to produce clearer error messages. This is a minor improvement to error handling.

Low
Reject duplicate fusion keys at parse

When fusion is provided as an object, parser.map() is called but there is no check
that fusion has not already been set (e.g. by an earlier string-form fusion value on
the same query). Duplicate fusion keys will silently overwrite the previous value.
Consider rejecting duplicates or, at minimum, ensure this is consistent with how
other duplicate top-level keys are handled.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [296-297]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+    if (Objects.nonNull(fusion)) {
+        throw new ParsingException(
+            parser.getTokenLocation(),
+            String.format(Locale.ROOT, "[%s] query [%s] specified more than once", NAME, FUSION_FIELD.getPreferredName())
+        );
+    }
     fusion = parser.map();
Suggestion importance[1-10]: 3

__

Why: Handling duplicate fusion keys is a minor edge case; most XContent parsers already have some behavior for duplicates. The suggestion is reasonable but low-impact.

Low
Possible issue
Fix max seeding to handle negative scores

Float.MIN_VALUE is the smallest positive value (~1.4e-45), not the most negative
float. If a leg contains only negative raw scores, max will incorrectly stay at
Float.MIN_VALUE instead of tracking the actual maximum. Use -Float.MAX_VALUE (or
Float.NEGATIVE_INFINITY) as the seed for max to correctly handle negative scores.

src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java [63-70]

 float min = Float.MAX_VALUE;
-float max = Float.MIN_VALUE;
+float max = -Float.MAX_VALUE;
 for (float raw : legRawScores.get(leg).values()) {
     min = Math.min(min, raw);
     max = Math.max(max, raw);
 }
 minPerLeg[leg] = min;
 maxPerLeg[leg] = max;
Suggestion importance[1-10]: 3

__

Why: The suggestion identifies that Float.MIN_VALUE is the smallest positive value, which is a valid concern for negative scores. However, the PR explicitly documents that this seeding matches classic hybrid's behavior exactly (getMinScores/getMaxScores), and the whole point is bit-for-bit parity with classic. Changing this would break the differential parity test.

Low
Suggestions up to commit b9cbd23
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix incorrect max initialization seed

Float.MIN_VALUE is the smallest positive float (~1.4e-45), not the most negative
value. If all raw scores in a leg are negative (possible for some scorers like
function_score with subtracted values), max will remain Float.MIN_VALUE and produce
incorrect normalization. Use -Float.MAX_VALUE or Float.NEGATIVE_INFINITY for the
initial max seed. Note: the classic path is claimed to seed the same way, but if
classic has the same bug, both paths should be fixed.

src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java [63-71]

 for (int leg = 0; leg < legCount; leg++) {
     float min = Float.MAX_VALUE;
-    float max = Float.MIN_VALUE;
+    float max = -Float.MAX_VALUE;
     for (float raw : legRawScores.get(leg).values()) {
         min = Math.min(min, raw);
         max = Math.max(max, raw);
     }
     minPerLeg[leg] = min;
     maxPerLeg[leg] = max;
 }
Suggestion importance[1-10]: 8

__

Why: Correctly identifies that Float.MIN_VALUE is the smallest positive float, not the most negative, which is a real bug for negative scores. However, the PR explicitly states this mirrors classic's behavior bit-for-bit, so changing it here alone could break parity — the fix would need to be coordinated.

Medium
General
Make top-level detection robust to cloning

Using reference-identity (==) to determine top-level vs nested is fragile and, per
the TODO comment, silently breaks if any layer clones the query before this rewrite
runs. Since the failure mode is a silently dropped Tail (wrong
total_hits/aggregations, no error), consider adding a defensive equality-based
fallback or an assertion/log when identity fails but the query equals
source().query(), so regressions are detectable rather than silent.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [480]

-boolean topLevel = Objects.nonNull(searchRequest.source()) && searchRequest.source().query() == this;
+QueryBuilder topQuery = Objects.nonNull(searchRequest.source()) ? searchRequest.source().query() : null;
+boolean topLevel = topQuery == this || (Objects.nonNull(topQuery) && topQuery.equals(this));
Suggestion importance[1-10]: 5

__

Why: Valid concern already acknowledged in the TODO comment; the suggested equals fallback would also match nested identical queries, so it's not a clean fix, but flagging the silent failure mode has merit.

Low
Validate weights element type explicitly

An unchecked cast to Number will throw ClassCastException with a poor error message
if a user supplies a non-numeric weight (e.g., a string). Validate the element type
explicitly and throw a clear IllegalArgumentException describing the invalid weights
entry, so mis-typed pipeline configs fail with a helpful message rather than a raw
CCE.

src/main/java/org/opensearch/neuralsearch/query/FusionSpec.java [179-184]

 List<Object> raw = (List<Object>) parameters.get(WEIGHTS_KEY);
 float[] weights = new float[raw.size()];
 for (int i = 0; i < raw.size(); i++) {
-    weights[i] = ((Number) raw.get(i)).floatValue();
+    Object element = raw.get(i);
+    if ((element instanceof Number) == false) {
+        throw new IllegalArgumentException(
+            String.format(Locale.ROOT, "weights[%d] must be a number, got [%s]", i, element)
+        );
+    }
+    weights[i] = ((Number) element).floatValue();
 }
 return weights;
Suggestion importance[1-10]: 4

__

Why: Minor error-message improvement for malformed weights; the current code would still fail with a ClassCastException, so functionality is preserved but user experience is slightly worse.

Low
Validate fusion string token type explicitly

This branch handles the string form of fusion inside the value-token handling block.
Ensure this path only accepts VALUE_STRING tokens; otherwise a number/boolean value
for fusion will call parser.text() and be misinterpreted as the string "pipeline"
check. Consider validating token == XContentParser.Token.VALUE_STRING explicitly, or
the current placement inside a token-typed branch already guarantees this—verify
placement.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [330-349]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-    // `"fusion": "pipeline"` == `"fusion": { "source": "pipeline" }`. Presence still
-    // enables the resolver; the config comes from the attached pipeline.
+    if (token != XContentParser.Token.VALUE_STRING) {
+        throw new ParsingException(
+            parser.getTokenLocation(),
+            String.format(Locale.ROOT, "[%s] query [%s] must be an object or the string [%s]", NAME, FUSION_FIELD.getPreferredName(), FUSION_SOURCE_PIPELINE)
+        );
+    }
     String source = parser.text();
Suggestion importance[1-10]: 3

__

Why: The branch is inside a token.isValue() block, and parser.text() handles value tokens reasonably; the improvement is marginal and largely already guarded by placement.

Low
Suggestions up to commit 457d8f6
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix incorrect max seed for negative scores

Float.MIN_VALUE is the smallest positive value (~1.4e-45), not the most negative
float. If all raw scores in a leg are negative (possible for certain scoring
functions like script_score), max will remain at Float.MIN_VALUE, producing
incorrect normalization. Use -Float.MAX_VALUE (or Float.NEGATIVE_INFINITY) as the
seed for max, mirroring what a correct max-seed should be.

src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java [63-70]

 float min = Float.MAX_VALUE;
-float max = Float.MIN_VALUE;
+float max = -Float.MAX_VALUE;
 for (float raw : legRawScores.get(leg).values()) {
     min = Math.min(min, raw);
     max = Math.max(max, raw);
 }
 minPerLeg[leg] = min;
 maxPerLeg[leg] = max;
Suggestion importance[1-10]: 7

__

Why: Correctly identifies that Float.MIN_VALUE is the smallest positive value, not the most negative, which could cause incorrect max seeding for negative scores. However, the PR explicitly documents this mirrors classic's seeding for parity, so this may be intentional; still, it's a legitimate concern worth flagging.

Medium
General
Validate weights are numeric values

The cast (Number) raw.get(i) will throw ClassCastException (a 500 error) if the user
supplies a non-numeric weight (e.g. "weights": ["0.5", "0.5"]). Validate the element
type and throw a clear IllegalArgumentException so malformed config yields a 400
rather than an internal error.

src/main/java/org/opensearch/neuralsearch/query/FusionSpec.java [171-185]

 @SuppressWarnings("unchecked")
 private static float[] readWeights(Map<String, Object> combinationClause) {
     if ((combinationClause.get(PARAMETERS_KEY) instanceof Map) == false) {
         return new float[0];
     }
     Map<String, Object> parameters = (Map<String, Object>) combinationClause.get(PARAMETERS_KEY);
     if ((parameters.get(WEIGHTS_KEY) instanceof List) == false) {
         return new float[0];
     }
     List<Object> raw = (List<Object>) parameters.get(WEIGHTS_KEY);
     float[] weights = new float[raw.size()];
     for (int i = 0; i < raw.size(); i++) {
-        weights[i] = ((Number) raw.get(i)).floatValue();
+        Object element = raw.get(i);
+        if ((element instanceof Number) == false) {
+            throw new IllegalArgumentException(
+                String.format(Locale.ROOT, "weights[%d] must be a number, got [%s]", i, element)
+            );
+        }
+        weights[i] = ((Number) element).floatValue();
     }
     return weights;
 }
Suggestion importance[1-10]: 5

__

Why: Correctly identifies that a non-numeric weight would cause a ClassCastException resulting in a 500 error instead of a proper 400; providing user-facing validation is a reasonable improvement.

Low
Validate fusion string token type

This branch is under token.isValue() handling, but there is no explicit check that
the token is a string. If someone passes "fusion": true or "fusion": 42,
parser.text() may coerce or fail unexpectedly. Explicitly validate the token type to
give a clear parse error.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [330-349]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
     // `"fusion": "pipeline"` == `"fusion": { "source": "pipeline" }`. Presence still
     // enables the resolver; the config comes from the attached pipeline.
+    if (token != XContentParser.Token.VALUE_STRING) {
+        throw new ParsingException(
+            parser.getTokenLocation(),
+            String.format(Locale.ROOT, "[%s] query [%s] must be a string or object", NAME, FUSION_FIELD.getPreferredName())
+        );
+    }
     String source = parser.text();
Suggestion importance[1-10]: 4

__

Why: Minor defensive improvement; parser.text() typically handles coercion or throws, and the subsequent equality check on FUSION_SOURCE_PIPELINE will reject unexpected values anyway.

Low
Make source queries list immutable

When sourceQueries is empty (Top-only self-erased query), the loop is skipped and
changed stays false — this is correct, but reads oddly. More importantly, if
sourceQueries.isEmpty() the constructor initializes a new ArrayList<>() on read from
the stream, meaning equals/hashCode compare correctly, but you should consider
making sourceQueries unmodifiable to prevent accidental mutation after construction
(Top-only queries in particular should be immutable).

src/main/java/org/opensearch/neuralsearch/query/HybridFusionQuery.java [54-58]

-@Override
-protected QueryBuilder doRewrite(QueryRewriteContext queryRewriteContext) throws IOException {
-    boolean changed = false;
-    List<QueryBuilder> rewritten = new ArrayList<>(sourceQueries.size());
-    for (QueryBuilder q : sourceQueries) {
-        QueryBuilder r = q.rewrite(queryRewriteContext);
-        rewritten.add(r);
-        changed |= r != q;
-    }
-    if (changed) {
-        HybridFusionQuery rewrittenBuilder = new HybridFusionQuery(ids, scores, rewritten);
-        rewrittenBuilder.boost(boost());
-        rewrittenBuilder.queryName(queryName());
-        return rewrittenBuilder;
-    }
-    return this;
+public HybridFusionQuery(String[] ids, float[] scores, List<QueryBuilder> sourceQueries) {
+    this.ids = ids;
+    this.scores = scores;
+    this.sourceQueries = Objects.isNull(sourceQueries) ? List.of() : List.copyOf(sourceQueries);
 }
Suggestion importance[1-10]: 3

__

Why: Minor code-hardening suggestion; the existing behavior is functionally correct, and making the list immutable is a stylistic improvement with limited impact.

Low
Suggestions up to commit 457d8f6
CategorySuggestion                                                                                                                                    Impact
General
Validate token type for fusion string form

Parsing fusion as a string via parser.text() in the VALUE_STRING branch will also
accept boolean/number tokens coerced to strings depending on parser behavior, and
more importantly this branch is reached for any scalar value, potentially producing
confusing errors. Verify the token type is VALUE_STRING explicitly before calling
parser.text(), otherwise a numeric or boolean fusion value could bypass the
validation with a coerced string.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [330-333]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
+    if (token != XContentParser.Token.VALUE_STRING) {
+        throwUnsupportedFieldParsingException(parser, currentFieldName);
+    }
     // `"fusion": "pipeline"` == `"fusion": { "source": "pipeline" }`. Presence still
     // enables the resolver; the config comes from the attached pipeline.
     String source = parser.text();
Suggestion importance[1-10]: 4

__

Why: The suggestion has minor merit — explicitly validating the token type could produce clearer errors, but this branch is under VALUE_STRING handling in the outer parser flow, and parser.text() is a standard pattern. Impact is low.

Low
Validate weight element types before cast

A weights list containing a non-Number element (e.g. a string from JSON like "0.5")
will throw an unchecked ClassCastException that surfaces as a 500 rather than a
proper 400 with a helpful message. Validate the element type and throw
IllegalArgumentException with a clear message when a weight entry is not a number.

src/main/java/org/opensearch/neuralsearch/query/FusionSpec.java [179-184]

 List<Object> raw = (List<Object>) parameters.get(WEIGHTS_KEY);
 float[] weights = new float[raw.size()];
 for (int i = 0; i < raw.size(); i++) {
-    weights[i] = ((Number) raw.get(i)).floatValue();
+    Object w = raw.get(i);
+    if ((w instanceof Number) == false) {
+        throw new IllegalArgumentException(
+            String.format(Locale.ROOT, "weights[%d] must be a number, got [%s]", i, w)
+        );
+    }
+    weights[i] = ((Number) w).floatValue();
 }
 return weights;
Suggestion importance[1-10]: 4

__

Why: Adding explicit type validation improves error messages for malformed input, converting a potential 500 to a 400. However, this is a minor robustness improvement in an internal parsing path that typically receives already-validated numeric values.

Low
Ensure parsed fusion map is mutable

parser.map() returns an unmodifiable/immutable map depending on the parser
implementation, but the parse code later constructs a HashMap for the string-form
path — inconsistency here means downstream mutation (or expectations of mutability)
can fail. Wrap the parsed map in a mutable HashMap to guarantee consistent behavior
across both parse branches.

src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java [296-297]

 } else if (FUSION_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
-    fusion = parser.map();
+    fusion = new HashMap<>(parser.map());
Suggestion importance[1-10]: 3

__

Why: The suggestion is speculative — parser.map() typically returns a HashMap in OpenSearch's XContent parsers. The inconsistency claim is weak and no concrete downstream mutation is shown to require this.

Low
Possible issue
Fix incorrect max seed for negative scores

Float.MIN_VALUE is the smallest POSITIVE value (~1.4e-45), not the most negative
float, so when all raw scores in a leg are negative, max will incorrectly remain at
Float.MIN_VALUE instead of the actual maximum. Use -Float.MAX_VALUE (or
Float.NEGATIVE_INFINITY) as the seed for max to correctly handle negative scores.
Note: the comment claims parity with classic, so this may also indicate a latent bug
in the classic path that should be addressed there as well.

src/main/java/org/opensearch/neuralsearch/fusion/CoordinatorScoreFusion.java [63-70]

 float min = Float.MAX_VALUE;
-float max = Float.MIN_VALUE;
+float max = -Float.MAX_VALUE;
 for (float raw : legRawScores.get(leg).values()) {
     min = Math.min(min, raw);
     max = Math.max(max, raw);
 }
 minPerLeg[leg] = min;
 maxPerLeg[leg] = max;
Suggestion importance[1-10]: 3

__

Why: The suggestion correctly points out that Float.MIN_VALUE is the smallest positive value, but the code comment explicitly states this seeding mirrors classic hybrid's behavior for parity. In practice, Lucene scores are non-negative, so this is unlikely to trigger a real bug, and changing it would break the intentional parity guarantee.

Low

@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.73913% with 61 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.52%. Comparing base (936d3c4) to head (c95d889).

Files with missing lines Patch % Lines
...nsearch/neuralsearch/query/HybridQueryBuilder.java 84.33% 15 Missing and 11 partials ⚠️
.../org/opensearch/neuralsearch/query/FusionSpec.java 80.64% 2 Missing and 10 partials ⚠️
...h/neuralsearch/query/HybridFusionOrchestrator.java 88.17% 3 Missing and 8 partials ⚠️
...earch/neuralsearch/query/FusionConfigResolver.java 82.05% 2 Missing and 5 partials ⚠️
...h/neuralsearch/query/HybridFusionQueryBuilder.java 94.11% 1 Missing and 2 partials ⚠️
...g/opensearch/neuralsearch/plugin/NeuralSearch.java 0.00% 0 Missing and 1 partial ⚠️
...processor/normalization/MinMaxScoreNormalizer.java 93.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                        Coverage Diff                        @@
##             feature/fusion-hybrid-query    #1933      +/-   ##
=================================================================
+ Coverage                          83.45%   83.52%   +0.07%     
- Complexity                          3884     4025     +141     
=================================================================
  Files                                291      297       +6     
  Lines                              13819    14255     +436     
  Branches                            2294     2387      +93     
=================================================================
+ Hits                               11532    11906     +374     
- Misses                              1454     1480      +26     
- Partials                             833      869      +36     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch from fee3689 to 5659fe8 Compare August 6, 2026 04:14
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit 5659fe8

@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch from 5659fe8 to d0c467b Compare August 6, 2026 05:00
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit d0c467b

@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch from d0c467b to f288716 Compare August 6, 2026 15:16
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit f288716

@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch 2 times, most recently from c89ce8c to 99c7fe7 Compare August 6, 2026 19:44
@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch from 99c7fe7 to eb1dbb8 Compare August 6, 2026 23:42
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit eb1dbb8

Signed-off-by: Martin Gaievski <gaievski@amazon.com>
@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch from eb1dbb8 to e16cfa3 Compare August 6, 2026 23:58
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Persistent review updated to latest commit e16cfa3

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 457d8f6

Signed-off-by: Martin Gaievski <gaievski@amazon.com>
@martin-gaievski
martin-gaievski force-pushed the feature/fusion-hybrid-query branch from a74ff45 to b9cbd23 Compare August 11, 2026 17:19
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit a74ff45

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit b9cbd23

*
* <p>This query is created internally by the coordinator self-erase and is never parseable from a search request.
*/
public class HybridFusionQuery extends AbstractQueryBuilder<HybridFusionQuery> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a QueryBuilder rather than a Query. Can we rename it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, will rename it

* this copy. The scoring {@link Weight}/iterator implementation is deferred to PR5, where the PIT/docId fast path is
* introduced — {@link #createWeight} intentionally throws until then.
*/
public final class FusedDocsScorerQuery extends Query {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we don't plan to use it in this PR maybe it's better to exclude it? And for the retriever framework we may also have a different implementation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

* coordinator rewrite wiring lands with the execution path.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class CoordinatorScoreFusion {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design suggestion: separation of concerns in CoordinatorScoreFusion

Currently fuseMinMax() computes per-leg min/max bounds, normalizes scores, and combines them — all in one method. This works for min_max alone, but once z_score, l2, and RRF land on this path, we'd end up with separate fuseMinMax / fuseZScore / fuseL2 / fuseRRF methods each with their own inline stats-gathering or rank-computation loop.

What if we extract a per-leg transformation step? Something like:

interface ScoreTransformer {
    List<Float> transform(List<Float> rawScores);
}

Each implementation computes what it needs internally and returns values ready for combination:

Technique transform() does
min_max compute min/max, apply (s-min)/(max-min)
z_score compute mean/stddev, apply (s-mean)/stddev
l2 compute L2 norm, apply s/norm
RRF ignore score values, return 1/(k+rank) by position

Then CoordinatorScoreFusion becomes technique-agnostic:

public static Map<String, Float> fuse(
    List<Map<String, Float>> legRawScores,
    ScoreTransformer transformer,
    ScoreCombinationTechnique combiner
) { ... }

This pairs ScoreTransformer with the existing ScoreCombinationTechnique — one transforms per-leg scores, the other combines across legs. Adding a technique means implementing the interface, no changes to the coordinator. It also mirrors how the classic pipeline already models RRF (normalization step replaces scores with reciprocals, combination step sums them) — just with a name that doesn't imply "normalization" for rank-based techniques.

Not blocking — the current code is correct. Just thinking about extensibility for the upcoming techniques.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's the same per-leg normalization seam we discussed for @vibrantvarun two-phase computeLegStats/normalize(score,stats) idea — let's converge on one abstraction rather than land two. Your single transform(List)→List is the better target: it generalizes to rank-based RRF (sort the leg, emit 1/(k+rank) by position), whereas the stats-split forces a per-score phase-2 that RRF can't satisfy.

This PR only supports min_max + arithmetic_mean (requireSupportedTechniques rejects the rest), so there's one technique today, I'd abstract in PR that lands the second scalar technique, keeping ScoreCombinationTechnique as the combine half.

} catch (Exception e) {
listener.onFailure(e);
}
}, listener::onFailure)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we provide a better error message here since the error from multiSearch can be confusing since end users think they are doing hybrid query?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree

for (QueryBuilder leg : legs) {
SearchSourceBuilder legSource = new SearchSourceBuilder().query(leg)
.size(windowSize)
.from(0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why from 0? Isn't the default behavior?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, legSource is a fresh SearchSourceBuilder whose from defaults to 0 at execution, so .from(0) is a no-op;

* the shared core consumes; a leg that matched nothing contributes an empty map (groupLegHits fails fast on failures,
* so every slot is non-null).
*/
private static RankedDocs computeRankedDocs(SearchHit[][] legHits, FusionSpec fusion, int windowSize) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential issue: _id collision in multi-index searches

In computeRankedDocs, leg hits are keyed purely by hit.getId():

Map<String, Float> byId = new LinkedHashMap<>();
for (SearchHit hit : hits) {
    byId.put(hit.getId(), hit.getScore());
}

For a single-index search this is fine — each _id is unique. But for multi-index searches (e.g., POST /index-a,index-b/_search), two different documents in different indices can share the same _id. In that case:

  1. Within a single leg's map, the second doc would overwrite the first (last-write-wins).
  2. Across legs, a doc from index-a in leg 0 and a different doc from index-b in leg 1 would be treated as the same document during fusion — their scores would be combined as if they're one entity.

The fused HybridFusionQuery then uses IdsQueryBuilder with those ids, which would match both documents, and both would get the same fused score.

Should the key be _index + _id (or use hit.getIndex() + "#" + hit.getId()) to avoid conflation? The downstream IdsQueryBuilder in the Top would also need to become index-aware (or use a bool with terms on _id + filter on _index).

This may be an acceptable limitation for the initial scope if multi-index hybrid queries are uncommon, but worth documenting or validating against.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree that's a real bug

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/** Legs whose Lucene match set is their own top-k (re-running them in the Tail = a redundant ANN pass). */
private static boolean isMaterializableLeg(QueryBuilder leg) {
String name = leg.getWriteableName();
return "knn".equals(name) || "neural".equals(name) || "neural_knn".equals(name);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use QueryBuilder name? what about neural_sparse?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree on naming, it should be safe to switch to QueryBuilder constant as we do have dependency on all these plugins.
neural_sparse should stay excluded. Materialization is only safe when a leg's full Lucene match set equals its returned top-k (true for HNSW kNN/neural). A neural_sparse leg's match set is every doc containing a query token — far larger than the window — so materializing to the window ids would drop the rest from the Tail and undercount total_hits/aggregations. Plus re-running it is cheap (no HNSW to re-walk); the check keys on getWriteableName() so it also correctly covers the seismic sparse-ANN variant (which keeps a lexical fallback). I'll expand the existing rationale comment at line 242 to make the "match-set == top-k" basis explicit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussed offline, with latest code we are breaking default behavior, which is allow partial results (true setting, https://docs.opensearch.org/latest/install-and-configure/configuring-opensearch/search-settings/). If false is hardcoded that will fail the whole top query independently of actual setting, including default. Thus I'm reverting to initial version so we keep the default behavior. @vibrantvarun that something we discussed in #1933 (comment)

return "knn".equals(name) || "neural".equals(name) || "neural_knn".equals(name);
}

private static boolean needsExecutionTail(SearchSourceBuilder source) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: does the Tail actually help explain and profile?

needsExecutionTail includes explain and profile as reasons to include the Tail, but I'm not sure the Tail provides useful output for either:

explain: The user wants to understand how the fused score was computed (per-leg raw scores → normalization → combination). But the Tail puts the original legs inside a filter clause (non-scoring), so the explanation only shows "constant_score, boost=0.716" from the Top — not the per-leg breakdown. The actual fusion happened at the coordinator before the self-erased query was built, so it's not in the Lucene explain tree regardless of the Tail.

profile: The user wants to see where time was spent. With the Tail, profiling shows the shard re-executing the original legs inside the filter — which is redundant work (they already ran during the MultiSearch fan-out). It profiles the wrong thing: the second execution in the Tail, not the actual leg latency from the MultiSearch.

In both cases the Tail adds query-phase cost (re-executing legs) without providing the information the user is looking for. These might need a fusion-aware solution (e.g., storing the per-leg breakdown in the response) rather than the Tail.

Should explain and profile be removed from needsExecutionTail? Or is there a plan for fusion-aware explain/profile that would use the Tail differently?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correct, Top is constant_score(ids)^fusedScore with min_max/arithmetic_mean already applied on the coordinator, so the Lucene tree has no fusion math to explain and the Tail is a non-scoring filter; for profile it re-executes legs that already ran in the fan-out. I'll drop explain and profile from needsExecutionTail. Safe: hits and fused scores come from the Top, and total_hits is still covered by wantsTotalsBeyondWindow; aggregations and highlight keep the Tail. Proper fusion-aware explain/profile is scoped to later PR.

boolean topOnly;
if (topLevel == false) {
topOnly = true; // nested: enclosing filter intersects at the query phase
} else if (needsExecutionTail(source) || legsHaveInnerHits(legs)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues:

1. needsExecutionTail doesn't cover all cases that need the Tail.

It checks aggregations, explain, profile, highlighter — but total_hits is handled separately by wantsTotalsBeyondWindow. If the method is called "needsExecutionTail" you'd expect it to be the single source of truth for "do we need the Tail?". Instead, the decision is split across three conditions. It would be clearer as one method that returns the full decision.

2. legsHaveInnerHits doesn't actually require the Tail.

extractInnerHitBuilders reads from sourceQueries (the field on HybridFusionQuery), not from the Tail's filter clause. The fetch phase registers inner_hits contexts from extractInnerHitBuilders and does its own per-doc lookup. The Tail re-executing the nested query during query phase is wasted work — the fetch phase doesn't use that result.

So legsHaveInnerHits being a reason for the Tail seems incorrect. The sourceQueries list just needs to be populated (which it always is when we pass the legs to HybridFusionQuery), but it doesn't need to be in the executed filter.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) Agreed, I'll consolidate needsExecutionTail / legsHaveInnerHits / wantsTotalsBeyondWindow into one needsTail(...); they're already OR'd so it's a readability win.

(2) You're right on the mechanics: inner_hits are computed in the fetch phase per parent doc from the registered InnerHitContextBuilder's own query, so re-running the nested leg in the query-phase Tail is wasted. That catch is: sourceQueries isn't always populated — it's the same list that backs the Tail filter and is empty when we go Top-only, so I can't just drop legsHaveInnerHits or extractInnerHitBuilders would register nothing. Clean fix decouples the inner-hit registration source (always carry the leg builders) from the executed Tail (built only for aggs/totals/highlight), with a regression test — and should also cover the nested-fused case, where inner_hits are actually dropped today. Since inner_hits are correct now and this is an optimization, I'd land it as a focused follow-up.

* </ol>
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
final class FusionConfigResolver {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Questions on FusionConfigResolver

1. Inline body pipeline is silently broken

The comment in Step 2 acknowledges that core's resolvePipeline drains searchPipelineSource() before query rewrite runs, so fromPipelineConfig({}) returns null and the request fails with "no fusion config found." The user sees an error but it's confusing — they DID specify a pipeline with a normalization processor in the body. The error doesn't explain that inline body pipelines are unsupported for fused mode.

Could we detect this case earlier (e.g., at parse time if fusion: "pipeline" coexists with an inline search_pipeline block) and throw a clear error like "inline body search pipelines are not supported with fused mode; use a named pipeline or specify the config directly in the fusion block"?

2. Named pipeline doesn't exist — misleading error

If ?search_pipeline=my-pipeline references a pipeline that doesn't exist in cluster state, pipelineConfigById returns null → the error says "requires a normalization or score-ranker processor in the attached search pipeline." The actual problem is the pipeline doesn't exist, not that it's missing a processor. A more specific error ("pipeline 'my-pipeline' not found") would help debugging.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On (1) you're right: core drains our inline search_pipeline map before coordinator rewrite, so we only see {} and emit the misleading "none found." Since searchPipelineSource() is still non-null when a block was attached, I'll turn that into a targeted rewrite-time error; genuinely supporting an inline body needs the companion core fix (deep-copy the config in resolvePipeline), which I'll track as a follow-up.

On (2), though, a genuinely missing named or index-default pipeline id doesn't reach this code: core's resolvePipeline throws "Pipeline [x] is not defined" (before rewriteAndFetch), so the user already gets a clear error. Our generic message only fires when the pipeline exists but has no fusion processor (or the drained-inline case), so a "not found" branch would be unreachable. I'll instead sharpen the wording to say the resolved pipeline has no normalization/score-ranker processor.

Signed-off-by: Martin Gaievski <gaievski@amazon.com>
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit 4f82009

Signed-off-by: Martin Gaievski <gaievski@amazon.com>
@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit c95d889

public static final Version MINIMAL_SUPPORTED_VERSION_METRICS_STATS = Version.V_3_3_0;
private static final Version MINIMAL_SUPPORTED_VERSION_NEURAL_KNN_QUERY_BUILDER = Version.V_3_0_0;
private static final Version MINIMAL_SUPPORTED_VERSION_AGENTIC_EMBEDDING_MODEL_ID = Version.V_3_6_0;
public static final Version MINIMAL_SUPPORTED_VERSION_FUSED_MODE_IN_HYBRID_QUERY = Version.V_3_8_0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

V_3_9_0 is available now

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3.8 is feature branch baseline, I keep it before all dependencies/plugins up-vert to 3.9 to avoid build failures and instabilities with not yet released changes in main. Control gate is the final merge from feature branch to main after all development is completed, then we must update the flag to latest OS version.

@martin-gaievski
martin-gaievski merged commit a71331b into opensearch-project:feature/fusion-hybrid-query Aug 13, 2026
89 of 144 checks passed
dbwiddis added a commit to dbwiddis/neural-search that referenced this pull request Aug 18, 2026
….parameters

BEHAVIOR FIX on top of opensearch-project#1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
dbwiddis added a commit to dbwiddis/neural-search that referenced this pull request Aug 18, 2026
….parameters

BEHAVIOR FIX on top of opensearch-project#1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
dbwiddis added a commit to dbwiddis/neural-search that referenced this pull request Aug 19, 2026
….parameters

BEHAVIOR FIX on top of opensearch-project#1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
dbwiddis added a commit to dbwiddis/neural-search that referenced this pull request Aug 19, 2026
….parameters

BEHAVIOR FIX on top of opensearch-project#1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
Signed-off-by: Daniel Widdis <widdis@gmail.com>
dbwiddis added a commit to dbwiddis/neural-search that referenced this pull request Aug 25, 2026
….parameters

BEHAVIOR FIX on top of opensearch-project#1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
Signed-off-by: Daniel Widdis <widdis@gmail.com>
dbwiddis added a commit to dbwiddis/neural-search that referenced this pull request Sep 1, 2026
….parameters

BEHAVIOR FIX on top of opensearch-project#1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
Signed-off-by: Daniel Widdis <widdis@gmail.com>
martin-gaievski pushed a commit that referenced this pull request Sep 1, 2026
* [RRF] Read rank_constant from the combination clause, not combination.parameters

BEHAVIOR FIX on top of #1933. FusionSpec.fromScoreRankerProcessor read
`combination.parameters.rank_constant`, but RRFProcessorFactory — the ground
truth for the score-ranker-processor shape — reads `rank_constant` directly off
the `combination` clause and rejects anything but `weights` under `parameters`.
Two consequences, both caught by an integration test:

  - Every user-supplied rank_constant was silently ignored, so fused mode always
    ranked with the default 60.
  - The shape FusionSpec documented and the IT fixture emitted is rejected by the
    real processor with HTTP 400 "provided parameter for combination technique is
    not supported. supported parameters are [weights]", so a fused query could
    not even reuse an existing RRF pipeline.

Changes:
  - Read rank_constant off the combination clause via the shared
    RRFScoreNormalizer.resolveRankConstant, so fused mode accepts, rejects and
    reports exactly what classic does (absent -> 60, non-integer -> "must be an
    integer", outside [1, 10000] -> the range error). Reading it directly here
    would silently accept a negative, oversized or fractional value.
  - Reject rank_constant under `parameters` with a clear message rather than
    falling back to 60 and mis-ranking every query for a user who put it in the
    wrong place.
  - Report the normalization technique for the rrf shape instead of pinning it to
    "none". The score-ranker-processor has no normalization clause so this still
    resolves to "none", but an inline fusion block can carry one, and reporting it
    lets the caller reject the contradictory pairing instead of silently dropping
    what the user asked for.
  - Correct the two javadocs that documented the wrong location, and emit
    rank_constant in the right place in BaseNeuralSearchIT.createRRFSearchPipeline
    (new optional overload; the 3-arg form delegates with null).
Signed-off-by: Daniel Widdis <widdis@gmail.com>

* [RRF] Wire RRF into the coordinator-side fusion path

Enables `rrf` for the resolver (fused) mode of the hybrid query, reusing the
shared helpers extracted in #1944 so coordinator-side RRF and classic shard-side
RRF compute rank scores through the same code.

RRF enters through the ScalarNormalizers registry rather than as a second fusion
routine, because reciprocal rank fusion *is* a whole-leg normalization: rank is
derived from the leg's full value set, exactly as min_max derives min/max from it.
That is also how classic models it — RRFProcessorFactory builds an
RRFNormalizationTechnique for a processor that has no normalization clause. So
CoordinatorScoreFusion.fuse needs no rrf-specific branch, and neither does the
orchestrator: both keep normalizing then combining without knowing which
technique they hold.

  - RrfScalarNormalizer: ranks the leg via
    RRFScoreNormalizer.assignRanksByScoreDescending, then replaces each score with
    the shared scoreForRank(rank, rank_constant). A leg a doc did not match keeps
    CoordinatorScoreFusion's 0.0 slot, which RRFScoreCombinationTechnique's
    participation rule (score >= 0) treats as inert — mirroring how classic leaves
    an unmatched sub-query out of the sum.
  - ScalarNormalizers maps names to factories instead of singletons, mirroring
    classic's ScoreNormalizationFactory, because rrf is the one technique carrying
    a parameter. The three score-based techniques are stateless, so their factories
    hand back a shared instance and ignore the parameters.
  - FusionSpec resolves the score-ranker shape to normalization "rrf" rather than
    "none", naming the normalization classic already applies for it. An inline
    block may still carry a normalization clause; it is reported rather than
    dropped so the caller rejects the contradictory pairing.
  - HybridQueryBuilder.requireSupportedTechniques admits rrf and short circuits on
    rrf + rrf, the one pairing classic's compatibility matrix cannot speak to: that
    matrix describes the normalization-processor, whose combination options are the
    three means, while RRFProcessorFactory pins both halves itself. Any other
    normalization alongside rrf still falls through to the matrix and is rejected
    there by name.

Tie contract: classic ranks ties by Lucene docId then shardId, both physical
layout artifacts that do not exist coordinator-side, where only merged results are
available. Coordinator RRF therefore ranks ties by ascending fusion key — layout-
and insertion-order-independent, and the closest stable analogue. This is a
deliberate divergence, documented on RrfScalarNormalizer and pinned by a test that
inserts tied docs in two different orders and asserts an identical result.

Tests:
  - CoordinatorScoreFusionRrfDifferentialTests: parity against
    RRFNormalizationTechnique + RRFScoreCombinationTechnique over both classic
    ranking paths (positional single-shard and the cross-shard priority queue),
    for single/multiple sub-queries, partial overlap, weights, rank_constant
    bounds, raw-score magnitude irrelevance, and the tie order above.
  - ScalarNormalizerTests: rrf resolves through the registry, the no-parameter
    overload falls back to classic's default rank_constant, two rank constants
    build two differently-scored normalizers, and the per-leg rank arithmetic is
    order-only (magnitude-independent).
  - HybridFusionOrchestratorTests: exact fused scores read off the self-erased
    query's should-clause boosts, plus rank_constant honoring and magnitude
    independence.
  - HybridQueryFusedModeIT: rrf end to end from both config sources (an attached
    score-ranker-processor pipeline and an inline block), and rejection of rrf
    paired with a score-normalization technique. Score assertions pin the set of
    possible rank-score sums rather than exact per-doc scores, since individual
    ranks depend on how the 3 shards split the corpus.
Signed-off-by: Daniel Widdis <widdis@gmail.com>

* [RRF] Add an integration test comparing fused RRF against the classic pipeline directly

The existing RRF integration tests assert that fused mode produces rank-score
sums; none of them compares fused mode against classic shard-side RRF. That
comparison is the actual contract of this work — the shared RRFScoreNormalizer
exists so the two paths cannot drift — so assert it end to end.

For one index and one pair of sub-queries, run classic (a score-ranker-processor
pipeline), fused with inline config, and fused reading that same pipeline, then
require identical documents and identical scores. Covers rank_constant at 1, 60
and 10000, unweighted and weighted [0.3, 0.7].

Design notes:
  - Single shard, and a window well above the match count, so the two paths'
    candidate pools are the same set. They can legitimately differ once a
    truncating window meets more than one shard: classic ranks the union of each
    shard's top-pagination_depth per leg, fused the merged global top-window_size.
  - Scores are compared per document at exact equality, and the score sequence is
    compared separately, so the assertion is agnostic about which document wins a
    tie on the fused score.
  - The corpus gives every document a distinct (term frequency, length) pair per
    leg so neither leg ties two documents on score, and the test asserts that up
    front. A within-leg tie is the one place the paths deliberately disagree
    (classic orders by Lucene docId, fused by ascending _id), so asserting
    tie-freeness makes a future scoring change surface as "the corpus developed a
    tie" rather than as an unexplained parity failure.

Verified non-vacuous by mutation: passing rankConstant + 1 to the inline fused
config fails the per-document assertion with a full score diff.

Signed-off-by: Daniel Widdis <widdis@gmail.com>

* Add changelog entry

Signed-off-by: Daniel Widdis <widdis@gmail.com>

* [RRF] Delete the now-dead NORMALIZATION_NONE constant

Modelling rrf as normalization=rrf removed the only place fused mode
resolved a config to "none", so the constant no longer names anything the
coordinator understands.

Its last reference was the constructor's null fallback, which now has
nothing to fall back to: both factories always resolve a technique name,
each shape defaulting its own way, so require non-null instead of
defaulting to a value the technique gate would reject anyway.

Signed-off-by: Daniel Widdis <widdis@gmail.com>

* [RRF] Read rank_constant per config shape, and key the matrix exemption on shape

Two ways fused mode diverged from classic for rrf, both reported in review.

1. A `normalization-processor` may name rrf as its *normalization*
   technique, and there the rank constant lives under
   `normalization.parameters` — that is the map
   NormalizationProcessorFactory hands to RRFNormalizationTechnique.
   FusionSpec only ever looked on the combination clause (the
   score-ranker-processor's location), so every such pipeline fused at the
   default 60 no matter what the user configured, silently. Each shape now
   reads the rank constant where its own classic factory reads it, through
   the shared resolver, so an out-of-range or non-integer value is the same
   400 classic gives rather than a silent fallback.

2. The rrf + rrf pairing was exempted from classic's compatibility matrix
   by technique name. But two different configs resolve to those same two
   names: the score-ranker-processor, where the exemption belongs because
   the matrix describes the normalization-processor and cannot speak to it,
   and a normalization-processor asked to combine rrf-normalized scores by
   rrf — which classic rejects through that very matrix. Matching on names
   admitted the second, leaving fused mode looser than classic. FusionSpec
   now carries the Shape it was read as, and the exemption keys on that, so
   the matrix stays authoritative for the normalization-processor shape.

Both were reachable only in fused mode, which is unreleased.

Signed-off-by: Daniel Widdis <widdis@gmail.com>

---------

Signed-off-by: Daniel Widdis <widdis@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants