SweetSpotSimilarity lets you mark a range of field lengths as ideal; shorter and longer fields score lower. Lucene's top-k search assumes that, at the same term frequency, a shorter field (a smaller norm) never scores lower. Below the plateau that is false.
- Score field f with
SweetSpotSimilarityandsetLengthNormFactors(10, 20, 0.5f): 10 to 20 terms is ideal (length factor 1.0), a 2-term field gets 1/3. Index 3,000 docs: docs 0-1999 "t" + 9 words (score 1.0), docs 2000-2999 "t t" (0.471), except doc 2150, "t t" + 8 words (1.414, the best hit). - Run
searcher.search(new TermQuery(new Term("f", "t")), 10). You expect doc 2150 first, as a search that counts every hit returns. - You get docs 0-9 (1.0 each); doc 2150 is missing. Lucene stores the field length as the norm and assumes a greater norm never scores higher. Below the plateau it does: norm 2 scores 0.333, norm 10 scores 1.0.
- Trusting that rule, the block of 256 postings that holds doc 2150 keeps only the pair (freq 2, norm 2) and claims a maximum of 0.471. From hit 1,001 on only scores above 1.0 can compete, so the search skips the block.
- No error, with assertions on or off, and the returned scores are right. The default plateau (min = max = 1) is safe. Every release since 8.0.0 is affected.
Applications that score a field with SweetSpotSimilarity and a plateau minimum of 2 or more, and run the default IndexSearcher#search(q, n) on terms with more than 1,000 matches. Solr's factory is exposed only with minExactCount. The default plateau is safe. How often real corpora lose hits was not measured.
Mechanism, terms and numbers767 words
SweetSpotSimilarity (lucene/misc) lets an application declare a plateau of ideal field lengths with setLengthNormFactors(min, max, steepness): lengths from min to max get a length factor of 1.0, shorter and longer fields less. It keeps the inherited computeNorm, which stores the field length as the norm, so below the plateau a longer field has a greater norm and a higher score. Similarity#computeNorm forbids exactly that ("greater unsigned norms must produce scores that are lower or equal"), because the postings writer keeps, for each block of 256 postings, only the (freq, norm) pairs that can produce the best score under that rule. A short field then hides a better-scoring longer field of the same or lower term frequency: the block claims a too-low maximum, and the default IndexSearcher#search(q, n) skips it once it has counted more than 1,000 hits. In the repro search(q, 10) returns docs 0-9 (1.0) and misses doc 2150 (1.414); Lucene's CheckHits.checkTopScores fails. Scores of returned hits are correct and nothing throws. Every plateau with min >= 2 (and a positive steepness) is affected, the default min = max = 1 is not; every release from 8.0.0 (block impacts, LUCENE-4198) to 10.5.2 and main.
- Norm: one byte per document and field, written at index time by
Similarity#computeNorm; by default the field length (SmallFloat.intToByte4, equal to the length up to 40 terms;Similarity.java:151-161). - Plateau:
lengthNorm(x) = 1/sqrt(steepness * (|x-min| + |x-max| - (max-min)) + 1), 1.0 formin <= x <= maxand lower on both sides (SweetSpotSimilarity.java:97-120). The defaultmin = max = 1, steepness 0.5, is1/sqrt(x), likeClassicSimilarity. - Block impacts: for each block of 256 postings the writer stores the (freq, norm) pairs that can produce the block's best score; top-k search computes the block's maximum score from those pairs and skips the block when it cannot beat the current k-th score.
With setLengthNormFactors(10, 20, 0.5f) (freq 1, idf 1):
| field length (= norm byte) | 1 | 2 | 5 | 9 | 10 to 20 | 21 | 28 |
|---|---|---|---|---|---|---|---|
lengthNorm | 0.316 | 0.333 | 0.408 | 0.707 | 1.0 | 0.707 | 0.333 |
Below 10 terms the score rises with the norm; above 20 it falls, as the rule requires.
- Score.
SweetSpotSimilarityoverrideslengthNorm(SweetSpotSimilarity.java:110-120) andtf(:127-130,sqrt(freq)with the default tf factors, as inClassicSimilarity), but notcomputeNorm, so the norm is the length (Similarity.java:151-161).TFIDFSimilarity#scorer(final) fillsnormTable[i] = lengthNorm(byte4ToInt(i))for the norm bytes 1-255 (TFIDFSimilarity.java:426-448) and scorestf(freq) * idf * boost * normTable[norm](:467-472): freq 1 scores 1.0 at norm 10 and 0.333 at norm 2. - Rule.
Similarity#computeNorm: "for a given term-document frequency, greater unsigned norms must produce scores that are lower or equal" (Similarity.java:140-145);SimScorer#score: "Score must not increase when the unsigned norm increases" (:211-213). - Index.
Lucene104PostingsWriteradds each posting's (freq, norm) to aCompetitiveImpactAccumulator(Lucene104PostingsWriter.java:280-296).getCompetitiveFreqNormPairs()walks the norms upward and keeps a pair only if its freq beats every freq at a smaller norm (CompetitiveImpactAccumulator.java:101-111). In block 2048-2303 norm 2 has freq 2 (short docs) and norm 10 has freq 2 (doc 2150): the norm-10 pair is dropped, the block's maximum becomessqrt(2)/3= 0.4714, andScorer#getMaxScore(2150)returns 0.47140452 while doc 2150 scores 1.4142135 (executed). The level-1 skip data of every 32 blocks merges these pairs through the same accumulator (Lucene104PostingsWriter.java:482-490), so its bound can be too low the same way. - Search.
IndexSearcher#search(q, n)counts 1,000 hits exactly (IndexSearcher.java:100,:597-599), then sets the minimum competitive score toMath.nextUp(1.0)(TopScoreDocCollector.java:154-165), andImpactsDISI#advanceTargetskips every block whose maximum score is below it (ImpactsDISI.java:67-98). The block of doc 2150 claims 0.471 and is skipped; the top 10 is docs 0-9. - Reach. A document is hidden when a shorter, sub-plateau field in its block has an equal or greater term frequency and a lower score. Plateau fields qualify only with a term frequency below
min(without overlapping tokens, freq <= length); fields above the plateau qualify too: for 10..20 a 21-term field with t once (0.707) was hidden by 2-term fields with t once (0.333) in an executed variant, and found with the fix. One short field in the same block is enough (the independent reproducer's layout: doc 2000 "t t" hides doc 2001). A bounded-exhaustive check of every plateau1 <= min <= max <= 64with the steepness values 0.1, 0.5, 1 and 2 found 8,064 of 8,320 settings breaking the rule: exactly those withmin >= 2(steepness 0 would flatten the curve).
Exposure and likelihood237 words
- Who is exposed. Users of
org.apache.lucene.misc.SweetSpotSimilaritywho callsetLengthNormFactorswithmin >= 2, the plateau the class exists for. Solr shipsSweetSpotSimilarityFactory(javadoc example 3/5/0.5) but counts hits exactly unless a request setsminExactCount; Elasticsearch and OpenSearch do not ship the similarity (source reading only). - Default or opt-in. Opt-in similarity with a non-default plateau; everything else is default:
IndexWriterConfig, codec, plainIndexSearcher,IndexSearcher#search(q, n). - Scale. More than 1,000 matches for the default API (3,000 documents in the repro), and the hidden document's block must be reached after the collector has raised its bar (in the one-segment repro: after the first 1,001 hits); callers with a lower total-hits threshold prune sooner. Only postings with stored impacts are skipped (a postings format without impacts serves
SlowImpactsEnum, which never skips). - Queries.
TermQuerytop-k executed wrong on main and every release run from 8.0.0 to 10.5.2; 2-clause disjunctions also on main and several releases. Phrase, synonym and other impact-based queries were not run. - Symptom. Silent: wrong best hits, correct scores, a correctly labelled hit count, a clean index, no exception with assertions on or off. A hit is lost only when a shorter, lower-scoring field with an equal or higher term frequency shares its 256-posting block and the hidden document beats the current k-th score. In a synthetic probe one short document per 200 postings removed the whole true top 10; real-corpus effect not measured.
The test shows that with SweetSpotSimilarity.setLengthNormFactors(10, 20, 0.5f), the default IndexSearcher.search(q, 1) misses the best hit, because a shorter-than-plateau field makes the postings block's impacts underestimate its max score and TOP_SCORES skips the block.
/*
* 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.lucene.misc;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.TopScoreDocCollectorManager;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestSweetSpotTopScores extends LuceneTestCase {
public void testTopHitMatchesExhaustiveSearch() throws Exception {
SweetSpotSimilarity sim = new SweetSpotSimilarity();
sim.setLengthNormFactors(10, 20, 0.5f);
IndexWriterConfig iwc =
new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()).setSimilarity(sim);
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 1280; i++) {
String text =
switch (i) {
case 1024 -> "t t";
case 1025 -> "t t x x x x x x x x";
default -> "t x x x x x x x x x";
};
Document doc = new Document();
doc.add(new TextField("f", text, Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setSimilarity(sim);
Query q = new TermQuery(new Term("f", "t"));
TopDocs exhaustive =
searcher.search(q, new TopScoreDocCollectorManager(1, Integer.MAX_VALUE));
// expected: search(q, 1) finds the exhaustive top hit, doc 1025 (1.41); actual: doc 0 (1.0)
assertEquals(exhaustive.scoreDocs[0].doc, searcher.search(q, 1).scoreDocs[0].doc);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-413/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.misc.TestSweetSpotTopScores
- Expected vs actual
- Expected: search(q, 1) returns the exhaustive top hit, doc 1025 (score 1.41). Actual: doc 0 (score 1.0) (
expected:<1025> but was:<0>), with -ea and -da.
Similarity#computeNorm: greater norms "must produce scores that are lower or equal"; the rule was added in 8.0 for exactly this pruning.IndexSearchersaysscoreDocs"is always accurate"; Lucene's ownCheckHits.checkTopScoresfails on the repro index.- The user only calls the public
setLengthNormFactors, which documents no limit;BaseSimilarityTestCasechecks this rule, but SweetSpot's tests never run it.
The full argument264 words
- Contracts.
Similarity#computeNorm(Similarity.java:140-145): "greater unsigned norms must produce scores that are lower or equal ... for any legal freq", and "1 is the norm that produces the highest scores".SimScorer#score(:211-216): "Score must not increase when the unsigned norm increases".IndexSearcher(IndexSearcher.java:59-67): only the hit count may be a lower bound; "The TopDocs#scoreDocs array is always accurate however."Scorer#getMaxScoremust bound the scores it covers; it returns 0.4714 for a document scoring 1.4142. - No documented limit.
setLengthNormFactors(SweetSpotSimilarity.java:85-95) names no restriction;SweetSpotSimilarity's own javadoc has no@lucene.experimentaltag (its base classSimilarityis experimental as a whole,Similarity.java:89); no CHANGES or MIGRATE entry warns about plateaus and top-k search. - Tests.
BaseSimilarityTestCaseassertsscore(freq, norm-1) >= score(freq, norm)for every norm ("scores non-decreasing as docs get shorter",BaseSimilarityTestCase.java:437-470), andTestClassicSimilarityextends it.TestSweetSpotSimilarityextends onlyLuceneTestCaseand never checks lengths below the plateau or a top-k search. ABaseSimilarityTestCasesubclass on random plateaus fails on stock ("score(1.0,1)=0.19876102 < score(1.0,2)=0.20500971") and passes with the fix. - History. LUCENE-8119 (#9167) wrote the rule for impacts: "scores must not increase when the unsigned norm increases. The latter is something I need for #5270"; LUCENE-4198 (#5270): "This patch adds the assumption than (unsigned) greater norms produce equal or lower scores". Neither touched
lucene/misc. The project fixed or dropped other non-monotone formulas (LUCENE-8010's sweep of core similarities; LUCENE-8015 for DFR; LUCENE-7997, #9045, changed BM25 to guarantee monotonicity, and LUCENE-9071 later sped it up and kept that guarantee; DFR's BE, P and D models were left out for this reason).
The devil's-advocate check179 words
The strongest case for "intended" (built by the devil's advocate, the GPT-5.6 intended-lens reviewer and the adjudication): the plateau and its drop-off below min are the class's documented purpose since 2006 (LUCENE-577); Similarity is a low-level API whose class javadoc says "Lucene makes no assumption about what is in this norm" (Similarity.java:47-48); the Similarity class (:89), ClassicSimilarity#lengthNorm and Similarity#computeNorm are @lucene.experimental; and a user can count all hits instead. So a maintainer could call it "garbage in".
Why it fails: the plateau's scores are intended, wrong top hits are not, and IndexSearcher promises accurate scoreDocs. The user implements nothing; a shipped, non-sandbox class breaks a "must" that core relies on. The later, specific rule (8.0, written for impacts) governs the older general sentence, and experimental status allows API changes, not wrong results. Every verifier and the adjudication rejected the argument. What is fairly conceded: any code fix changes the stored norm and needs re-indexing, so documenting "use min = 1 or count all hits" is a legitimate maintainer choice for a misc class.
Recommended Store a sub-plateau length as the equally distant length above the plateau (validated)
In SweetSpotSimilarity, store a field of min - d terms as max + d (the same length factor) and score every length up to min as on the plateau, so norm order matches score order (+39/-6, one file). The repro, the upstream nightly test and the misc module pass. Short fields need re-indexing; wide plateaus change some scores.
@Override
public long computeNorm(FieldInvertState state) {
final int numTerms = ...; // as in Similarity#computeNorm
if (numTerms > 0 && numTerms < ln_min) { // below the plateau: store max + (min - numTerms)
final long mirrored = (long) ln_max + (ln_min - numTerms);
return SmallFloat.intToByte4((int) Math.min(Integer.MAX_VALUE, mirrored));
}
return SmallFloat.intToByte4(numTerms);
}
@Override
public float lengthNorm(int numTerms) {
final long x = Math.max(numTerms, ln_min); // then the unchanged formula, in long arithmetic
...All options and trade-offs5 options · 952 words
Option A was built and validated with gradle in a scratch clone of ea57360912ed; A2 was run in the standalone harness; B, C and D were not run. The finder's earlier sketch (store the distance from the plateau) failed the upstream nightly test and is not offered.
Store a sub-plateau length as the equally distant length above the plateau (validated) (recommended)
computeNorm writes a field of min - d terms (d >= 1) as max + d, which the plateau formula scores the same (both give 1/sqrt(2 s d + 1)), and lengthNorm uses x = max(numTerms, min), so over all 255 norm bytes the factor is 1.0 up to max and falls after it. Lengths >= min and empty fields are encoded as today; new indexes score exactly as before whenever the mirrored length is at most 40 (for example 29 for the plateau 10..20; identical scores for every length 1 to 2,000 on the plateaus 10..20 and 3..5), while wider plateaus change some scores (Precision below). Patch: SweetSpotSimilarity.java +39/-6; the computeNorm body copies Similarity#computeNorm.
@Override
public long computeNorm(FieldInvertState state) {
final int numTerms = ...; // as in Similarity#computeNorm
if (numTerms > 0 && numTerms < ln_min) { // below the plateau: store max + (min - numTerms)
final long mirrored = (long) ln_max + (ln_min - numTerms);
return SmallFloat.intToByte4((int) Math.min(Integer.MAX_VALUE, mirrored));
}
return SmallFloat.intToByte4(numTerms);
}
@Override
public float lengthNorm(int numTerms) {
final long x = Math.max(numTerms, ln_min); // then the unchanged formula, in long arithmetic
...Trade-offs
- Re-indexing. Sub-plateau fields get a new norm (the
computeNormjavadoc warns that overriding it needs re-indexing). Until an index is rebuilt, its old sub-plateau fields score like plateau fields, while newly indexed ones keep their penalty; merging copies norms and does not recompute them, so mixed segments score inconsistently until the documents are re-indexed (read, not executed). lengthNorm(int)changes for direct callers: belowminit returns 1.0; the drop-off moves intocomputeNorm. The class javadoc needs a sentence.- CombinedFieldQuery requires an additive
computeNormencoded withSmallFloat#intToByte4(CombinedFieldQuery.java:60-64; the defaultcomputeNormsatisfies it); the mirrored encoding is not additive. Today the combination is correct, because its boundscore(+Infinity, 1)is +Infinity and no block is skipped; with the fix, a document with two 2-term fields is scored as one 56-term field (28 + 28) instead of 4 terms: 0.5345225 becomes 0.23249526 (executed). - Precision (wide plateaus). A mirrored length above 40 keeps 4 significant bits and is rounded down, possibly into the plateau. For the plateau 50..100, 43 of the lengths 1 to 110 score higher than today, none lower; a 47-term field with t once goes from 0.4472136 to the plateau score 1.0 (executed). The norm order stays correct. Not tried: rounding the mirrored length up instead.
- Not covered by the new tests:
IndexOptions.DOCSfields and overlapping tokens (the override copies those branches fromSimilarity#computeNorm). - Backports. The design needs 9.12.0 or later (overridable
Similarity#computeNorm; tested on main only). Through 9.11.1TFIDFSimilarity#computeNormisfinalandsetLengthNormFactorstakes a fourth argument, so 8.x and early 9.x need another design. - The
longarithmetic also removes a NaN length factor for fields of more than about 1.07 billion terms (anintoverflow noticed during the hunt, a separate edge). TheBaseSimilarityTestCasesubclass needs it because it probes norm byte 255; drop both for the smallest change.
What the checks validated
- Gradle, scratch clone of
ea57360912ed, OpenJDK 25.0.3: stock: the repro fails 5 of 6 (assertions on and off), theBaseSimilarityTestCasesubclass fails, upstreamTestSweetSpotSimilaritywith-Ptests.nightly=truepasses (its@NightlytestSweetSpotComputeNormran). - With the fix: the repro passes 6 of 6 (assertions on and off); the subclass passes 2 of 2 and 40 of 40 over 20 iterations; upstream
TestSweetSpotSimilaritypasses with-Ptests.nightly=true(two seeds, assertions on and off), including the 0.0-tolerance comparison withClassicSimilaritythat the finder's sketch failed; the whole misc module (25 classes, 323 tests, 0 failures, 9 skipped) and:lucene:misc:check -x testpass. - Standalone harness, fixed class shadowed: the finder's JUnit passes 2 of 2; the devil's advocate's
main()agrees on all four configurations; the independent reproducer's JUnit passes 3 of 4: itstestNormContractasserts as a precondition that a 2-term field's norm byte is 2, which the fix changes on purpose (28).
The computeNorm override alone, without the lengthNorm clamp (run, not recommended alone) (validated by the checks)
Old segments keep their old scores (and their wrong pruning) until re-indexed; new segments prune correctly.
Trade-offs
The score table still inverts for norm bytes below min, which new segments never write: the repro's two score-table checks fail (norm 2 scores 0.33333334 > norm 1 scores 0.31622776), the search tests pass, and score(Float.MAX_VALUE, 1) is then not the documented maximum.
What the checks validated
Standalone harness: the repro fails 1 of 6 (the score-table check); the subclass fails.
Only the lengthNorm clamp, no computeNorm change.
Trade-offs
No re-indexing and CombinedFieldQuery stays compatible, but short fields are no longer penalized at all, which drops half of what the class documents ("Below the min, and above the max the lengthNorm drops off").
Javadoc on the class and on setLengthNormFactors: a plateau with min >= 2 breaks the computeNorm order, so top-k search with pruning can miss hits; use min = 1 or count all hits (totalHitsThreshold = Integer.MAX_VALUE; in Solr, no minExactCount).
Trade-offs
Cheap and honest, but a shipped class keeps silently breaking a core contract.
For example a per-similarity flag that disables impact-based skipping.
Trade-offs
Touches the postings format or every pruning scorer to support one misc class.
Why: whoever can get documents indexed into such a field can hide other documents from top-k results without touching queries. In a probe one short document hid the true top hit, one per 200 postings emptied the true top 10, and deleting them helped only after a merge. No denied or non-matching document is returned.
Code reading of SweetSpotSimilarity against the Similarity norm rule, then a bounded-exhaustive check on the stock jar: 8,064 of 8,320 plateau settings break the rule, exactly those with min >= 2. A public-API test with search(q, 10) then showed the missed hit.
The method in full131 words
Method: semi-formal. A hunt unit (misc module, 2026-10-26). The finder, an AI tool, read SweetSpotSimilarity against the Similarity#computeNorm rule (the plan's secondary method, "SweetSpotSimilarity monotonicity"), then checked the rule bounded-exhaustively on the stock jar: every plateau 1 <= min <= max <= 64, 4 steepness values, every consecutive pair of norm bytes. 8,064 of 8,320 configurations violate it, exactly those with min >= 2; none with min = 1. It then wrote an end-to-end public-API JUnit with IndexSearcher#search(q, 10) and CheckHits.checkTopScores.
Then: an independent reproducer with its own layout, cross-model reviews (OpenAI GPT-5.6, reading only), a devil's advocate with a plain-IndexSearcher main(), an adjudication, a version sweep with a commit-level bisect, a prior-report search, a document-only security probe, and this doc's fix with gradle validation.
No prior report found (2026-10-26): 55 tool queries and 23 manual GitHub searches across Lucene, Solr, Elasticsearch and OpenSearch, JIRA, the mailing lists and the web; 24 hits read, none this bug. The general rule is documented, but nobody tied it to SweetSpotSimilarity.
Searches5 searches
Verdict new. The rule itself is the oracle, not a report: LUCENE-8119 (#9167) and LUCENE-4198 (#5270) state it, LUCENE-7997 and LUCENE-8010 test and fix it for core similarities only, a committer restates it on SOLR-13289, and the Elasticsearch scripted-similarity docs warn that breaking it can "silently return wrong top hits". None names SweetSpotSimilarity or a plateau. Not fixed upstream: the last change to SweetSpotSimilarity.java is 7c056ab88c7 (#13757, 2024, a refactor), and no open PR touches the fix sites. Gaps: shared GitHub search throttling (3 queries retried), keyword-based list search, private channels (including the security list) not searchable.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | scripted prior-report search (16 issue/PR and 3 commit queries) plus manual searches: SweetSpot, TestSweetSpotSimilarity, BaseSimilarityTestCase, monotonic/non-monotonic similarity, computeNorm impacts, "unsigned norms", lengthNorm impacts, setLengthNormFactors, CheckHits checkTopScores similarity, "custom similarity" WAND/impacts | no report; context items #9167, #5270, #9183, #9045; old SweetSpot issues from before impacts |
| Open apache/lucene PRs | 2026-10-26 | changed files of all 411 open PRs | none touches SweetSpotSimilarity, its test, TFIDFSimilarity or CompetitiveImpactAccumulator |
| Elasticsearch, OpenSearch, Solr, Lucene.NET, all of GitHub | 2026-10-26 | SweetSpotSimilarity with WAND, monotonic, block-max or impacts | 0 hits |
| JIRA (LUCENE, SOLR) | 2026-10-26 | SweetSpot*, monotonic + norm, lengthNorm since 2017, impacts + similarity + norm, plateau, TOP_SCORES + similarity, minExactCount + similarity; full comments of LUCENE-4198, -7730, -7997, -8010, -8015, -8116, -8119, -8135 and others | context only (the rule); no report |
| Mailing lists (dev@, java-user@, issues@, commits@, solr-user@, Solr lists) and the web | 2026-10-26 | SweetSpot, SweetSpotSimilarity, setLengthNormFactors; 10 web queries | a 2011 usage question and a generic migration article; no report |
Introduced in 8.0.0 by LUCENE-4198 (f410df81136, 2018, block impacts); the default search(q, n) started pruning with LUCENE-8060. Every release run from 8.0.0 to 10.5.2, and main, is affected; 7.x is clean. Not fixed upstream.
Versions tested7 rows
Introduced by LUCENE-4198 "Give codecs the opportunity to index impacts" (f410df81136, jpountz, 2018-01-31; a JIRA patch, now #5270, no pull request): it added the impact accumulator that drops a (freq, greater norm) pair and TermScorer block skipping. Companion commits on the same 8.0 line: LUCENE-8119 (838c604b765, #9167) wrote the rule into Similarity; LUCENE-8060 (99dbe936818, #9108) made search(q, n) prune after 1,000 hits. Latent since 7.0.0: LUCENE-7730 (06a6034d9bc) made the norm the field length; the plateau formula dates from LUCENE-577 (2006).
Not executed: every 8.x release from 8.1.0 to 8.11.3; 9.4.1, 9.4.2, 9.5.0, 9.9.1, 9.9.2 and 9.11.0; 10.1.0 to 10.5.1 (all between affected releases, same code path); the 10.6 branch (10.6.0, released 2026-10-29); x86. For 2-clause disjunctions, "right" in some releases only means their disjunction scorer did not prune that layout.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.0.0, 7.7.3 | no pruning | right top hit: the plateau already inverts the norm order, but nothing prunes on norms |
f410df81136 (LUCENE-4198) vs parent ebb346d5820 | bisect | the TermQuery top-scores case flips from right to wrong; controls agree on both |
99dbe936818 (LUCENE-8060) vs parent 0dc124aa78e | bisect | the default search(q, 10) flips from right to wrong |
| 8.0.0, 8.11.4 | first affected | TermQuery top-k misses the best hit (version probe; the finder's portable main() fails) |
| 9.0.0 to 9.12.3 (15 releases run) | affected | TermQuery top-k wrong in every one; 2-clause disjunctions also wrong on 9.3.0-9.7.0 and 9.12.x |
| 10.0.0, 10.5.2 | affected | TermQuery and 2-clause disjunctions wrong; the independent reproducer's JUnit fails 3 of 4 on 10.5.2 |
main ea57360912ed | affected | this doc's test fails 5 of 6 with assertions on and off; disjunctions wrong too |
An independent reproducer, a devil's advocate, OpenAI GPT-5.6 reviewers (reading only) and an adjudication checked it: every executed check reproduced it, and the adjudication confirmed it with high confidence. The finder's first fix sketch was found broken and replaced.
Checks and verdicts319 words
The hunt's checks: the finder's candidate, a cross-model refute review, verifiers (an independent reproducer, an intended-lens adversary from another model family with its own runs, and a devil's advocate), an adjudication, the follow-up stages (history, prior art, security), a re-run and credibility audit, and this doc's fix validation.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Bounded-exhaustive check of the norm rule (8,064 of 8,320 plateaus violate it) and a JUnit that fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3; its fix sketch later proved broken. |
| Refute review (OpenAI GPT-5.6, reading only) | not real (low; procedural) | It could not run code, and it voted not real on procedure only; its reading agreed with the root cause through the impact accumulator, MaxScoreCache and ImpactsDISI; a separate re-run failed 2 of 2. |
| Independent reproducer | real | Own JUnit with one short document next to the best one: fails 3 of 4 on main (assertions on and off), 10.5.2 and 9.12.3; the control passes. |
| Intended-lens adversary (OpenAI GPT-5.6, reading only, plus a verifier's runs) | real (medium); fix sketch broken | Rated the intended-behaviour case moderate and rejected it; its verifier found that the finder's fix sketch fails the upstream @Nightly testSweetSpotComputeNorm. |
| Devil's advocate | real (high) | Deterministic plain-IndexSearcher main() misses the top hit for plateaus 10..20 and 3..5 with assertions on and off; found the finder's JUnit seed-sensitive. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test and the intended-behaviour check's main(); no convincing not-real argument. |
| Follow-up re-run and credibility audit | keep | Re-ran the independent reproduction, the intended-behaviour check and the finder's test on main (all fail as before, controls pass). |
| This doc's fix validation | fix validated | Gradle in a scratch clone: the repro fails 5 of 6 on stock and passes with the fix, as do the upstream nightly test and the misc module. |
Finding record, Lucene and upstream sources and repro files1,286 words
- Registry title
- SweetSpotSimilarity with a length plateau (setLengthNormFactors with min >= 2) breaks Similarity's norm-monotonicity contract, so default top-k search silently misses the best hits
- Area
- misc / similarities (SweetSpotSimilarity) with core impacts-based TOP_SCORES pruning
- Priority
- Medium, rank 62: SweetSpotSimilarity with a length plateau has made default top-k search silently lose its best hits since 8.0, which is the same similarity-breaks-pruning profile as LFV-190 and more plausible than LFV-253's Axiomatic plus CombinedFieldQuery pairing.
- Impact class
- silent wrong results
- Impact note
- Applications that score a field with lucene/misc SweetSpotSimilarity and call setLengthNormFactors with min >= 2 (the plateau the class exists for; the default min = max = 1 is monotone) can get wrong top hits, with no error, from the default IndexSearcher.search(query, n) once more than 1,000 documents match, whenever a postings block holds a field shorter than the plateau whose term frequency is at least that of a better-scoring plateau-length field; default IndexWriterConfig, codec and searcher, -ea and -da, no scale beyond about 1,000 matches, every release since 8.0.0; returned scores are correct and the index is not damaged; callers that count hits exactly are not affected, and Solr is exposed only when minExactCount is set.
- Summary
- You score a field with SweetSpotSimilarity and setLengthNormFactors(10, 20, 0.5f), index mostly 10-term fields plus some shorter ones, and run searcher.search(new TermQuery(new Term("f", "t")), 10) on a term with more than 1,000 matches, expecting the same top hits as an exhaustive search. Instead a 10-term document that contains the term twice (score 1.414) is missing and a document that contains it once (score 1.0) comes first. Below the plateau a 2-term field scores lower than a 10-term field although its norm is smaller, which breaks the rule that a greater norm never scores higher; the postings writer relies on that rule, keeps only the short field's (freq, norm) pair for the block, advertises a block maximum of 0.471, and top-k search skips the block.
- Introduced
- f410df81136291f61b8deaace22bf4bb650de1ad (LUCENE-4198 'Give codecs the opportunity to index impacts', a JIRA patch by jpountz, migrated as apache/lucene#5270; no pull request): it added CompetitiveFreqNormAccumulator (today CompetitiveImpactAccumulator), which drops a (freq, larger norm) pair as dominated, and TermScorer block skipping under TOP_SCORES [exec: commit-level A/B, parent ebb346d5820 TermQuery top-scores SAME, f410df81136 DIFFERENT, controls SAME on both]. Companion commits, same author, same 8.0 development line: 838c604b765 (LUCENE-8119, #9167) wrote the norm-monotonicity rule into Similarity and is the earliest commit with any plateau-specific wrong hit (explicit TOP_SCORES disjunctions; that exposure was removed by LUCENE-8135 before any release); 99dbe936818 (LUCENE-8060, #9108) made the default search(q, n) use TOP_SCORES after 1,000 hits [exec: parent 0dc124aa78e default SAME, 99dbe936818 DIFFERENT]. Latent precondition, harmless before 8.0: 06a6034d9bc (LUCENE-7730, 7.0.0) encodes the field length as the norm; the plateau formula dates from LUCENE-577 (2006) (2018-01-31 (f410df81136; the contract commit 838c604b765 is 2018-01-09 and the default-search commit 99dbe936818 is 2018-08-01))
- First affected version
- 8.0.0 [exec: the history probe and the finder's portable repro fail]; 7.0.0 and 7.7.3 are not affected [exec: no TOP_SCORES pruning exists there; the inverted norm property is already present but nothing relies on it]; none of the four 2018 commits was backported to 7.x [read]
- Affected versions
- Every release from 8.0.0 to 10.5.2 and main ea57360912ed; not fixed upstream (the last upstream commit to SweetSpotSimilarity.java is 7c056ab88c7, #13757, 2024-09-13, a refactor; no open PR touches the fix sites; read 2026-10-26). [exec] The history probe HistSweetSpotProbe (one public-API source for every version) misses the top hit of a TermQuery top-k search on 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.0, 9.6.0, 9.7.0, 9.8.0, 9.9.0, 9.10.0, 9.11.1, 9.12.0, 9.12.1, 9.12.2, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main), with its controls agreeing everywhere; on its data 2-clause disjunctions also miss the top hit on 9.3.0-9.7.0, 9.12.0-9.12.3, 10.0.0, 10.5.2 and main (the other releases' disjunction scorers did not prune those layouts, which does not show them safe); the finder's JUnit fails on 9.12.3, 10.5.2 and main (-ea and -da) and its portable main() on 8.0.0, 8.11.4, 9.0.0 and 9.1.0; the independent reproduction's JUnit fails on 9.12.3, 10.5.2 and main (-ea and -da). [read] Releases not executed (8.1.0-8.10.1, 9.5.0, 10.1.0-10.5.1) lie between affected ones with the same mechanism; unreleased branch_10x and 10.6.0 (released 2026-10-29) not checked separately
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, the prior-report record verdict new: 55 scripted prior-report search queries over apache/lucene GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web; 23 manual GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET and all of GitHub); manual LUCENE and SOLR JIRA queries including full-text greps of LUCENE-4198, -7730, -7997, -8010, -8015, -8116, -8119, -8135 and others; the solr-user@ and solr.apache.org lists; the changed files of all 411 open apache/lucene PRs; 24 hits read, none the same bug; the finder's, the independent reproduction's and the intended-behaviour check's searches found nothing either). Not fixed upstream: the last upstream commit to SweetSpotSimilarity.java is 7c056ab88c7 (#13757, 2024-09-13, refactor only), and no open PR touches SweetSpotSimilarity.java, TestSweetSpotSimilarity.java, TFIDFSimilarity.java or CompetitiveImpactAccumulator.java. Context, not reports: LUCENE-8119 (#9167) and LUCENE-4198 (#5270) state the assumption that greater norms never score higher; LUCENE-7997 and LUCENE-8010 test and fix it for core similarities only; a committer comment on SOLR-13289 and the Elasticsearch scripted-similarity documentation state the general rule; none names SweetSpotSimilarity or a length plateau. Gaps: shared GitHub search throttling (3 queries retried and succeeded), keyword-based list search, private channels not visible
- Lucene commit tested
- main
ea57360912ed(commit of 2026-10-24; tested 2026-10-26), seed DEADBEEF, assertions on and off, through gradle and through a standalone JUnit harness; the version probe on 21 releases from 7.0.0 to 10.5.2 and on 10 commit builds of the 8.0 development line; the fix in a scratch clone of the same commit.
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:21-26 (at ea57360912ed) (class javadoc: plateau; 'Below the min, and above the max the lengthNorm drops off'), :85-95 (setLengthNormFactors, no restriction documented), :110-120 (lengthNorm = 1/sqrt(s*(|x-min|+|x-max|-(max-min))+1), int arithmetic); the class inherits computeNormlucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:140-143 (at ea57360912ed) (computeNorm javadoc: 'greater unsigned norms must produce scores that are lower or equal')lucene/core/src/java/org/apache/lucene/codecs/CompetitiveImpactAccumulator.java:25, (at ea57360912ed) :46-47 ('greater norms compare lower'), :64-72 (add keeps the highest freq per norm), :101 and :128-134 (competitive (freq, norm) set: a pair is dropped when a smaller norm has an equal or greater freq)lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:59-66 (at ea57360912ed) (only the hit count is approximate: 'The TopDocs#scoreDocs array is always accurate however'), :100 (TOTAL_HITS_THRESHOLD = 1000)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:21-26, 36-38, 85-95, 97-120Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:140-145, 151-161, 211-216Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/TFIDFSimilarity.java:426-448, 467-472Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/CompetitiveImpactAccumulator.java:64-72, 101-111Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsWriter.java:280-296, 482-490; ForUtil.java:34Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, 597-599; TopScoreDocCollector.java:154-165; ImpactsDISI.java:67-98
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:59-67, 100Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/SlowImpactsEnum.java:106-107
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:47-48, 89, 140-147, 211-216Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:59-67Lucene main ea57360912ed: lucene/misc/src/java/org/apache/lucene/misc/SweetSpotSimilarity.java:85-95Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/ClassicSimilarity.java:39-43Lucene main ea57360912ed: lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:437-470Lucene main ea57360912ed: lucene/misc/src/test/org/apache/lucene/misc/TestSweetSpotSimilarity.java:39, 73-162
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java:132-134, 151-161Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/CombinedFieldQuery.java:60-64, 416, 447-448
- TestSweetSpotTopScores.java (JUnit test):
repro/LFV-413/src/org/apache/lucene/misc/TestSweetSpotTopScores.java(66 lines)