diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index d67e7742b625..e9d663c44853 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -7,6 +7,9 @@ http://s.apache.org/luceneversions API Changes --------------------- +* GITHUB#14399: Add a default LeafFieldComparator#disableSkipping method so document skipping can be + disabled per segment. (Serhiy Bzhezytskyy) + * GITHUB#15929: Rename CollectionStatistics to FieldStats and TermStatistics to TermStats. (Zhou Hui) * GITHUB#15763: Deprecate Operations.complement() method. This operation can be slow and is not @@ -190,6 +193,10 @@ Optimizations Bug Fixes --------------------- +* GITHUB#14399: TopFieldCollector now decides per segment whether the search sort is a prefix of the + index sort, instead of caching the decision from the first segment. This fixes incorrect results + when searching a MultiReader that combines indexes with different index sorts. (Serhiy Bzhezytskyy) + * GITHUB#14049: Randomize KNN codec params in RandomCodec. Fixes scalar quantization div-by-zero when all values are identical. (Mike Sokolov) diff --git a/lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java b/lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java index 594d8827882a..04b127c8d19d 100644 --- a/lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java +++ b/lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java @@ -111,4 +111,13 @@ default DocIdSetIterator competitiveIterator() throws IOException { * collector when hits threshold is reached. */ default void setHitsThresholdReached() throws IOException {} + + /** + * Informs this leaf comparator that document skipping should be disabled for this segment. Called + * by {@link TopFieldCollector} when the search sort is a prefix of this segment's index + * sort, so the collector can already terminate early and any extra skipping work in the + * comparator is redundant. This is a per-segment decision: a {@link + * org.apache.lucene.index.MultiReader} may combine segments with different index sorts. + */ + default void disableSkipping() {} } diff --git a/lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java b/lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java index 7b1dabb9c123..250a418abc13 100644 --- a/lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java +++ b/lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java @@ -45,20 +45,18 @@ private abstract class TopFieldLeafCollector implements LeafCollector { final LeafFieldComparator comparator; final int reverseMul; + // Whether the search sort is a prefix of this segment's index sort (decided per segment). + final boolean searchSortPartOfIndexSort; Scorable scorer; boolean collectedAllCompetitiveHits = false; TopFieldLeafCollector(FieldValueHitQueue queue, Sort sort, LeafReaderContext context) throws IOException { - // as all segments are sorted in the same way, enough to check only the 1st segment for - // indexSort - if (searchSortPartOfIndexSort == null) { - final Sort indexSort = context.reader().getMetaData().sort(); - searchSortPartOfIndexSort = canEarlyTerminate(sort, indexSort); - if (searchSortPartOfIndexSort) { - firstComparator.disableSkipping(); - } - } + // Whether the search sort is a prefix of the index sort is decided per segment: a MultiReader + // may combine segments with different index sorts, so this cannot be cached across leaves + // (GITHUB#14399). + final Sort indexSort = context.reader().getMetaData().sort(); + searchSortPartOfIndexSort = canEarlyTerminate(sort, indexSort); LeafFieldComparator[] comparators = queue.getComparators(context); int[] reverseMuls = queue.getReverseMul(); if (comparators.length == 1) { @@ -68,6 +66,12 @@ private abstract class TopFieldLeafCollector implements LeafCollector { this.reverseMul = 1; this.comparator = new MultiLeafFieldComparator(comparators, reverseMuls); } + if (searchSortPartOfIndexSort) { + // Early termination handles this segment; skipping work in the comparator is redundant. + for (LeafFieldComparator comparator : comparators) { + comparator.disableSkipping(); + } + } } void countHit() throws IOException { @@ -304,8 +308,6 @@ public void collect(int doc) throws IOException { final FieldComparator firstComparator; final boolean canSetMinScore; - Boolean searchSortPartOfIndexSort = null; // shows if Search Sort if a part of the Index Sort - // an accumulator that maintains the maximum of the segment's minimum competitive scores final MaxScoreAccumulator minScoreAcc; // the current local minimum competitive score already propagated to the underlying scorer diff --git a/lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java b/lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java index 77d74b240211..7eecf8895bd4 100644 --- a/lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java +++ b/lucene/core/src/java/org/apache/lucene/search/comparators/NumericComparator.java @@ -98,7 +98,7 @@ public void disableSkipping() { public abstract class NumericLeafComparator implements LeafFieldComparator { private final LeafReaderContext context; protected final NumericDocValues docValues; - private final CompetitiveDISIBuilder competitiveDISIBuilder; + private CompetitiveDISIBuilder competitiveDISIBuilder; public NumericLeafComparator(LeafReaderContext context) throws IOException { this.context = context; @@ -106,6 +106,14 @@ public NumericLeafComparator(LeafReaderContext context) throws IOException { this.competitiveDISIBuilder = buildCompetitiveDISIBuilder(); } + @Override + public void disableSkipping() { + // Drop the competitive iterator so this segment is scanned without skipping; the collector + // has determined the search sort is a prefix of this segment's index sort and will terminate + // early on its own. + this.competitiveDISIBuilder = null; + } + protected CompetitiveDISIBuilder buildCompetitiveDISIBuilder() throws IOException { if (pruning == Pruning.NONE) { return null; diff --git a/lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java b/lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java index 406f50612240..8696f448496d 100644 --- a/lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java +++ b/lucene/core/src/java/org/apache/lucene/search/comparators/TermOrdValComparator.java @@ -214,7 +214,7 @@ class TermOrdValLeafComparator implements LeafFieldComparator { /** Which ordinal to use for a missing value. */ final int missingOrd; - private final CompetitiveState competitiveState; + private CompetitiveState competitiveState; private final boolean dense; @@ -491,6 +491,13 @@ private void updateCompetitiveIterator() throws IOException { competitiveState.update(minOrd, maxOrd); } + @Override + public void disableSkipping() { + // Drop the competitive iterator so this segment is scanned without skipping; the collector + // will terminate early on its own because the search sort is a prefix of this segment's sort. + competitiveState = null; + } + @Override public DocIdSetIterator competitiveIterator() { return competitiveState != null ? competitiveState.iterator : null; diff --git a/lucene/core/src/test/org/apache/lucene/search/TestTopFieldCollectorEarlyTermination.java b/lucene/core/src/test/org/apache/lucene/search/TestTopFieldCollectorEarlyTermination.java index d56463468c3d..bc6fa583fa62 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestTopFieldCollectorEarlyTermination.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestTopFieldCollectorEarlyTermination.java @@ -27,8 +27,11 @@ import org.apache.lucene.document.Field.Store; import org.apache.lucene.document.NumericDocValuesField; import org.apache.lucene.document.StringField; +import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.IndexReader; import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.MultiReader; import org.apache.lucene.index.SerialMergeScheduler; import org.apache.lucene.index.Term; import org.apache.lucene.search.IndexSearcher.LeafReaderContextPartition; @@ -41,6 +44,7 @@ import org.apache.lucene.tests.util.LuceneTestCase; import org.apache.lucene.tests.util.TestUtil; import org.apache.lucene.util.Bits; +import org.apache.lucene.util.IOUtils; public class TestTopFieldCollectorEarlyTermination extends LuceneTestCase { @@ -264,4 +268,86 @@ public void testCanEarlyTerminateOnPrefix() { new SortField("c", SortField.Type.LONG), new SortField("b", SortField.Type.STRING)))); } + + /** + * GITHUB#14399: TopFieldCollector caches whether the search sort is a prefix of the index sort + * after inspecting only the first leaf. That is safe for a single index (IndexWriter enforces one + * index sort), but a MultiReader can span indexes with different index sorts. Here the first + * index is sorted so the search sort IS a prefix (early termination is eligible) while the second + * index is sorted the opposite way (it is NOT). The cached "yes" wrongly early-terminates the + * second leaf and drops results that should rank first. + */ + public void testMultiReaderWithDifferentIndexSorts() throws IOException { + final Sort ascSort = new Sort(new SortField("ndv", SortField.Type.LONG)); + + // Index A: sorted ndv ASC, many docs with a moderate value (50). Under an ASC search sort this + // leaf is prefix-sorted, so the collector caches "search sort is part of index sort" = true and + // calls disableSkipping(). + Directory dirA = newDirectory(); + IndexWriterConfig iwcA = newIndexWriterConfig().setIndexSort(ascSort); + try (RandomIndexWriter w = new RandomIndexWriter(random(), dirA, iwcA)) { + for (int i = 0; i < 20; i++) { + Document doc = new Document(); + doc.add(new NumericDocValuesField("ndv", 50L)); + w.addDocument(doc); + } + w.forceMerge(1); + } + + // Index B: sorted ndv DESC (a DIFFERENT index sort). Its most competitive doc for an ASC search + // (value 1) is written LAST, so in docid order the leaf is [90, 90, ..., 1]. If leaf B is + // wrongly + // treated as prefix-sorted (leaf A's cached decision), collection terminates in docid order + // after + // the threshold and never reaches the trailing value 1 -- the true top result is dropped. + Directory dirB = newDirectory(); + IndexWriterConfig iwcB = + newIndexWriterConfig() + .setIndexSort(new Sort(new SortField("ndv", SortField.Type.LONG, true))); + try (RandomIndexWriter w = new RandomIndexWriter(random(), dirB, iwcB)) { + for (int i = 0; i < 20; i++) { + Document doc = new Document(); + doc.add(new NumericDocValuesField("ndv", 90L)); + w.addDocument(doc); + } + Document winner = new Document(); + winner.add(new NumericDocValuesField("ndv", 1L)); // the smallest value overall + w.addDocument(winner); + w.forceMerge(1); + } + + DirectoryReader readerA = DirectoryReader.open(dirA); + DirectoryReader readerB = DirectoryReader.open(dirB); + // MultiReader with A first, so the first leaf is the ASC-sorted one (search sort is a prefix -> + // eligible for early termination); the B leaf is DESC-sorted (not a prefix). + MultiReader multiReader = new MultiReader(readerA, readerB); + try { + // Force both leaves into a single slice so one collector (with one shared + // searchSortPartOfIndexSort cache) sees both the ASC-sorted and DESC-sorted leaves. + IndexSearcher searcher = + new IndexSearcher(multiReader) { + @Override + protected LeafSlice[] slices(List leaves) { + List partitions = new ArrayList<>(); + for (LeafReaderContext ctx : leaves) { + partitions.add(LeafReaderContextPartition.createForEntireSegment(ctx)); + } + return new LeafSlice[] {new LeafSlice(partitions)}; + } + }; + // numHits=1, threshold=1: want the single smallest value overall, which is 1 (last docid of + // leaf B). Correct answer is 1; the bug drops it and returns 50 (from leaf A). + TopFieldCollectorManager manager = new TopFieldCollectorManager(ascSort, 1, null, 1); + TopFieldDocs td = searcher.search(new MatchAllDocsQuery(), manager); + + assertEquals(1, td.scoreDocs.length); + long topValue = (long) ((FieldDoc) td.scoreDocs[0]).fields[0]; + assertEquals("the smallest value (1, trailing docid of leaf B) must win", 1L, topValue); + } finally { + multiReader.close(); + readerA.close(); + readerB.close(); + IOUtils.close(dirA, dirB); + } + } }