[Fusion] Report matched_queries for named sub-queries in fused mode - #1967
Conversation
PR Reviewer Guide 🔍(Review updated until commit dfee492)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to c72bf75
Previous suggestionsSuggestions up to commit c6bdb4e
|
matched_queries is built from the names registered while a query is converted on the shard, and the fetch phase then evaluates each name from its own weight - so a named sub-query has to be registered, never executed, for its name to be reported. Fused mode converted legs only through the non-scoring Tail, so a Top-only request registered no leg and the field vanished at HTTP 200, on a boundary that says nothing about names: track_total_hits one above the fused window reports names, exactly at the window does not. A materialized kNN/neural leg lost its name in every configuration, because the ids-address substitute carried none. Named legs are now carried in a third registered-but-never-executed list when the Tail is absent, keeping a Top-only query Top-only, and a materialized leg inherits the name of the leg it replaced. Also corrects the CandidateScope rationale that claimed the self-erased query could not carry matched_queries at all. Signed-off-by: Martin Gaievski <gaievski@amazon.com>
c6bdb4e to
c72bf75
Compare
|
Persistent review updated to latest commit c72bf75 |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## feature/fusion-hybrid-query #1967 +/- ##
=================================================================
- Coverage 83.83% 83.83% -0.01%
- Complexity 4126 4138 +12
=================================================================
Files 306 306
Lines 14572 14614 +42
Branches 2437 2443 +6
=================================================================
+ Hits 12216 12251 +35
- Misses 1482 1485 +3
- Partials 874 878 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // un-rewritten builder either compiles to something else or refuses to compile at all. | ||
| List<QueryBuilder> rewrittenNamedOnly = new ArrayList<>(namedOnlyQueries.size()); | ||
| for (QueryBuilder q : namedOnlyQueries) { | ||
| QueryBuilder r = q.rewrite(matchSetContext); |
There was a problem hiding this comment.
For neural sparse we need to trigger inference call again during the rewrite. Same thing can happen for the tail rewrite. Seems like there is no elegant way to avoid this unless we rewrite the leg in the coordinator and dispatch the rewritten leg query?
Or prioritize #1558 can help on duplicated inference call.
There was a problem hiding this comment.
that's mostly pre-existing rather than new here. Tail is rewritten in this same method and is on by default, so a neural_sparse leg with a model_id already pays 2 inferences in fused mode vs classic's 1. Measured: classic 1, fused+Tail 2, Top-only+named 2. It's one extra coordinator call per model leg, never per shard.
This PR adds that cost in one shape only: Top-only and a named leg. knn/neural/neural_knn are unaffected, they arrive materialized to ids.
Pre-rewriting leg on the coordinator doesn't work cleanly: it registers an async action mid-drain, and a nested fused-hybrid leg can't share one rewritten form between the round-1 fan-out and Tail. #1558 is the right approqch, same coordinator node, one request, and it also fixes the pre-existing Tail case. I'll file the follow-up.
vibrantvarun
left a comment
There was a problem hiding this comment.
Read the fused-mode matched_queries fix end-to-end (Top-only registration + materialized-leg name inheritance). The design mirrors the existing innerHitsQueries register-don't-execute pattern cleanly, and the oracle-anchored ITs are strong. A few things worth considering inline — mostly the materialized-leg deviation being under-tested/undocumented, plus a couple of nits. Nothing that looks like a correctness defect in the released contract.
| // similarity: the shard never sees the vector query, and re-running it for a reporting field is exactly the | ||
| // graph walk materialization exists to avoid. For the same reason only the leg's own name is inherited — a | ||
| // _name nested inside the leg (on a knn filter, say) has no clause left here to be registered against. | ||
| tail.add(materializedLeg(legHits[legIndex]).queryName(leg.queryName())); |
There was a problem hiding this comment.
For a materialized kNN/neural leg, the substitute registered under the leg's _name here is a filter-only bool{ids, _index}. Two behavioral gaps vs classic hybrid seem worth pinning:
- Under
include_named_queries_score, a filter-only bool contributes 0 to score, so the named-query score is reported as0.0rather than the ANN similarity classic reports. - The substitute only addresses the ids the leg returned (≤
window_size), so a doc matched by this leg but outside its own window won't get the name; classic re-evaluates the weight and would.
Both are inherent to not re-running the vector query and are documented in the code comment, but neither is covered by an IT against the classic oracle (the scoring IT uses function_score, i.e. non-materializable, legs). Could we add an IT pinning the materialized-leg score (expected 0.0) and the recall bound, so the asymmetry is anchored to behavior rather than only a comment?
There was a problem hiding this comment.
ack, will add tests
There was a problem hiding this comment.
Confirmed on dfee4926 — the two new ITs (testFusedKnnTail_whenIncludeNamedQueriesScore_thenMaterializedLegReportsZeroAndRealLegReportsItsScore and testFusedKnnTail_whenAnnLegMatchedBeyondItsWindow_thenTheTruncatedDocLosesTheAnnName) pin both the score-0.0 and the recall bound against the real behavior. Thanks for adding them.
| * In-query fusion in hybrid search. Implement base classes and enable fusion (min_max and arithmetic mean) ([#1933](https://github.com/opensearch-project/neural-search/pull/1933)) | ||
| * In-query fusion in hybrid search. Support nested hybrid queries, search across multiple indices, aggregations, collapse with group expansion, and point in time; refuse fused mode while any node in the cluster is below 3.8.0; refuse `scroll` in fused mode with a validation error (use `point_in_time` instead); cap the leg sub-searches one request may fan out with the `plugins.neural_search.hybrid.fusion.max_leg_searches` cluster setting ([#1943](https://github.com/opensearch-project/neural-search/pull/1943)) | ||
| * In-query fusion in hybrid search. Support `z_score` and `l2` normalization in fused mode, so the whole score-normalization family is available with `arithmetic_mean`; both run the same shared normalization cores as the classic shard-side path ([#1962](https://github.com/opensearch-project/neural-search/pull/1962)) | ||
| * In-query fusion in hybrid search. Report `matched_queries` for a sub-query carrying `_name`, matching classic hybrid: named legs are registered on the shard even when the non-scoring Tail is not built, and a materialized kNN/neural leg keeps its `_name` |
There was a problem hiding this comment.
Two small things:
- This reads "matching classic hybrid", but for materialized kNN/neural legs the
include_named_queries_scorevalue and recall differ (see the orchestrator comment) — a short caveat here would keep it from being read as full parity. - The sibling fusion entries (Implement base classes and enable fusion for hybrid query (limited to min_max and arithmetic mean) #1933/In-query fusion advanced query features #1943/[Fusion] Wire z_score and l2 into fused mode #1962) each end with a
([#1967](...))-style PR link; this line is missing it.
There was a problem hiding this comment.
ack, will correct this entry
There was a problem hiding this comment.
The updated entry reads well — dropping "matching classic hybrid" for the caveat about the returned-docs bound and the substitute score under include_named_queries_score, plus the #1967 link, covers both points. Thanks.
| // thing that converts legs. When the Tail is not built, carry the same leg forms for registration alone: the fetch | ||
| // phase re-evaluates every named query from its own weights, so nothing has to execute for one to be reported. | ||
| boolean namesOnly = tailNeeded == false && anyLegNamed(legs); | ||
| List<QueryBuilder> legsInTailForm = tailNeeded || namesOnly ? legQueriesForTail(legs, legHits) : List.of(); |
There was a problem hiding this comment.
In the names-only path legsInTailForm carries all legs, and registerNamedOnlyQueries then calls toQuery() on each — including unnamed, non-materializable legs that a Top-only query would otherwise never compile on the shard. For a request with one named leg + one unnamed heavy leg (e.g. script_score), that's an extra shard-side compile per unnamed leg for no reporting benefit. Bounded by max_leg_searches so minor — but would filtering the name-only carry to legs that actually render a _name be worth it, or is avoiding a re-decision of named-ness at conversion time the deliberate tradeoff?
There was a problem hiding this comment.
It's not a deliberate tradeoff. you and @bzhangam landed on the same line from opposite ends. You're counting the wasted shard-side toQuery compile per unnamed leg; his concern was that a carried model-resolving leg re-runs its rewrite and pays a second ML inference on the coordinator. One filter fixes both, so it's going in this PR.
anyLegNamed already computes the per-leg predicate, so this is roughly ten lines: filter legsInTailForm on queryName() != null || rendersQueryName(leg) in the names-only branch. It has to be that predicate rather than the shallow queryName() check, or a _name nested under nested/function_score/a knn filter gets dropped. Semantics-preserving: AbstractQueryBuilder.toQuery registers only when a name is set, and namedOnlyQueries never reaches buildSelfErasedQuery(). It also removes the mutually-exclusive dual ternary the bot flagged, since the two branches stop sharing one list.
There was a problem hiding this comment.
The namedLegsForRegistration + per-leg carriesQueryName refactor is exactly it: unnamed legs are no longer carried or compiled on the shard, and as a bonus it removes the extra neural_sparse coordinator inference in the unnamed Top-only case @bzhangam raised. Nice touch pinning the later-named-leg hit alignment (testBuildFusedQuery_whenOnlyALaterLegIsNamed_thenTheSubstituteAddressesThatLegsOwnHits) — that's the exact off-by-one the refactor could have introduced.
| this.innerHitsQueries = in.readNamedWriteableList(QueryBuilder.class); | ||
| // No wire-version gate: this query is built only for a cluster whose every node supports fused mode (see | ||
| // HybridQueryBuilder#requireClusterSupportsFusedMode), and it has never shipped in a released version. | ||
| this.namedOnlyQueries = in.readNamedWriteableList(QueryBuilder.class); |
There was a problem hiding this comment.
namedOnlyQueries is (de)serialized unconditionally with no TransportVersion gate. This looks fine for the released contract (type is new-in-3.8.0, version-gated via requireClusterSupportsFusedMode, never shipped). The one residual case is a mixed cluster of two different 3.8.0 pre-release snapshots — both pass the min-version gate but disagree on wire format, so the old reader stops after innerHitsQueries and the trailing list corrupts the stream. Worth a one-line note in the PR description so the unconditional read is a recorded decision.
There was a problem hiding this comment.
makes sense, will do the change
There was a problem hiding this comment.
The min-supported-version round-trip test (testSerializationRoundTrip_whenStreamPinnedToMinimumSupportedVersion_thenAllThreeListsSurvive) is a good way to record the wire contract. The one case it doesn't cover is old-reader/new-writer across two different pre-release 3.8.0 snapshots — but that's unreleased and low-risk, so fine to leave (or a one-line note in the description if you want it on record). No blocker either way.
| // another object makes every render fail, and this method's fail-open would then carry every leg unconditionally. | ||
| try (XContentBuilder builder = XContentFactory.jsonBuilder()) { | ||
| leg.toXContent(builder, ToXContent.EMPTY_PARAMS); | ||
| return builder.toString().contains(QUERY_NAME_KEY); |
There was a problem hiding this comment.
nit: builder.toString() works here (the json builder is byte-backed), but Strings.toString(builder) is the idiomatic way to render an XContentBuilder. Also — already acknowledged in the javadoc, just flagging — the "_name": substring check false-positives on a leg that queries a field literally named _name (e.g. {"term":{"_name":...}}); harmless (one no-op registration), since the shallow queryName() check covers a leg's own name.
There was a problem hiding this comment.
there's no Strings.toString(XContentBuilder) overload in core OpenSearch, form that would compile, Strings.toString(XContentType.JSON, leg), but imo it's worse here: it catches IOException internally and returns an error-JSON string, which contains no "_name":. I incline for keeping existing code as is
There was a problem hiding this comment.
Agreed — withdrawing the suggestion. I checked org.opensearch.core.common.Strings: there's no toString(XContentBuilder) overload, and the toString(MediaType, ToXContent) form catches IOException and returns error-JSON with no "_name":, which would turn the current fail-open into fail-closed and silently drop names. Keeping the existing code is the correct call. Thanks for folding the _name false-positive note into the carriesQueryName javadoc.
Carry only legs that render a _name when the Tail is absent, instead of all legs once any is named: an unnamed leg registers nothing and its shard-side toQuery is pure cost. Pin the three-list wire format at the minimum supported version, and add ITs for the materialized leg's include_named_queries_score value and for its window-bounded recall. Correct the CHANGELOG entry. Signed-off-by: Martin Gaievski <gaievski@amazon.com>
|
Persistent review updated to latest commit dfee492 |
vibrantvarun
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround — all five points are addressed. The materialized-leg score/recall deviation is now pinned by ITs, the CHANGELOG caveat + link are in, the name-only carry is filtered to named legs (which also removes the extra neural_sparse coordinator inference in the unnamed Top-only case), and the serialization round-trip is version-pinned. Confirmed my Strings.toString nit was wrong — that form would flip the fail-open detection to fail-closed. LGTM.
eaffa13
into
opensearch-project:feature/fusion-hybrid-query
Description
In fused mode (
hybridwith thefusionparameter),matched_queriessilently disappears from the responsefor a sub-query that carries
_name. Today hybrid reports it in every configuration. This PR closes bothhalves of that gap.
matched_queriesis a fetch-phase field built from the names registered while the query is converted on theshard (
QueryShardContext#addNamedQuery→ParsedQuery#namedFilters());MatchedQueriesPhasethen builds its ownWeightper registered name and evaluates it per hit. So a named sub-query has to be registered,never executed, for its name to be reported.
Fused mode converted legs only via the non-scoring Tail, and the coordinator passed an empty leg list whenever
the Tail was not built (
needsTailfalse). The result is an emptynamedFilters(), soMatchedQueriesPhase.getProcessorreturnsnulland the field is absent — at HTTP 200, with no warning. Theboundary is a request-shape detail rather than anything the user said about names:
track_total_hitsone abovethe fused window reports names, exactly at the window does not. Same for the other three Tail triggers
(aggregations, highlight, non-
_scoresort, collapse expansion) — turning any of them on brings the field back.A second, independent loss existed in every configuration, Tail or not: a materializable kNN/neural leg is
replaced by an ids-address substitute, and the substitute was a fresh builder carrying no name.
Examples
Before fix no
matched_queriesanywhere:After fix, and byte-for-byte what classic hybrid returns for the same legs:
Flipping
track_total_hitstotrue— or adding an aggregation, a highlight, a non-_scoresort, or collapseexpansion — brings the field back on the old code, which is what makes this hard to notice: nothing in the
request mentions named queries, and the ranking is unaffected either way.
Related Issues
#1930
Check List
--signoff.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.