You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by ho...@apache.org on 2026/10/22 00:02:46 UTC
(lucene) 01/07: Add detail to KNN no-match explanations (#16271)
This is an automated email from the ASF dual-hosted git repository.
HoustonPutman pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git
commit 5ab7c5b8199dd19185fd64844b179dd71617c328
Author: Jakub Slowinski <32...@users.noreply.github.com>
AuthorDate: Thu Jul 9 19:50:01 2026 +0100
Add detail to KNN no-match explanations (#16271)
(cherry picked from commit 4b982fffe24f06aed4228645e705afdb1a7b2c80)
---
lucene/CHANGES.txt | 2 +
.../lucene/search/AbstractKnnVectorQuery.java | 60 +++++++++++++-
.../org/apache/lucene/search/DocAndScoreQuery.java | 51 ++++++++++--
.../lucene/search/BaseKnnVectorQueryTestCase.java | 93 ++++++++++++++++++++--
4 files changed, 192 insertions(+), 14 deletions(-)
diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 423fc3eed65..aca9843de45 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -122,6 +122,8 @@ Improvements
* GITHUB#16305: Alter TopGroups.merge() to not return null if no groups. (Binlong Gao)
+* GITHUB#16271: Add detail to KNN no-match explanations. (Jakub Slowinski)
+
* GITHUB#16423: Add (configurable) guards for complexity of expressions. Previously
the expressions parser could throw StackOverflowError on deeply nested expressions
or also cause VerifyError and other LinkageErrors when the expression was to complex.
diff --git a/lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java b/lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java
index 7252c3d9ff7..5113ff0e2ce 100644
--- a/lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java
@@ -147,9 +147,65 @@ abstract class AbstractKnnVectorQuery extends Query {
topK = runSearchTasks(tasks, taskExecutor, perLeafResults, leafReaderContexts);
}
if (topK.scoreDocs.length == 0) {
- return MatchNoDocsQuery.INSTANCE;
+ return new MatchNoDocsQuery("No documents matched the nearest-neighbor search");
}
- return DocAndScoreQuery.createDocAndScoreQuery(reader, topK);
+ return DocAndScoreQuery.createDocAndScoreQuery(
+ reader, topK, noMatchExplainer(topK, filterWeight));
+ }
+
+ /** Builds the explainer for documents this query did not collect, capturing minTopKScore. */
+ private DocAndScoreQuery.NoMatchExplainer noMatchExplainer(TopDocs topK, Weight filterWeight) {
+ // topK is score-descending, so the lowest collected score is the last entry.
+ final float minTopKScore = topK.scoreDocs[topK.scoreDocs.length - 1].score;
+ return (context, doc, topN) ->
+ explainNotCollected(context, doc, topN, filterWeight, minTopKScore);
+ }
+
+ /** Explains why a doc was not collected, by recomputing its score. null when no vectors. */
+ private Explanation explainNotCollected(
+ LeafReaderContext context, int doc, int topN, Weight filterWeight, float minTopKScore)
+ throws IOException {
+ String prefix = "Not in top " + topN + " doc(s): ";
+ FieldInfo fi = context.reader().getFieldInfos().fieldInfo(field);
+ if (fi == null || fi.getVectorDimension() == 0) {
+ return null;
+ }
+ VectorScorer vectorScorer = createVectorScorer(context, fi);
+ if (vectorScorer == null) {
+ return null;
+ }
+ if (vectorScorer.iterator().advance(doc) != doc) {
+ return Explanation.noMatch(prefix + "no vector value in field \"" + field + "\"");
+ }
+ if (filterWeight != null && docPassesFilter(filterWeight, context, doc) == false) {
+ return Explanation.noMatch(prefix + "excluded by filter");
+ }
+ float score = vectorScorer.score();
+ if (score < minTopKScore) {
+ return Explanation.noMatch(
+ prefix + "score " + score + " < minimum top-K score " + minTopKScore);
+ }
+ // Score meets the cutoff but the doc was not collected (tie-break, recall miss, or rescoring).
+ return Explanation.noMatch(
+ prefix
+ + "score "
+ + score
+ + " >= minimum top-K score "
+ + minTopKScore
+ + " (tie-break or approximate-search miss)");
+ }
+
+ private static boolean docPassesFilter(Weight filterWeight, LeafReaderContext context, int doc)
+ throws IOException {
+ Scorer scorer = filterWeight.scorer(context);
+ if (scorer == null) {
+ return false;
+ }
+ TwoPhaseIterator twoPhase = scorer.twoPhaseIterator();
+ if (twoPhase != null) {
+ return twoPhase.approximation().advance(doc) == doc && twoPhase.matches();
+ }
+ return scorer.iterator().advance(doc) == doc;
}
private TopDocs runSearchTasks(
diff --git a/lucene/core/src/java/org/apache/lucene/search/DocAndScoreQuery.java b/lucene/core/src/java/org/apache/lucene/search/DocAndScoreQuery.java
index d86249c778b..f32968c812a 100644
--- a/lucene/core/src/java/org/apache/lucene/search/DocAndScoreQuery.java
+++ b/lucene/core/src/java/org/apache/lucene/search/DocAndScoreQuery.java
@@ -25,16 +25,25 @@ import java.util.List;
import java.util.Objects;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.ReaderUtil;
/** A query that wraps precomputed documents and scores */
class DocAndScoreQuery extends Query {
+ /** Optional hook to explain why a doc is missing. Returns null for the generic message. */
+ @FunctionalInterface
+ interface NoMatchExplainer {
+ Explanation explain(LeafReaderContext context, int doc, int topN) throws IOException;
+ }
+
private final int[] docs;
private final float[] scores;
private final float maxScore;
private final int[] segmentStarts;
private final long visited;
private final Object contextIdentity;
+ // Only used in explain(). Omitted from equals()/hashCode().
+ private final NoMatchExplainer noMatchExplainer;
/**
* Constructor
@@ -57,12 +66,24 @@ class DocAndScoreQuery extends Query {
int[] segmentStarts,
long visited,
Object contextIdentity) {
+ this(docs, scores, maxScore, segmentStarts, visited, contextIdentity, null);
+ }
+
+ DocAndScoreQuery(
+ int[] docs,
+ float[] scores,
+ float maxScore,
+ int[] segmentStarts,
+ long visited,
+ Object contextIdentity,
+ NoMatchExplainer noMatchExplainer) {
this.docs = docs;
this.scores = scores;
this.maxScore = maxScore;
this.segmentStarts = segmentStarts;
this.visited = visited;
this.contextIdentity = contextIdentity;
+ this.noMatchExplainer = noMatchExplainer;
}
@Override
@@ -73,12 +94,21 @@ class DocAndScoreQuery extends Query {
}
return new Weight(this) {
@Override
- public Explanation explain(LeafReaderContext context, int doc) {
+ public Explanation explain(LeafReaderContext context, int doc) throws IOException {
int found = Arrays.binarySearch(docs, doc + context.docBase);
if (found < 0) {
- return Explanation.noMatch("not in top " + docs.length + " docs");
+ // Defer to the originating query for a richer reason, but only when this leaf belongs
+ // to the reader this query was built against.
+ if (noMatchExplainer != null
+ && ReaderUtil.getTopLevelContext(context).id() == contextIdentity) {
+ Explanation enriched = noMatchExplainer.explain(context, doc, docs.length);
+ if (enriched != null) {
+ return enriched;
+ }
+ }
+ return Explanation.noMatch("Not in top " + docs.length + " doc(s)");
}
- return Explanation.match(scores[found] * boost, "within top " + docs.length + " docs");
+ return Explanation.match(scores[found] * boost, "Within top " + docs.length + " doc(s)");
}
@Override
@@ -174,7 +204,7 @@ class DocAndScoreQuery extends Query {
@Override
public String toString(String field) {
- return "DocAndScoreQuery[" + docs[0] + ",...][" + scores[0] + ",...]," + maxScore;
+ return "DocAndScoreQuery[" + docs.length + " doc(s), maxScore=" + maxScore + "]";
}
@Override
@@ -199,6 +229,11 @@ class DocAndScoreQuery extends Query {
}
static Query createDocAndScoreQuery(IndexReader reader, TopDocs topK) {
+ return createDocAndScoreQuery(reader, topK, null);
+ }
+
+ static Query createDocAndScoreQuery(
+ IndexReader reader, TopDocs topK, NoMatchExplainer noMatchExplainer) {
int len = topK.scoreDocs.length;
assert len > 0;
@@ -213,7 +248,13 @@ class DocAndScoreQuery extends Query {
}
int[] segmentStarts = findSegmentStarts(reader.leaves(), docs);
return new DocAndScoreQuery(
- docs, scores, maxScore, segmentStarts, topK.totalHits.value(), reader.getContext().id());
+ docs,
+ scores,
+ maxScore,
+ segmentStarts,
+ topK.totalHits.value(),
+ reader.getContext().id(),
+ noMatchExplainer);
}
static int[] findSegmentStarts(List<LeafReaderContext> leaves, int[] docs) {
diff --git a/lucene/core/src/test/org/apache/lucene/search/BaseKnnVectorQueryTestCase.java b/lucene/core/src/test/org/apache/lucene/search/BaseKnnVectorQueryTestCase.java
index 3a9589f75e3..198ae7ae9f1 100644
--- a/lucene/core/src/test/org/apache/lucene/search/BaseKnnVectorQueryTestCase.java
+++ b/lucene/core/src/test/org/apache/lucene/search/BaseKnnVectorQueryTestCase.java
@@ -164,6 +164,8 @@ abstract class BaseKnnVectorQueryTestCase extends LuceneTestCase {
assertMatches(searcher, kvq, 0);
Query q = searcher.rewrite(kvq);
assertTrue(q instanceof MatchNoDocsQuery);
+ assertEquals(
+ "MatchNoDocsQuery(\"No documents matched the nearest-neighbor search\")", q.toString());
}
}
@@ -435,13 +437,16 @@ abstract class BaseKnnVectorQueryTestCase extends LuceneTestCase {
assertTrue(matched.isMatch());
assertEquals(1 / 2f, matched.getValue());
assertEquals(0, matched.getDetails().length);
- assertEquals("within top 3 docs", matched.getDescription());
+ assertEquals("Within top 3 doc(s)", matched.getDescription());
- Explanation nomatch = searcher.explain(query, 5);
+ // Doc 0 ({0,0}) is farthest from the query {2,3}, so it is reliably ranked out.
+ Explanation nomatch = searcher.explain(query, 0);
assertFalse(nomatch.isMatch());
assertEquals(0f, nomatch.getValue());
assertEquals(0, matched.getDetails().length);
- assertEquals("not in top 3 docs", nomatch.getDescription());
+ assertTrue(
+ nomatch.getDescription(),
+ nomatch.getDescription().startsWith("Not in top 3 doc(s): score "));
}
}
}
@@ -463,13 +468,86 @@ abstract class BaseKnnVectorQueryTestCase extends LuceneTestCase {
assertTrue(matched.isMatch());
assertEquals(1 / 2f, matched.getValue());
assertEquals(0, matched.getDetails().length);
- assertEquals("within top 3 docs", matched.getDescription());
+ assertEquals("Within top 3 doc(s)", matched.getDescription());
- Explanation nomatch = searcher.explain(query, 4);
+ // Doc 0 ({0,0}) is farthest from the query {2,3}, so it is reliably ranked out.
+ Explanation nomatch = searcher.explain(query, 0);
assertFalse(nomatch.isMatch());
assertEquals(0f, nomatch.getValue());
assertEquals(0, matched.getDetails().length);
- assertEquals("not in top 3 docs", nomatch.getDescription());
+ assertTrue(
+ nomatch.getDescription(),
+ nomatch.getDescription().startsWith("Not in top 3 doc(s): score "));
+ }
+ }
+ }
+
+ public void testExplainFiltered() throws IOException {
+ try (Directory d = newDirectoryForTest()) {
+ try (IndexWriter w = new IndexWriter(d, new IndexWriterConfig())) {
+ for (int j = 0; j < 5; j++) {
+ Document doc = new Document();
+ doc.add(getKnnVectorField("field", new float[] {j, j}));
+ doc.add(new IntPoint("tag", j));
+ w.addDocument(doc);
+ }
+ // Doc 5 passes the filter (tag in range) but has no vector.
+ Document noVector = new Document();
+ noVector.add(new IntPoint("tag", 1));
+ w.addDocument(noVector);
+ }
+ try (IndexReader reader = DirectoryReader.open(d)) {
+ IndexSearcher searcher = new IndexSearcher(reader);
+ // Filter to docs 0-2, so docs 3 and 4 cannot be collected.
+ Query filter = IntPoint.newRangeQuery("tag", 0, 2);
+ AbstractKnnVectorQuery query = getKnnVectorQuery("field", new float[] {2, 3}, 3, filter);
+
+ // Doc 4 has a vector but fails the filter.
+ Explanation filtered = searcher.explain(query, 4);
+ assertFalse(filtered.isMatch());
+ assertEquals("Not in top 3 doc(s): excluded by filter", filtered.getDescription());
+
+ // Doc 5 passes the filter but has no vector, so blame the missing vector, not the filter.
+ Explanation noVector = searcher.explain(query, 5);
+ assertFalse(noVector.isMatch());
+ assertEquals(
+ "Not in top 3 doc(s): no vector value in field \"field\"", noVector.getDescription());
+ }
+ }
+ }
+
+ public void testExplainTieBreak() throws IOException {
+ try (Directory d = newDirectoryForTest()) {
+ // All five docs share one vector, so they all score equally. With top k = 3, two are dropped
+ // due to tie-break.
+ try (IndexWriter w = new IndexWriter(d, new IndexWriterConfig())) {
+ for (int j = 0; j < 5; j++) {
+ Document doc = new Document();
+ doc.add(getKnnVectorField("field", new float[] {1, 1}));
+ w.addDocument(doc);
+ }
+ w.forceMerge(1);
+ }
+ try (IndexReader reader = DirectoryReader.open(d)) {
+ IndexSearcher searcher = new IndexSearcher(reader);
+ AbstractKnnVectorQuery query = getKnnVectorQuery("field", new float[] {1, 1}, 3);
+
+ Set<Integer> collected = new HashSet<>();
+ for (ScoreDoc sd : searcher.search(query, 3).scoreDocs) {
+ collected.add(sd.doc);
+ }
+ int dropped = -1;
+ for (int doc = 0; doc < 5; doc++) {
+ if (collected.contains(doc) == false) {
+ dropped = doc;
+ break;
+ }
+ }
+ Explanation nomatch = searcher.explain(query, dropped);
+ assertFalse(nomatch.isMatch());
+ String description = nomatch.getDescription();
+ assertTrue(description, description.startsWith("Not in top 3 doc(s): score "));
+ assertTrue(description, description.endsWith(" (tie-break or approximate-search miss)"));
}
}
}
@@ -1066,7 +1144,8 @@ abstract class BaseKnnVectorQueryTestCase extends LuceneTestCase {
// Since a forceMerge could occur in this test, we must not assert that a specific doc_id is
// matched
// But that instead the string format is expected and that the max score is 1.0
- assertTrue(queryString.matches("DocAndScoreQuery\\[\\d+,...]\\[\\d+.\\d+,...],1.0"));
+ assertTrue(
+ queryString, queryString.matches("DocAndScoreQuery\\[\\d+ doc\\(s\\), maxScore=1.0]"));
}
/**