From 0b430609c14e7961e3635adbf96a0f70e3e5373f Mon Sep 17 00:00:00 2001 From: liangkaiwen Date: Fri, 18 Sep 2026 14:58:09 -0400 Subject: [PATCH 1/5] add param for KNN query parser --- .../apache/solr/schema/DenseVectorField.java | 61 ++++- .../apache/solr/search/vector/KnnQParser.java | 30 ++- .../solr/schema/DenseVectorFieldTest.java | 142 ++++++++++ .../KnnQParserOversampleRerankTest.java | 164 ++++++++++++ .../solr/search/vector/KnnQParserTest.java | 246 ++++++++++++++++++ 5 files changed, 637 insertions(+), 6 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/search/vector/KnnQParserOversampleRerankTest.java diff --git a/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java b/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java index 9ac1aae556bb..3ac4df5d34ae 100644 --- a/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java +++ b/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java @@ -38,8 +38,10 @@ import org.apache.lucene.queries.function.valuesource.ByteKnnVectorFieldSource; import org.apache.lucene.queries.function.valuesource.FloatKnnVectorFieldSource; import org.apache.lucene.search.FieldExistsQuery; +import org.apache.lucene.search.FullPrecisionFloatVectorSimilarityValuesSource; import org.apache.lucene.search.PatienceKnnVectorQuery; import org.apache.lucene.search.Query; +import org.apache.lucene.search.RescoreTopNQuery; import org.apache.lucene.search.SeededKnnVectorQuery; import org.apache.lucene.search.SortField; import org.apache.lucene.search.knn.KnnSearchStrategy; @@ -499,6 +501,28 @@ public Query getKnnVectorQuery( Query seedQuery, EarlyTerminationParams earlyTermination, Integer filteredSearchThreshold) { + return getKnnVectorQuery( + fieldName, + vectorToSearch, + topK, + efSearch, + filterQuery, + seedQuery, + earlyTermination, + filteredSearchThreshold, + 1); + } + + public Query getKnnVectorQuery( + String fieldName, + String vectorToSearch, + int topK, + int efSearch, + Query filterQuery, + Query seedQuery, + EarlyTerminationParams earlyTermination, + Integer filteredSearchThreshold, + int rerankOversample) { if (FLAT_ALGORITHM.equals(knnAlgorithm)) { throw new SolrException( @@ -507,9 +531,20 @@ public Query getKnnVectorQuery( + "Use vectorSimilarity() function queries instead."); } + if (rerankOversample > 1 && vectorEncoding != VectorEncoding.FLOAT32) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "rerankOversample is only supported for FLOAT32 vector encoding; field '" + + fieldName + + "' uses " + + vectorEncoding); + } + DenseVectorParser vectorBuilder = getVectorBuilder(vectorToSearch, DenseVectorParser.BuilderPhase.QUERY); + final int candidateTopK = topK * rerankOversample; + // Create KnnSearchStrategy if filteredSearchThreshold is provided KnnSearchStrategy searchStrategy = null; if (filteredSearchThreshold != null) { @@ -524,12 +559,16 @@ public Query getKnnVectorQuery( ? new SolrKnnFloatVectorQuery( fieldName, vectorBuilder.getFloatVector(), - topK, + candidateTopK, efSearch, filterQuery, searchStrategy) : new SolrKnnFloatVectorQuery( - fieldName, vectorBuilder.getFloatVector(), topK, efSearch, filterQuery); + fieldName, + vectorBuilder.getFloatVector(), + candidateTopK, + efSearch, + filterQuery); break; case BYTE: baseQuery = @@ -537,12 +576,12 @@ public Query getKnnVectorQuery( ? new SolrKnnByteVectorQuery( fieldName, vectorBuilder.getByteVector(), - topK, + candidateTopK, efSearch, filterQuery, searchStrategy) : new SolrKnnByteVectorQuery( - fieldName, vectorBuilder.getByteVector(), topK, efSearch, filterQuery); + fieldName, vectorBuilder.getByteVector(), candidateTopK, efSearch, filterQuery); break; default: throw new SolrException( @@ -560,6 +599,20 @@ public Query getKnnVectorQuery( baseQuery = getEarlyTerminationQuery(baseQuery, earlyTermination); } + // Re-rank the oversampled candidates against the raw full precision vectors and keep topK. + if (rerankOversample > 1) { + // The similarity function is passed explicitly instead of using + // RescoreTopNQuery#createFullPrecisionRescorerQuery, which leaves it null until the search + // resolves it lazily. That would make the query un-printable in the meantime, and + // debugQuery relies on Query#toString. + baseQuery = + new RescoreTopNQuery( + baseQuery, + new FullPrecisionFloatVectorSimilarityValuesSource( + vectorBuilder.getFloatVector(), fieldName, similarityFunction), + topK); + } + return baseQuery; } diff --git a/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java b/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java index eb5335827698..1b582c55bc99 100644 --- a/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java +++ b/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java @@ -43,6 +43,11 @@ public class KnnQParser extends AbstractVectorQParserBase { protected static final String SEED_QUERY = "seedQuery"; protected static final String FILTERED_SEARCH_THRESHOLD = "filteredSearchThreshold"; + // multiplier applied to topK to decide how many candidates to collect before re-ranking them + // down to topK results + protected static final String RERANK_OVERSAMPLE = "rerankOversample"; + protected static final int DEFAULT_RERANK_OVERSAMPLE = 1; + // parameters for PatienceKnnVectorQuery, a version of knn vector query that exits early when HNSW // queue saturates over a {@code #saturationThreshold} for more than {@code #patience} times. protected static final String EARLY_TERMINATION = "earlyTermination"; @@ -102,6 +107,16 @@ public EarlyTerminationParams getEarlyTerminationParams() { return new EarlyTerminationParams(enabled, saturationThreshold, patience); } + public int getRerankOversample() { + final int rerankOversample = localParams.getInt(RERANK_OVERSAMPLE, DEFAULT_RERANK_OVERSAMPLE); + if (rerankOversample < 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "rerankOversample (" + rerankOversample + ") must be >= 1"); + } + return rerankOversample; + } + protected Query getSeedQuery() throws SolrException, SyntaxError { String seed = localParams.get(SEED_QUERY); if (seed == null) return null; @@ -129,6 +144,16 @@ public Query parse() throws SyntaxError { final String vectorToSearch = getVectorToSearch(); final int topK = localParams.getInt(TOP_K, DEFAULT_TOP_K); + final int rerankOversample = getRerankOversample(); + + final int candidateTopK; + try { + candidateTopK = Math.multiplyExact(topK, rerankOversample); + } catch (ArithmeticException e) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "topK (" + topK + ") * rerankOversample (" + rerankOversample + ") overflows an integer"); + } final double efSearchScaleFactor = localParams.getDouble("efSearchScaleFactor", 1.0); if (Double.isNaN(efSearchScaleFactor) || efSearchScaleFactor < 1.0) { @@ -136,7 +161,7 @@ public Query parse() throws SyntaxError { SolrException.ErrorCode.BAD_REQUEST, "efSearchScaleFactor (" + efSearchScaleFactor + ") must be >= 1.0"); } - final int efSearch = (int) Math.round(efSearchScaleFactor * topK); + final int efSearch = (int) Math.round(efSearchScaleFactor * candidateTopK); final Integer filteredSearchThreshold = localParams.getInt(FILTERED_SEARCH_THRESHOLD); @@ -189,7 +214,8 @@ public Query parse() throws SyntaxError { getFilterQuery(), getSeedQuery(), getEarlyTerminationParams(), - filteredSearchThreshold); + filteredSearchThreshold, + rerankOversample); } private BooleanQuery getParentsFilter(String[] parentsFilterQueries) throws SyntaxError { diff --git a/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java b/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java index 3160b4561150..95ff66880496 100644 --- a/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java +++ b/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java @@ -17,6 +17,7 @@ package org.apache.solr.schema; import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.StringContains.containsString; import java.io.ByteArrayOutputStream; import java.util.ArrayList; @@ -26,10 +27,13 @@ import org.apache.lucene.index.VectorEncoding; import org.apache.lucene.index.VectorSimilarityFunction; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.FullPrecisionFloatVectorSimilarityValuesSource; import org.apache.lucene.search.KnnByteVectorQuery; import org.apache.lucene.search.KnnFloatVectorQuery; +import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.PatienceKnnVectorQuery; import org.apache.lucene.search.Query; +import org.apache.lucene.search.RescoreTopNQuery; import org.apache.lucene.search.SeededKnnVectorQuery; import org.apache.lucene.search.knn.KnnSearchStrategy; import org.apache.solr.client.solrj.request.JavaBinUpdateRequestCodec; @@ -43,6 +47,8 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.search.vector.KnnQParser; +import org.apache.solr.search.vector.SolrKnnByteVectorQuery; +import org.apache.solr.search.vector.SolrKnnFloatVectorQuery; import org.apache.solr.update.CommitUpdateCommand; import org.apache.solr.update.processor.UpdateRequestProcessor; import org.apache.solr.update.processor.UpdateRequestProcessorChain; @@ -1312,4 +1318,140 @@ public void flatAlgorithm_getKnnVectorQuery_shouldThrowException() throws Except deleteCore(); } } + + @Test + public void rerankOversampleOne_shouldNotWrapInRescoreQuery() throws Exception { + try { + initCore("solrconfig-basic.xml", "schema-densevector.xml"); + DenseVectorField type = getVectorFieldType("vector"); + + Query query = + type.getKnnVectorQuery("vector", "[2, 1, 3, 4]", 3, 3, null, null, null, null, 1); + + assertTrue(query instanceof SolrKnnFloatVectorQuery); + // no oversampling, so the knn search collects exactly topK candidates + assertEquals(3, ((SolrKnnFloatVectorQuery) query).getK()); + } finally { + deleteCore(); + } + } + + @Test + public void rerankOversampleNotSpecified_shouldBehaveAsOversampleOne() throws Exception { + try { + initCore("solrconfig-basic.xml", "schema-densevector.xml"); + DenseVectorField type = getVectorFieldType("vector"); + + Query withoutOversample = + type.getKnnVectorQuery("vector", "[2, 1, 3, 4]", 3, 3, null, null, null, null); + Query withOversampleOne = + type.getKnnVectorQuery("vector", "[2, 1, 3, 4]", 3, 3, null, null, null, null, 1); + + assertEquals(withOversampleOne, withoutOversample); + } finally { + deleteCore(); + } + } + + @Test + public void rerankOversampleGreaterThanOne_shouldWrapInFullPrecisionRescoreQuery() + throws Exception { + try { + initCore("solrconfig-basic.xml", "schema-densevector.xml"); + DenseVectorField type = getVectorFieldType("vector"); + + Query query = + type.getKnnVectorQuery("vector", "[2, 1, 3, 4]", 3, 6, null, null, null, null, 2); + + // the knn search collects topK * rerankOversample candidates, which are then re-ranked + // against the raw full precision vectors and trimmed back down to topK + float[] target = new float[] {2, 1, 3, 4}; + Query expected = + new RescoreTopNQuery( + new SolrKnnFloatVectorQuery("vector", target, 6, 6, null), + new FullPrecisionFloatVectorSimilarityValuesSource( + target, "vector", VectorSimilarityFunction.COSINE), + 3); + + assertTrue(query instanceof RescoreTopNQuery); + assertEquals(expected, query); + } finally { + deleteCore(); + } + } + + @Test + public void rerankOversampleGreaterThanOne_byteEncoding_shouldThrowException() throws Exception { + try { + initCore("solrconfig-basic.xml", "schema-densevector.xml"); + DenseVectorField type = getVectorFieldType("vector_byte_encoding"); + + SolrException e = + expectThrows( + SolrException.class, + () -> + type.getKnnVectorQuery( + "vector_byte_encoding", "[2, 1, 3, 4]", 3, 6, null, null, null, null, 2)); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code()); + assertThat( + e.getMessage(), + containsString( + "rerankOversample is only supported for FLOAT32 vector encoding; field 'vector_byte_encoding' uses BYTE")); + } finally { + deleteCore(); + } + } + + @Test + public void rerankOversampleOne_byteEncoding_shouldNotThrow() throws Exception { + try { + initCore("solrconfig-basic.xml", "schema-densevector.xml"); + DenseVectorField type = getVectorFieldType("vector_byte_encoding"); + + Query query = + type.getKnnVectorQuery( + "vector_byte_encoding", "[2, 1, 3, 4]", 3, 3, null, null, null, null, 1); + + assertTrue(query instanceof SolrKnnByteVectorQuery); + assertEquals(3, ((SolrKnnByteVectorQuery) query).getK()); + } finally { + deleteCore(); + } + } + + @Test + public void rerankOversampleGreaterThanOne_withSeedQuery_shouldRescoreOutermost() + throws Exception { + try { + initCore("solrconfig-basic.xml", "schema-densevector.xml"); + DenseVectorField type = getVectorFieldType("vector"); + Query seedQuery = new MatchAllDocsQuery(); + + Query query = + type.getKnnVectorQuery("vector", "[2, 1, 3, 4]", 3, 6, null, seedQuery, null, null, 2); + + // the re-ranking wraps the seeded query, so that it re-ranks whatever the knn phase returned + float[] target = new float[] {2, 1, 3, 4}; + Query expected = + new RescoreTopNQuery( + SeededKnnVectorQuery.fromFloatQuery( + new SolrKnnFloatVectorQuery("vector", target, 6, 6, null), seedQuery), + new FullPrecisionFloatVectorSimilarityValuesSource( + target, "vector", VectorSimilarityFunction.COSINE), + 3); + + assertTrue(query instanceof RescoreTopNQuery); + assertEquals(expected, query); + } finally { + deleteCore(); + } + } + + private DenseVectorField getVectorFieldType(String fieldName) { + IndexSchema schema = h.getCore().getLatestSchema(); + SchemaField schemaField = schema.getField(fieldName); + assertNotNull(schemaField); + return (DenseVectorField) schemaField.getType(); + } } diff --git a/solr/core/src/test/org/apache/solr/search/vector/KnnQParserOversampleRerankTest.java b/solr/core/src/test/org/apache/solr/search/vector/KnnQParserOversampleRerankTest.java new file mode 100644 index 000000000000..4c6c9ce3bcdb --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/vector/KnnQParserOversampleRerankTest.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.search.vector; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.common.params.CommonParams; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Tests the {@code rerankOversample} local param of the {@code knn} query parser against a + * quantized dense vector field, where the HNSW search ranks candidates using lossy quantized + * vectors and the re-ranking phase re-scores them against the raw full precision vectors. + */ +public class KnnQParserOversampleRerankTest extends SolrTestCaseJ4 { + + private static final String IDField = "id"; + + /** Not quantized: the knn search already scores against the raw vectors. */ + private static final String exactField = "vector"; + + /** 4 bit scalar quantized: the knn search scores against lossy quantized vectors. */ + private static final String quantizedField = "v_scalar_half_byte"; + + /** + * The 5 nearest neighbours of {@code [1.0, 2.0, 3.0, 4.0]} by exact cosine similarity. The top + * four are separated by less than 0.003, so they are easily reordered by quantization. + */ + private static final String[] EXPECTED_EXACT_TOP_5 = + new String[] { + "//result[@numFound='5']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='2']", + "//result/doc[4]/str[@name='id'][.='10']", + "//result/doc[5]/str[@name='id'][.='3']" + }; + + @Before + public void prepareIndex() throws Exception { + initCore("solrconfig_codec.xml", "schema-densevector-quantized.xml"); + + for (SolrInputDocument doc : prepareDocs()) { + assertU(adoc(doc)); + } + assertU(commit()); + } + + /** Indexes the same 10 vectors into both an exact and a quantized field. */ + private List prepareDocs() { + List> vectors = + List.of( + Arrays.asList(1f, 2f, 3f, 4f), // id 1, cosine = 1.0 + Arrays.asList(1.5f, 2.5f, 3.5f, 4.5f), // id 2, cosine = 0.998 + Arrays.asList(7.5f, 15.5f, 17.5f, 22.5f), // id 3, cosine = 0.992 + Arrays.asList(1.4f, 2.4f, 3.4f, 4.4f), // id 4, cosine = 0.999 + Arrays.asList(30f, 22f, 35f, 20f), // id 5, cosine = 0.862 + Arrays.asList(40f, 1f, 1f, 200f), // id 6, cosine = 0.756 + Arrays.asList(5f, 10f, 20f, 40f), // id 7, cosine = 0.970 + Arrays.asList(120f, 60f, 30f, 15f), // id 8, cosine = 0.515 + Arrays.asList(200f, 50f, 100f, 25f), // id 9, cosine = 0.554 + Arrays.asList(1.8f, 2.5f, 3.7f, 4.9f)); //id 10, cosine = 0.997 + + List docs = new ArrayList<>(vectors.size()); + for (int i = 0; i < vectors.size(); i++) { + SolrInputDocument doc = new SolrInputDocument(); + doc.addField(IDField, i + 1); + doc.addField(exactField, vectors.get(i)); + doc.addField(quantizedField, vectors.get(i)); + docs.add(doc); + } + return docs; + } + + @After + public void cleanUp() { + clearIndex(); + deleteCore(); + } + + @Test + public void exactField_isTheReferenceRanking() { + // sanity check: the un-quantized field produces the exact cosine ranking + assertQ( + req(CommonParams.Q, "{!knn f=" + exactField + " topK=5}[1.0, 2.0, 3.0, 4.0]", "fl", "id"), + EXPECTED_EXACT_TOP_5); + } + + @Test + public void rerankOversampledQuantizedSearch_shouldRestoreExactRanking() { + // topK * rerankOversample = 25 candidates covers all 10 documents, so every document is + // re-scored against its raw vector: the ranking must match the exact one, whatever the + // quantized vectors ranked them as + assertQ( + req( + CommonParams.Q, + "{!knn f=" + quantizedField + " topK=5 rerankOversample=5}[1.0, 2.0, 3.0, 4.0]", + "fl", + "id"), + EXPECTED_EXACT_TOP_5); + } + + @Test + public void rerankOversampledQuantizedSearch_shouldScoreWithRawVectors() { + // the re-ranked score is the exact cosine similarity of the raw vector, so the best hit + // scores identically to the same document on the un-quantized field + assertQ( + req( + CommonParams.Q, + "{!knn f=" + quantizedField + " topK=1 rerankOversample=10}[1.0, 2.0, 3.0, 4.0]", + "fl", + "id,score"), + "//result[@numFound='1']", + "//result/doc[1]/str[@name='id'][.='1']", + // cosine of a vector with itself, as computed by Lucene's COSINE similarity function + "//result/doc[1]/float[@name='score'][.='1.0']"); + } + + @Test + public void rerankOversampleOnQuantizedField_shouldReturnExactlyTopK() { + assertQ( + req( + CommonParams.Q, + "{!knn f=" + quantizedField + " topK=3 rerankOversample=4}[1.0, 2.0, 3.0, 4.0]", + "fl", + "id"), + "//result[@numFound='3']"); + } + + @Test + public void rerankOversampleOnQuantizedField_withPreFilter_shouldReturnTopKFilteredResults() { + assertQ( + req( + CommonParams.Q, + "{!knn f=" + + quantizedField + + " topK=3 rerankOversample=5 preFilter='id:(1 4 7 8 9 10)'}[1.0, 2.0, 3.0, 4.0]", + "fl", + "id"), + "//result[@numFound='3']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='10']"); + } +} diff --git a/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java b/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java index 1350b94b83e1..724edc7bafcd 100644 --- a/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java @@ -197,6 +197,252 @@ public void efSearchScaleFactorSet_shouldWorkCorrectly() { "//result/doc[5]/str[@name='id'][.='3']"); } + @Test + public void incorrectOversample_shouldThrowException() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQEx( + "String rerankOversample should throw Exception", + "For input string: \"string\"", + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=string}" + vectorToSearch, + "fl", + "id"), + SolrException.ErrorCode.BAD_REQUEST); + + assertQEx( + "Double rerankOversample should throw Exception", + "For input string: \"2.5\"", + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=2.5}" + vectorToSearch, + "fl", + "id"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void rerankOversampleLessThanOne_shouldThrowException() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQEx( + "rerankOversample = 0 should throw Exception", + "rerankOversample (0) must be >= 1", + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=0}" + vectorToSearch, + "fl", + "id"), + SolrException.ErrorCode.BAD_REQUEST); + + assertQEx( + "Negative rerankOversample should throw Exception", + "rerankOversample (-1) must be >= 1", + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=-1}" + vectorToSearch, + "fl", + "id"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void rerankOversampleOverflowingTopK_shouldThrowException() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQEx( + "topK * rerankOversample overflowing an integer should throw Exception", + "topK (2000000000) * rerankOversample (3) overflows an integer", + req( + CommonParams.Q, + "{!knn f=vector topK=2000000000 rerankOversample=3}" + vectorToSearch, + "fl", + "id"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void rerankOversampleOnByteEncodedField_shouldThrowException() { + String vectorToSearch = "[1, 2, 3, 4]"; + + assertQEx( + "rerankOversample on a BYTE encoded field should throw Exception", + "rerankOversample is only supported for FLOAT32 vector encoding; field 'vector_byte_encoding' uses BYTE", + req( + CommonParams.Q, + "{!knn f=vector_byte_encoding topK=3 rerankOversample=2}" + vectorToSearch, + "fl", + "id"), + SolrException.ErrorCode.BAD_REQUEST); + } + + @Test + public void rerankOversampleOneOnByteEncodedField_shouldNotThrow() { + String vectorToSearch = "[1, 2, 3, 4]"; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector_byte_encoding topK=3 rerankOversample=1}" + vectorToSearch, + "fl", + "id"), + "//result[@numFound='3']"); + } + + @Test + public void rerankOversampleSet_shouldReturnTopKResults() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + // rerankOversample widens the candidate pool, but exactly topK results are returned + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=3}" + vectorToSearch, + "fl", + "id"), + "//result[@numFound='5']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='2']", + "//result/doc[4]/str[@name='id'][.='10']", + "//result/doc[5]/str[@name='id'][.='3']"); + } + + @Test + public void rerankOversampleOnNonQuantizedField_shouldNotChangeRanking() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + // 'vector' is not quantized, so the knn search already scores against the raw vectors and + // re-ranking them cannot reorder anything: oversampling must be a no-op on the final ranking + String[] expected = + new String[] { + "//result[@numFound='5']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='2']", + "//result/doc[4]/str[@name='id'][.='10']", + "//result/doc[5]/str[@name='id'][.='3']" + }; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=1}" + vectorToSearch, + "fl", + "id"), + expected); + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=4}" + vectorToSearch, + "fl", + "id"), + expected); + } + + @Test + public void rerankOversampleWithPreFilter_shouldReturnTopKFilteredResults() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=4 rerankOversample=3 preFilter='id:(1 4 7 8 9 10)'}" + + vectorToSearch, + "fl", + "id"), + "//result[@numFound='4']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='10']", + "//result/doc[4]/str[@name='id'][.='7']"); + } + + @Test + public void rerankOversampleWithSeedQuery_shouldReturnTopKResults() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=4 rerankOversample=3 seedQuery='id:(1 4 7 8 9)'}" + vectorToSearch, + "fl", + "id"), + "//result[@numFound='4']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='2']", + "//result/doc[4]/str[@name='id'][.='10']"); + } + + @Test + public void rerankOversampleWithEarlyTermination_shouldReturnTopKResults() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=3 earlyTermination=true saturationThreshold=0.989 patience=10}" + + vectorToSearch, + "fl", + "id"), + "//result[@numFound='5']"); + } + + @Test + public void rerankOversampleWithDebugQuery_matchingNoDocs_shouldNotThrow() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + // debugQuery stringifies the parsed query. The re-ranking query resolves its similarity + // function lazily during the search, which never happens when nothing matches, so the + // similarity function has to be set up front or toString blows up here. + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=3 preFilter='id:nonexistent'}" + vectorToSearch, + "fl", + "id", + CommonParams.DEBUG_QUERY, + "true"), + "//result[@numFound='0']", + "//str[@name='parsedquery_toString']"); + } + + @Test + public void rerankOversampleWithDebugQuery_shouldNotThrow() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=3}" + vectorToSearch, + "fl", + "id", + CommonParams.DEBUG_QUERY, + "true"), + "//result[@numFound='5']", + "//str[@name='parsedquery_toString']"); + } + + @Test + public void rerankOversampleWithEfSearchScaleFactor_shouldReturnTopKResults() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=2 efSearchScaleFactor=2.0}" + vectorToSearch, + "fl", + "id"), + "//result[@numFound='5']", + "//result/doc[1]/str[@name='id'][.='1']", + "//result/doc[2]/str[@name='id'][.='4']", + "//result/doc[3]/str[@name='id'][.='2']", + "//result/doc[4]/str[@name='id'][.='10']", + "//result/doc[5]/str[@name='id'][.='3']"); + } + @Test public void topKMissing_shouldReturnDefaultTopK() { String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; From 7f411302341676212bd540f4bc13714d48d0233b Mon Sep 17 00:00:00 2001 From: liangkaiwen Date: Fri, 18 Sep 2026 15:03:47 -0400 Subject: [PATCH 2/5] rerank behavior with diversifying child query --- .../apache/solr/schema/DenseVectorField.java | 27 +++++++--- .../apache/solr/search/vector/KnnQParser.java | 31 +++++++++--- .../BlockJoinNestedVectorsQParserTest.java | 50 +++++++++++++++++++ 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java b/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java index 3ac4df5d34ae..2af3343cf766 100644 --- a/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java +++ b/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java @@ -492,6 +492,24 @@ public ValueSource getValueSource(SchemaField field, QParser parser) { SolrException.ErrorCode.BAD_REQUEST, "Vector encoding not supported for function queries."); } + /** + * Re-ranking compares candidates against the raw full precision vectors, which is only possible + * for FLOAT32 encoded fields: a BYTE encoded field has no higher precision representation to + * re-rank against. + * + * @throws SolrException if oversampling was requested for a field that cannot support it + */ + public void checkRerankOversampleSupported(String fieldName, int rerankOversample) { + if (rerankOversample > 1 && vectorEncoding != VectorEncoding.FLOAT32) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "rerankOversample is only supported for FLOAT32 vector encoding; field '" + + fieldName + + "' uses " + + vectorEncoding); + } + } + public Query getKnnVectorQuery( String fieldName, String vectorToSearch, @@ -531,14 +549,7 @@ public Query getKnnVectorQuery( + "Use vectorSimilarity() function queries instead."); } - if (rerankOversample > 1 && vectorEncoding != VectorEncoding.FLOAT32) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "rerankOversample is only supported for FLOAT32 vector encoding; field '" - + fieldName - + "' uses " - + vectorEncoding); - } + checkRerankOversampleSupported(fieldName, rerankOversample); DenseVectorParser vectorBuilder = getVectorBuilder(vectorToSearch, DenseVectorParser.BuilderPhase.QUERY); diff --git a/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java b/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java index 1b582c55bc99..2faa7516f12d 100644 --- a/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java +++ b/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java @@ -20,7 +20,9 @@ import org.apache.lucene.index.VectorEncoding; import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.FullPrecisionFloatVectorSimilarityValuesSource; import org.apache.lucene.search.Query; +import org.apache.lucene.search.RescoreTopNQuery; import org.apache.lucene.search.join.BitSetProducer; import org.apache.lucene.search.join.DiversifyingChildrenByteKnnVectorQuery; import org.apache.lucene.search.join.DiversifyingChildrenFloatKnnVectorQuery; @@ -186,19 +188,36 @@ public Query parse() throws SyntaxError { req, subQuery(allParentsQuery, null).getQuery()); final BooleanQuery acceptedParents = getParentsFilter(parentsFilterQueries); + denseVectorType.checkRerankOversampleSupported(vectorField, rerankOversample); + Query acceptedChildren = getChildrenFilter(getFilterQuery(), acceptedParents, allParentsBitSet); switch (vectorEncoding) { case FLOAT32: - return new DiversifyingChildrenFloatKnnVectorQuery( + // The diversifying query returns the best matching child per parent, so collecting + // candidateTopK of them and re-ranking down to topK only ever narrows an already + // diversified set: at most one child per parent is preserved. Note that which child + // represents a parent is still picked using the (possibly quantized) approximate score, + // re-ranking only reorders the representatives that were chosen. + final float[] target = vectorBuilder.getFloatVector(); + final Query diversified = + new DiversifyingChildrenFloatKnnVectorQuery( + vectorField, target, acceptedChildren, candidateTopK, allParentsBitSet); + if (rerankOversample <= 1) { + return diversified; + } + return new RescoreTopNQuery( + diversified, + new FullPrecisionFloatVectorSimilarityValuesSource( + target, vectorField, denseVectorType.getSimilarityFunction()), + topK); + case BYTE: + return new DiversifyingChildrenByteKnnVectorQuery( vectorField, - vectorBuilder.getFloatVector(), + vectorBuilder.getByteVector(), acceptedChildren, - topK, + candidateTopK, allParentsBitSet); - case BYTE: - return new DiversifyingChildrenByteKnnVectorQuery( - vectorField, vectorBuilder.getByteVector(), acceptedChildren, topK, allParentsBitSet); default: throw new SolrException( SolrException.ErrorCode.SERVER_ERROR, diff --git a/solr/core/src/test/org/apache/solr/search/join/BlockJoinNestedVectorsQParserTest.java b/solr/core/src/test/org/apache/solr/search/join/BlockJoinNestedVectorsQParserTest.java index 1243d4f7fae2..8f8b2e0da4e1 100644 --- a/solr/core/src/test/org/apache/solr/search/join/BlockJoinNestedVectorsQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/join/BlockJoinNestedVectorsQParserTest.java @@ -212,6 +212,56 @@ public void parentRetrievalFloat_knnChildrenWithNoDiversifying_shouldReturnOnePa "//result/doc[1]/str[@name='id'][.='10']"); } + @Test + public void parentRetrievalFloat_knnChildrenWithRerankOversample_shouldReturnKnnParents() { + // 'vector' is not quantized, so the knn search already scores against the raw vectors and + // re-ranking cannot reorder anything: oversampling must not change the result + assertQ( + req( + "q", "{!parent which=$allParents score=max v=$children.q}", + "fl", "id,score", + "children.q", + "{!knn f=vector topK=3 rerankOversample=4 childrenOf=$allParents}" + + FLOAT_QUERY_VECTOR, + "allParents", "parent_s:[* TO *]"), + "//*[@numFound='3']", + "//result/doc[1]/str[@name='id'][.='10']", + "//result/doc[2]/str[@name='id'][.='9']", + "//result/doc[3]/str[@name='id'][.='8']"); + } + + @Test + public void childrenRetrievalFloat_knnChildrenWithRerankOversample_shouldStayDiversified() { + // Collecting topK * rerankOversample candidates and re-ranking down to topK only narrows an + // already diversified set, so at most one child per parent survives: the closest child of + // each of the three nearest parents, and never two children of the same parent + assertQ( + req( + "q", + "{!knn f=vector topK=3 rerankOversample=4 childrenOf=$allParents}" + + FLOAT_QUERY_VECTOR, + "fl", "id", + "allParents", "parent_s:[* TO *]"), + "//*[@numFound='3']", + "//result/doc[1]/str[@name='id'][.='102']", + "//result/doc[2]/str[@name='id'][.='92']", + "//result/doc[3]/str[@name='id'][.='82']"); + } + + @Test + public void parentRetrievalByte_knnChildrenWithRerankOversample_shouldThrowException() { + assertQEx( + "rerankOversample is only supported for FLOAT32 vector encoding", + req( + "q", "{!parent which=$allParents score=max v=$children.q}", + "fl", "id,score", + "children.q", + "{!knn f=vector_byte topK=3 rerankOversample=2 childrenOf=$allParents}" + + BYTE_QUERY_VECTOR, + "allParents", "parent_s:[* TO *]"), + 400); + } + @Test public void parentRetrievalFloat_knnChildrenWithParentFilter_shouldReturnKnnParents() { assertQ( From e81acf52dff1890273cdca0400217eead585a343 Mon Sep 17 00:00:00 2001 From: liangkaiwen Date: Fri, 18 Sep 2026 15:57:11 -0400 Subject: [PATCH 3/5] update ref guide --- .../query-guide/pages/dense-vector-search.adoc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/solr/solr-ref-guide/modules/query-guide/pages/dense-vector-search.adoc b/solr/solr-ref-guide/modules/query-guide/pages/dense-vector-search.adoc index 879ec4391244..4a31d289d689 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/dense-vector-search.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/dense-vector-search.adoc @@ -338,6 +338,8 @@ https://arxiv.org/abs/1908.10396[Accelerating Large-Scale Inference with Anisotr This vector type is best utilized for data sets consisting of large amounts of high dimensionality vectors. +`NOTE:` Because binary quantization is particularly lossy, it is recommended to use this in conjunction with `rerankOversample` param to improve recall + Here is how a BinaryQuantizedDenseVectorField can be defined in the schema: [source,xml] @@ -481,6 +483,20 @@ Here's an example of a `knn` search using the early termination with input param [source,text] ?q={!knn f=vector topK=10 earlyTermination=true saturationThreshold=0.989 patience=10 efSearchScaleFactor=3.0}[1.0, 2.0, 3.0, 4.0] +`rerankOversample`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: 1 +|=== ++ +If provided, the query will retrieve a candidate pool of documents equal to topK multiplied by the rerankOversample value provided. The candidate set of documents will then be rescored with their raw vector values, and reranked down to a topK result set. + +Recommended for use only with quantized dense vector types, particularly binary quantized. ++ +Accepted values: +Any int >= 1 + `seedQuery`:: + [%autowidth,frame=none] From 042a05c70a44a6861d8e262d1f3e68f84da79ad9 Mon Sep 17 00:00:00 2001 From: liangkaiwen Date: Fri, 18 Sep 2026 17:05:14 -0400 Subject: [PATCH 4/5] add safeguard against bug where pre-filtered query can lead to index out of bounds exception when docs is zero --- .../apache/solr/schema/DenseVectorField.java | 4 +- .../apache/solr/search/vector/KnnQParser.java | 3 +- .../search/vector/SolrRescoreTopNQuery.java | 63 +++++++++++++++++++ .../solr/schema/DenseVectorFieldTest.java | 10 +-- .../solr/search/vector/KnnQParserTest.java | 18 ++++++ 5 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/search/vector/SolrRescoreTopNQuery.java diff --git a/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java b/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java index 2af3343cf766..440de81c8f4f 100644 --- a/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java +++ b/solr/core/src/java/org/apache/solr/schema/DenseVectorField.java @@ -41,7 +41,6 @@ import org.apache.lucene.search.FullPrecisionFloatVectorSimilarityValuesSource; import org.apache.lucene.search.PatienceKnnVectorQuery; import org.apache.lucene.search.Query; -import org.apache.lucene.search.RescoreTopNQuery; import org.apache.lucene.search.SeededKnnVectorQuery; import org.apache.lucene.search.SortField; import org.apache.lucene.search.knn.KnnSearchStrategy; @@ -53,6 +52,7 @@ import org.apache.solr.search.vector.KnnQParser.EarlyTerminationParams; import org.apache.solr.search.vector.SolrKnnByteVectorQuery; import org.apache.solr.search.vector.SolrKnnFloatVectorQuery; +import org.apache.solr.search.vector.SolrRescoreTopNQuery; import org.apache.solr.uninverting.UninvertingReader; import org.apache.solr.util.vector.ByteDenseVectorParser; import org.apache.solr.util.vector.DenseVectorParser; @@ -617,7 +617,7 @@ public Query getKnnVectorQuery( // resolves it lazily. That would make the query un-printable in the meantime, and // debugQuery relies on Query#toString. baseQuery = - new RescoreTopNQuery( + new SolrRescoreTopNQuery( baseQuery, new FullPrecisionFloatVectorSimilarityValuesSource( vectorBuilder.getFloatVector(), fieldName, similarityFunction), diff --git a/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java b/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java index 2faa7516f12d..775c55497e3d 100644 --- a/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java +++ b/solr/core/src/java/org/apache/solr/search/vector/KnnQParser.java @@ -22,7 +22,6 @@ import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.FullPrecisionFloatVectorSimilarityValuesSource; import org.apache.lucene.search.Query; -import org.apache.lucene.search.RescoreTopNQuery; import org.apache.lucene.search.join.BitSetProducer; import org.apache.lucene.search.join.DiversifyingChildrenByteKnnVectorQuery; import org.apache.lucene.search.join.DiversifyingChildrenFloatKnnVectorQuery; @@ -206,7 +205,7 @@ public Query parse() throws SyntaxError { if (rerankOversample <= 1) { return diversified; } - return new RescoreTopNQuery( + return new SolrRescoreTopNQuery( diversified, new FullPrecisionFloatVectorSimilarityValuesSource( target, vectorField, denseVectorType.getSimilarityFunction()), diff --git a/solr/core/src/java/org/apache/solr/search/vector/SolrRescoreTopNQuery.java b/solr/core/src/java/org/apache/solr/search/vector/SolrRescoreTopNQuery.java new file mode 100644 index 000000000000..2e8f3ad782d6 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/search/vector/SolrRescoreTopNQuery.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.search.vector; + +import java.io.IOException; +import org.apache.lucene.search.DoubleValuesSource; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.MatchNoDocsQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.RescoreTopNQuery; + +/** + * A {@link RescoreTopNQuery} that tolerates an inner query matching no documents. + * + *

{@link RescoreTopNQuery#rewrite} unconditionally hands its collected hits to {@code + * DocAndScoreQuery#createDocAndScoreQuery}, which requires at least one hit: it asserts as much, + * and without assertions enabled it reads element zero of an empty array. {@code + * AbstractKnnVectorQuery#rewrite} guards the very same call by returning {@link MatchNoDocsQuery}, + * but the re-ranking query has no equivalent guard, so an oversampled knn query that matches + * nothing (a restrictive {@code preFilter}, say) fails instead of returning no results. + * + *

TODO: remove this class once the guard is added upstream in Lucene and Solr picks up a release + * containing it. + */ +public class SolrRescoreTopNQuery extends RescoreTopNQuery { + + private final Query innerQuery; + private final DoubleValuesSource valuesSource; + private final int n; + + public SolrRescoreTopNQuery(Query query, DoubleValuesSource valuesSource, int n) { + super(query, valuesSource, n); + this.innerQuery = query; + this.valuesSource = valuesSource; + this.n = n; + } + + @Override + public Query rewrite(IndexSearcher indexSearcher) throws IOException { + final Query rewrittenInner = indexSearcher.rewrite(innerQuery); + if (rewrittenInner instanceof MatchNoDocsQuery) { + return rewrittenInner; + } + // Delegate using the already rewritten inner query rather than calling super.rewrite(), which + // would rewrite innerQuery a second time. That matters because rewriting a knn query runs the + // whole vector search; rewriting its result is a no-op. + return new RescoreTopNQuery(rewrittenInner, valuesSource, n).rewrite(indexSearcher); + } +} diff --git a/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java b/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java index 95ff66880496..545a051838bd 100644 --- a/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java +++ b/solr/core/src/test/org/apache/solr/schema/DenseVectorFieldTest.java @@ -33,7 +33,7 @@ import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.PatienceKnnVectorQuery; import org.apache.lucene.search.Query; -import org.apache.lucene.search.RescoreTopNQuery; +import org.apache.solr.search.vector.SolrRescoreTopNQuery; import org.apache.lucene.search.SeededKnnVectorQuery; import org.apache.lucene.search.knn.KnnSearchStrategy; import org.apache.solr.client.solrj.request.JavaBinUpdateRequestCodec; @@ -1367,13 +1367,13 @@ public void rerankOversampleGreaterThanOne_shouldWrapInFullPrecisionRescoreQuery // against the raw full precision vectors and trimmed back down to topK float[] target = new float[] {2, 1, 3, 4}; Query expected = - new RescoreTopNQuery( + new SolrRescoreTopNQuery( new SolrKnnFloatVectorQuery("vector", target, 6, 6, null), new FullPrecisionFloatVectorSimilarityValuesSource( target, "vector", VectorSimilarityFunction.COSINE), 3); - assertTrue(query instanceof RescoreTopNQuery); + assertTrue(query instanceof SolrRescoreTopNQuery); assertEquals(expected, query); } finally { deleteCore(); @@ -1434,14 +1434,14 @@ public void rerankOversampleGreaterThanOne_withSeedQuery_shouldRescoreOutermost( // the re-ranking wraps the seeded query, so that it re-ranks whatever the knn phase returned float[] target = new float[] {2, 1, 3, 4}; Query expected = - new RescoreTopNQuery( + new SolrRescoreTopNQuery( SeededKnnVectorQuery.fromFloatQuery( new SolrKnnFloatVectorQuery("vector", target, 6, 6, null), seedQuery), new FullPrecisionFloatVectorSimilarityValuesSource( target, "vector", VectorSimilarityFunction.COSINE), 3); - assertTrue(query instanceof RescoreTopNQuery); + assertTrue(query instanceof SolrRescoreTopNQuery); assertEquals(expected, query); } finally { deleteCore(); diff --git a/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java b/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java index 724edc7bafcd..cded6787a721 100644 --- a/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java +++ b/solr/core/src/test/org/apache/solr/search/vector/KnnQParserTest.java @@ -390,6 +390,24 @@ public void rerankOversampleWithEarlyTermination_shouldReturnTopKResults() { "//result[@numFound='5']"); } + @Test + public void rerankOversampleWithFilterMatchingNoDocs_shouldReturnNoResults() { + String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; + + // A plain fq is folded into the knn query's pre-filter, and a filter matching nothing makes + // the knn query rewrite to MatchNoDocsQuery. The re-ranking wrapper has to cope with an empty + // candidate set rather than failing. + assertQ( + req( + CommonParams.Q, + "{!knn f=vector topK=5 rerankOversample=3}" + vectorToSearch, + "fq", + "id:nonexistent", + "fl", + "id"), + "//result[@numFound='0']"); + } + @Test public void rerankOversampleWithDebugQuery_matchingNoDocs_shouldNotThrow() { String vectorToSearch = "[1.0, 2.0, 3.0, 4.0]"; From 4bf42f857e80122ed53fbf133de6c3d52d540376 Mon Sep 17 00:00:00 2001 From: liangkaiwen Date: Wed, 23 Sep 2026 17:42:16 -0400 Subject: [PATCH 5/5] update changelog --- changelog/unreleased/SOLR-18424.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 changelog/unreleased/SOLR-18424.yml diff --git a/changelog/unreleased/SOLR-18424.yml b/changelog/unreleased/SOLR-18424.yml new file mode 100644 index 000000000000..df4344db6cf4 --- /dev/null +++ b/changelog/unreleased/SOLR-18424.yml @@ -0,0 +1,11 @@ +title: > + Added `rerankOversample` parameter to the `{!knn}` query parser. The approximate vector search + collects `topK * rerankOversample` candidates, which are then rescored against the raw + full-precision vectors and reranked down to `topK` results. Recommended for quantized dense + vector fields, particularly binary quantized ones, where it recovers recall lost to quantization. +type: added +authors: + - name: Kevin Liang +links: + - name: SOLR-18424 + url: https://issues.apache.org/jira/browse/SOLR-18424