mkierus opened a new issue, #16702:
URL: https://github.com/apache/lucene/issues/16702
### Description
While testing a migration from Elasticsearch 7.10 to OpenSearch, we noticed
that a query with a `MUST_NOT` clause on a multi-valued numeric field runs
several times slower on OpenSearch 3.x than on earlier versions. We first
noticed this by loading the same dataset into Docker containers running each
engine version and running the same reference query against each one. We traced
this back to Lucene: `IndexOrDocValuesQuery` picks the doc-values side instead
of the points side when the rest of the query is very selective and the
excluded value has many owners. We reproduced this on both a real index and a
synthetic example (attached), and checked it across versions: 9.12.1 is
fine, but 10.1.0, 10.3.2, 10.5.0, and even `main` (11.0.0-SNAPSHOT, built
2026-09-22) are all slow. The data and code behind this are below.
Investigated with AI assistance; every claim here is backed by the attached
reproducer or a direct source quote.
**Source**
Here's the relevant code from `IndexOrDocValuesQuery.java` - it hasn't
changed between 10.1.0 and `main`:
```java
public Scorer get(long leadCost) throws IOException {
final long threshold = cost() >>> 3; // cost() =
indexScorerSupplier.cost(): estimated matches via points
if (threshold <= leadCost) {
return indexScorerSupplier.get(leadCost); // points
} else {
return dvScorerSupplier.get(leadCost); // doc values
}
}
public BulkScorer bulkScorer() throws IOException {
return indexScorerSupplier.bulkScorer(); // always points
}
```
The catch is that `BooleanScorerSupplier` fetches `MUST_NOT` clauses through
`get(leadCost)`, not through `bulkScorer()`:
```java
for (ScorerSupplier ss : subs.get(Occur.MUST_NOT)) {
prohibited.add(ss.get(positiveScorerCost));
}
```
So if the rest of the query is very selective (a low `leadCost`) and the
excluded value has a lot of owners (a high `cost()`), you end up on the
doc-values side. Before #13408 (Lucene 10.0.0), the prohibited clause was built
with `w.scorer(context)` instead, which has no cost bound, so it always went
through the points side - which is why 9.12.1 isn't affected.
**Reproducer 1: real index**
We took a real OpenSearch 3.0.0 shard and opened it directly with plain
Lucene 10.1.0, bypassing OpenSearch. It has about 409,845 documents, and a
handful of small segments are made up almost entirely of documents carrying
~470,000 values in one multi-valued integer field. The query is a term match on
another field (matching 6 documents) as `MUST`, plus a `MUST_NOT` on that big
field.
| excluded value | owns the fat docs | top-1 (sort by doc) |
|---|---|---:|
| real user id | yes, nearly all | 316-675 ms |
| 3 other ids | no | 0.5-5 ms |
| points-only `MUST_NOT` | any value | 2-6 ms |
Here's what `hot_threads` shows while one of the slow queries is running:
```
Lucene90DocValuesProducer$VaryingBPVReader.getLongValue
<- Lucene90DocValuesProducer$23.nextValue
<- SortedNumericDocValuesRangeQuery$1$2.matches
<- ReqExclBulkScorer.score (line 60)
```
Breaking the timing down per segment, the cost sits entirely in the segments
made of those fat documents, and it scales with how many values each segment
holds - roughly 3 ns per value.
**Reproducer 2: synthetic (`Repro.java` attached)**
We built an index of N documents, where the last F of them are "fat" - about
50,000 values each, indexed as both `IntPoint` and
`SortedNumericDocValuesField` - and every one of those fat documents contains
the value we're excluding. The query is 3 required terms plus a term that
matches 3 documents, with a `MUST_NOT IndexOrDocValuesQuery` on the excluded
value. We ran it with the matching group sitting at the start, the middle, and
the end of the fat block.
With 200,000 documents, 500 of them fat at 50,000 values each, one segment,
median of 7 runs:
| required doc at | 9.12.1 | 10.1.0 | 10.3.2 | 10.5.0 | main |
|---|---:|---:|---:|---:|---:|
| start of fat block | 0.2 ms | 9.1 ms | 9.7 ms | 9.2 ms | 12.4 ms |
| middle | 0.1 ms | 4.7 ms | 4.8 ms | 4.9 ms | 6.5 ms |
| end (no run follows) | 0.1 ms | 0.2 ms | 0.2 ms | 0.1 ms | 0.1 ms |
| points-only `MUST_NOT` | ~0.2 ms | 0.2-0.3 ms | 0.3 ms | 0.2-0.4 ms |
0.2-0.3 ms |
The cost tracks how many excluded documents follow the required one. We also
tried scoring in 4096-document windows, the way OpenSearch's
`CancellableBulkScorer` does it, by driving
`Weight#bulkScorer`/`ScorerSupplier#bulkScorer` directly - no OpenSearch
involved - and it made no difference.
**Workarounds**
- Mapping the field as `keyword` instead of numeric moves the exclusion onto
postings: 196-326 ms on the real-world query above, versus tens of seconds with
the numeric mapping.
- Turning off doc values (`doc_values: false`) is fast too, but it breaks
any script or aggregation that reads the field.
- Or just use a points-only query for the `MUST_NOT` clause and skip
`IndexOrDocValuesQuery` altogether.
**Attachments**
-
[Repro.java](https://github.com/user-attachments/files/32518126/Repro.java) -
the code for reproducer 2, self-contained, just needs a `lucene-core` jar.
-
[lucene-repro.zip](https://github.com/user-attachments/files/32518112/lucene-repro.zip)
- a small Docker harness (`Dockerfile` + `run.sh`) that runs `Repro.java`
against several Lucene versions in one go, including building `main` from
source. This is what produced the table above.
### Version and environment details
Lucene versions: 9.12.1, 10.1.0, 10.3.2, 10.5.0, main (11.0.0-SNAPSHOT,
shallow-cloned and built 2026-09-22)
Java: OpenJDK 21.0.12 (2026-07-21 LTS) — used to build and run the
reproducer for all of the above
Host: macOS, Docker containers on linux/arm64
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]