Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lucene/CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ Other
API Changes
---------------------

* GITHUB#14399: Add a default LeafFieldComparator#disableSkipping method so document skipping can be
disabled per segment. (Serhiy Bzhezytskyy)

* GITHUB#16286: Introduce BinaryDocValues#binaryValues to help speed up the
retrieval of many binary doc values at once. (Costin Leau)

Expand Down Expand Up @@ -402,6 +405,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#16251: Fix UpdateGraphsUtils#computeJoinSet to not request more coverage for a node than
the node's degree. Previously nodes with a degree of 1 were always forced into the join set,
which made the join set degenerate to the whole graph on hub-and-spoke shaped HNSW graphs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em>this segment's</em> 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() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Entry> 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It would be nice to use Sort.getPrimarySortField() here but I think that's going to end up being fairly complex in its interaction with sort prefixes, so we can leave that for a follow-up.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed on leaving it for a follow-up. canEarlyTerminate compares the full prefix, so a primary-field shortcut would have to keep the same answer for multi-field sorts — worth its own change with its own tests.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Measured it since: substituting the shortcut into canEarlyTerminateOnPrefix returns a wrong result — an index sorted (a, c) searched by (a, b) gives the wrong top hit, because documents with equal a are ordered by c so their b order is arbitrary. testCanEarlyTerminateOnPrefix already asserts false for that shape. So the follow-up isn't needed here — the full prefix comparison is load-bearing.

searchSortPartOfIndexSort = canEarlyTerminate(sort, indexSort);
LeafFieldComparator[] comparators = queue.getComparators(context);
int[] reverseMuls = queue.getReverseMul();
if (comparators.length == 1) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,22 @@ 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;
this.docValues = getNumericDocValues(context, field);
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This comment is outdated now, I think?

* 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<LeafReaderContext> leaves) {
List<LeafReaderContextPartition> 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);
}
}
}
Loading