-
Notifications
You must be signed in to change notification settings - Fork 25.2k
ES\QL: Fix scoring for non full text functions #124540
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ES\QL: Fix scoring for non full text functions #124540
Conversation
Hi @carlosdelest, I've created a changelog YAML for you. |
…ll-text-functions' into bugfix/esql-fix-scoring-full-text-functions # Conflicts: # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR Overview
This PR fixes scoring inconsistencies for full text functions in ES|QL by introducing a scorable flag and adjusting query boosting accordingly. Key changes include updated query classes with protected asBuilder methods, integration tests validating scoring behavior, and modifications in DSL construction for proper boost application.
Reviewed Changes
File | Description |
---|---|
x-pack/plugin/esql/src/internalClusterTest/java/org/elasticsearch/xpack/esql/plugin/ScoringIT.java | Adds integration tests for scoring behavior |
docs/changelog/124540.yaml | Adds changelog entry for the fix |
x-pack/plugin/esql-core/.../Query.java | Refactors asBuilder to apply boost based on scorable() |
x-pack/plugin/esql-core/.../(various query files) | Changes asBuilder visibility to protected and, where applicable, adjusts scorable implementations |
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/optimizer/rules/physical/local/PushFiltersToSource.java | Uses toQueryBuilder() for query creation and simplifies clause selection |
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Term.java | Updates TermQuery construction to include explicit boolean values for case sensitivity and scoring |
Copilot reviewed 38 out of 38 changed files in this pull request and generated no comments.
Comments suppressed due to low confidence (2)
x-pack/plugin/esql-core/src/main/java/org/elasticsearch/xpack/esql/core/querydsl/query/TermQuery.java:78
- Consider using the '==' operator for comparing the primitive boolean 'scorable' in the equals method to improve clarity and performance.
&& Objects.equals(scorable, other.scorable);
x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/Term.java:144
- [nitpick] Consider adding inline comments or replacing the boolean literals with named constants to clarify the meaning of the 'false' (caseInsensitive) and 'true' (scorable) arguments.
return new TermQuery(source(), ((FieldAttribute) field()).name(), queryAsObject(), false, true);
*/ | ||
public abstract QueryBuilder asBuilder(); | ||
public final QueryBuilder toQueryBuilder() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the method changed to include boosting information according to the query being scorable. asBuilder()
is refactored to a protected method that must be implemented by subclasses.
BoolQueryBuilder boolQuery = boolQuery(); | ||
for (Query query : queries) { | ||
QueryBuilder queryBuilder = query.toQueryBuilder(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
asBuilder()
is replaced by toQueryBuilder()
- see below
Queries.Clause combiningQueryClauseType = queryExec.hasScoring() ? Queries.Clause.MUST : Queries.Clause.FILTER; | ||
var query = Queries.combine(combiningQueryClauseType, asList(queryExec.query(), planQuery)); | ||
QueryBuilder planQuery = queryDSL.toQueryBuilder(); | ||
var query = Queries.combine(Queries.Clause.MUST, asList(queryExec.query(), planQuery)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We go back to use MUST to combine queries, as the queries themselves have boosting to enable or disable scoring
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After thinking about it more, I'm wondering what is the implication here that we always use MUST
query here for all pushdown queries. Is a MUST
query more expensive than a FILTER
query? If this change affects queries that don't have full text functions, and there is potential performance impact, if we can have some performance data before and after this change that will be great.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My reasoning here is that it doesn't matter, as a clause with boosting 0.0 gets rewritten into a ConstantScoreQuery
. This means that score mode does not apply, and that it is equivalent to using a filter (which rewrites to ConstantScoreQuery
as well).
Also, the first query in the queryExec could be executed as a MUST query independently of the combination - the Queries.combine() method just adds the new query as a FILTER clause, but does nothing with the previous existing query AFAIU.
In any case, it makes sense to keep FILTER when scoring is not needed - I will restore the previous code as I'd prefer to deal with query performance as a separate issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have checked with the team, and filters differ in that they can be cached. So I reverted this change in e641e38.
Thanks for raising this!
@@ -94,6 +94,8 @@ public ShapeRelation shapeRelation() { | |||
*/ | |||
public abstract class ShapeQueryBuilder implements QueryBuilder { | |||
|
|||
private float boost = 0.0f; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This change is needed to allow boosting this query, as now all non scoring queries are explicitly set to a boost score of 0
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks good, I did not get the time to test it yet, just left some questions
} | ||
|
||
@Override | ||
public QueryBuilder boost(float boost) { | ||
throw new UnsupportedOperationException("Unimplemented: float"); | ||
this.boost = boost; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we ever expect this to be called with a value different from 0
? should we add an assertion if we always expect the boost to be 0?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now there's no other expectation, but I think it's better to honor the QueryBuilder
interface correctly and check that the boosting is done as expected on the tests.
@@ -38,7 +38,7 @@ public enum StatsType { | |||
|
|||
public record Stat(String name, StatsType type, QueryBuilder query) { | |||
public QueryBuilder filter(QueryBuilder sourceQuery) { | |||
return query == null ? sourceQuery : Queries.combine(Queries.Clause.FILTER, asList(sourceQuery, query)); | |||
return query == null ? sourceQuery : Queries.combine(Queries.Clause.FILTER, asList(sourceQuery, query)).boost(0.0f); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is it necessary to put an explicit boost given that we combine the queries into a boolean query filter?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The combine method does not necessarily transforms both queries into a filter. When the first query is a boolean query, it will be used as the boolean query to use as a first step for the combination - which means that the first query is unchanged to a filter, and thus can have scoring.
@@ -173,7 +173,7 @@ private static class DefaultShardContextForUnmappedField extends DefaultShardCon | |||
} | |||
|
|||
public Function<org.elasticsearch.compute.lucene.ShardContext, Query> querySupplier(QueryBuilder builder) { | |||
QueryBuilder qb = builder == null ? QueryBuilders.matchAllQuery() : builder; | |||
QueryBuilder qb = builder == null ? QueryBuilders.matchAllQuery().boost(0.0f) : builder; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if there is a follow up optimisation we could do here.
In the case where no QueryBuilder
is available and we are using match_all
, when we are adding the score block:
- we would no longer need to retrieve the scores from Lucene (we know they should all be zero), so we no longer need to set the score mode to
COMPLETE
- we could be using
ConstantDoubleVector
to set the score block, which I guess would be using a constant amount of memory, no matter how many docs we retrieve.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
we would no longer need to retrieve the scores from Lucene (we know they should all be zero), so we no longer need to set the score mode to COMPLETE
I believe a similar optimization is done by the MatchAllDocsQuery internally - it uses a ConstantScoreScorer
which I don't think uses the COMPLETE score mode.
we could be using ConstantDoubleVector to set the score block, which I guess would be using a constant amount of memory, no matter how many docs we retrieve.
Yes, that's doable. Right now the Lucene operators do not take that into account. We can do it as a follow up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tested this locally too - with profile enabled - looks good.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @carlosdelest! I tried a few queries with _score
that I can think of, and I didn't find anything questionable or obviously wrong. I added a couple of suggestions.
There is one thing that I can think of, it could be a potential candidate for future improvements, it is related to NOT
. For example, if I have a query like below, the NOT
resets the _score
of each returned row to 0.
from books metadata _score
| where not (match(name, "Space") or length(name) < 0)
Is my understanding correct, that the match query will still collect scores, and the NOT
will discard them? If we can recognize the _score
will be reset to 0 before pushing down the match query, we don't have to ask the match query to return scores back.
public final QueryBuilder toQueryBuilder() { | ||
QueryBuilder builder = asBuilder(); | ||
if (scorable() == false) { | ||
builder.boost(0.0f); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A minor suggestion, we can define a constant variable for 0.0f
, call it something like ESQL_NON_FTF_DEFAULT_BOOST
, 0.0f
is also used in several places in the tests like PhysicalPlanOptimizerTests
they can all reference to the same constant variable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in afab198
var range = wrapWithSingleQuery(query, QueryBuilders.rangeQuery("salary").gt(1000), "salary", source); | ||
var expected = QueryBuilders.boolQuery().must(range).must(exists); | ||
assertThat(expected.toString(), is(esStatsQuery.query().toString())); | ||
var range = wrapWithSingleQuery(query, QueryBuilders.rangeQuery("salary").gt(1000).boost(0.0f), "salary", source); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If there is a utility method to add boost(0.0f)
, it will be handy, it is repeated in quite a few places in the tests. Having it managed in a central place is less error prone.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense. I added a Query.unscore()
method in afab198 that takes into account both of your suggestions here. LMKWYT!
That is correct. We can do that optimization as a follow up 👍 |
…ng-full-text-functions # Conflicts: # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @carlosdelest! I just have one more comment in PushFiltersToSource
, the rest looks good to me.
Queries.Clause combiningQueryClauseType = queryExec.hasScoring() ? Queries.Clause.MUST : Queries.Clause.FILTER; | ||
var query = Queries.combine(combiningQueryClauseType, asList(queryExec.query(), planQuery)); | ||
QueryBuilder planQuery = queryDSL.toQueryBuilder(); | ||
var query = Queries.combine(Queries.Clause.MUST, asList(queryExec.query(), planQuery)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After thinking about it more, I'm wondering what is the implication here that we always use MUST
query here for all pushdown queries. Is a MUST
query more expensive than a FILTER
query? If this change affects queries that don't have full text functions, and there is potential performance impact, if we can have some performance data before and after this change that will be great.
…ng-full-text-functions # Conflicts: # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @carlosdelest , LGTM!
💔 Backport failed
You can use sqren/backport to manually backport by running |
(cherry picked from commit 7950751) # Conflicts: # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/QueryBuilderResolver.java # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java
💚 All backports created successfully
Questions ?Please refer to the Backport tool documentation |
(cherry picked from commit 7950751) # Conflicts: # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryBuilderResolver.java # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java
(cherry picked from commit 7950751) # Conflicts: # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/session/QueryBuilderResolver.java # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/optimizer/LocalPhysicalPlanOptimizerTests.java
(cherry picked from commit 7950751) # Conflicts: # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/action/EsqlCapabilities.java # x-pack/plugin/esql/src/main/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryBuilderResolver.java # x-pack/plugin/esql/src/test/java/org/elasticsearch/xpack/esql/expression/function/fulltext/QueryStringTests.java
When using METADATA _score, there are some inconsistencies with scoring:
WHERE id > 7 OR ratings > 4.5
would add 1.0 to scores for results that match any of those conditions.This PR addressed the above by adding boosting in the
Query
class:scorable()
method that indicates whether a query should be considered for scoring. Full text functions redefine this to betrue
, all other queries havefalse
as default.toQueryBuilder()
method that uses the above information for adding aboost
of 0 to queries that are not scorable. This method uses internally the previousasBuilder()
method that is the one used by queries to provide the corresponding query builder.Changes were done to tests so they take into account the new boosting for queries that do not contribute to score.
Another option I explored was to add queries that do not contribute to score to a
filter
clause instead. This proved more difficult as combining queries was not straightforward, and some resulting boolean queries were hard to simplify (think filters clauses with boolean queries that have filters themselves).