From 075902932e1ca837e1b55d4be6db0f707124ef3c Mon Sep 17 00:00:00 2001 From: Kristian Rickert Date: Sun, 5 Jul 2026 15:57:30 -0400 Subject: [PATCH 1/4] Add shared-floor kNN collection to the sandbox module Adds GlobalKnnFloor, FloorAwareKnnCollector, SharedFloorKnnCollectorManager, and a resurrected BlockingFloatHeap under org.apache.lucene.sandbox.search.knn. Opt-in, zero changes to lucene/core, composes with optimistic multi-segment search via the existing minCompetitiveSimilarity hook. --- lucene/sandbox/src/java/module-info.java | 1 + .../sandbox/search/knn/BlockingFloatHeap.java | 193 +++++++ .../search/knn/FloorAwareKnnCollector.java | 299 ++++++++++ .../sandbox/search/knn/GlobalKnnFloor.java | 181 ++++++ .../knn/SharedFloorKnnCollectorManager.java | 237 ++++++++ .../sandbox/search/knn/package-info.java | 24 + .../search/knn/TestBlockingFloatHeap.java | 100 ++++ .../knn/TestFloorAwareKnnCollector.java | 360 ++++++++++++ .../search/knn/TestGlobalKnnFloor.java | 213 +++++++ .../search/knn/TestSharedFloorKnnSearch.java | 538 ++++++++++++++++++ 10 files changed, 2146 insertions(+) create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java create mode 100644 lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/package-info.java create mode 100644 lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java create mode 100644 lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java create mode 100644 lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java create mode 100644 lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnSearch.java diff --git a/lucene/sandbox/src/java/module-info.java b/lucene/sandbox/src/java/module-info.java index ee9be3227de2..8d593a2adf04 100644 --- a/lucene/sandbox/src/java/module-info.java +++ b/lucene/sandbox/src/java/module-info.java @@ -28,6 +28,7 @@ exports org.apache.lucene.sandbox.document; exports org.apache.lucene.sandbox.queries; exports org.apache.lucene.sandbox.search; + exports org.apache.lucene.sandbox.search.knn; exports org.apache.lucene.sandbox.index; exports org.apache.lucene.sandbox.facet; exports org.apache.lucene.sandbox.facet.recorders; diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java new file mode 100644 index 000000000000..5118feea0f63 --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java @@ -0,0 +1,193 @@ +/* + * 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.sandbox.search.knn; + +import java.util.concurrent.locks.ReentrantLock; + +/** + * A blocking bounded min heap that stores floats. The top element is the lowest value of the heap. + * + *

A primitive priority queue that maintains a partial ordering of its elements such that the + * least element can always be found in constant time. Implementation is based on {@link + * org.apache.lucene.util.LongHeap} + * + * @lucene.internal + */ +public final class BlockingFloatHeap { + private final int maxSize; + private final float[] heap; + private final ReentrantLock lock; + private int size; + + public BlockingFloatHeap(int maxSize) { + this.maxSize = maxSize; + this.heap = new float[maxSize + 1]; + this.lock = new ReentrantLock(); + this.size = 0; + } + + /** + * Inserts a value into this heap. + * + *

If the number of values would exceed the heap's maxSize, the least value is discarded + * + * @param value the value to add + * @return the new 'top' element in the queue. + */ + public float offer(float value) { + lock.lock(); + try { + if (size < maxSize) { + push(value); + return heap[1]; + } else { + if (value >= heap[1]) { + updateTop(value); + } + return heap[1]; + } + } finally { + lock.unlock(); + } + } + + /** + * Inserts array of values into this heap. + * + *

Values must be sorted in ascending order. + * + * @param values a set of values to insert, must be sorted in ascending order + * @param len number of values from the {@code values} array to insert + * @return the new 'top' element in the queue. + */ + public float offer(float[] values, int len) { + lock.lock(); + try { + for (int i = len - 1; i >= 0; i--) { + if (size < maxSize) { + push(values[i]); + } else { + if (values[i] >= heap[1]) { + updateTop(values[i]); + } else { + break; + } + } + } + return heap[1]; + } finally { + lock.unlock(); + } + } + + /** + * Removes and returns the head of the heap + * + * @return the head of the heap, the smallest value + * @throws IllegalStateException if the heap is empty + */ + public float poll() { + if (size > 0) { + float result; + + lock.lock(); + try { + result = heap[1]; // save first value + heap[1] = heap[size]; // move last to first + size--; + downHeap(1); // adjust heap + } finally { + lock.unlock(); + } + return result; + } else { + throw new IllegalStateException("The heap is empty"); + } + } + + /** + * Retrieves, but does not remove, the head of this heap. + * + * @return the head of the heap, the smallest value + */ + public float peek() { + lock.lock(); + try { + return heap[1]; + } finally { + lock.unlock(); + } + } + + /** + * Returns the number of elements in this heap. + * + * @return the number of elements in this heap + */ + public int size() { + lock.lock(); + try { + return size; + } finally { + lock.unlock(); + } + } + + private void push(float element) { + size++; + heap[size] = element; + upHeap(size); + } + + private float updateTop(float value) { + heap[1] = value; + downHeap(1); + return heap[1]; + } + + private void downHeap(int i) { + float value = heap[i]; // save top value + int j = i << 1; // find smaller child + int k = j + 1; + if (k <= size && heap[k] < heap[j]) { + j = k; + } + while (j <= size && heap[j] < value) { + heap[i] = heap[j]; // shift up child + i = j; + j = i << 1; + k = j + 1; + if (k <= size && heap[k] < heap[j]) { + j = k; + } + } + heap[i] = value; // install saved value + } + + private void upHeap(int origPos) { + int i = origPos; + float value = heap[i]; // save bottom value + int j = i >>> 1; + while (j > 0 && value < heap[j]) { + heap[i] = heap[j]; // shift parents down + i = j; + j = j >>> 1; + } + heap[i] = value; // install saved value + } +} diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java new file mode 100644 index 000000000000..0ce7fa73107f --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java @@ -0,0 +1,299 @@ +/* + * 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.sandbox.search.knn; + +import org.apache.lucene.search.AbstractKnnCollector; +import org.apache.lucene.search.KnnCollector; +import org.apache.lucene.util.hnsw.FloatHeap; + +/** + * A {@link org.apache.lucene.search.KnnCollector.Decorator} that raises its {@link + * #minCompetitiveSimilarity()} using a {@link GlobalKnnFloor} shared with the other searchers of + * the same query, so that a graph search stops exploring candidates that cannot enter the query's + * final, merged top-k, not merely candidates that cannot enter this collector's local top-k. + * + *

The graph searcher already re-reads {@link #minCompetitiveSimilarity()} whenever a collected + * hit reports an improvement, and uses it both to stop the search when the best remaining candidate + * falls below it and to avoid enqueueing hopeless candidates. Folding the shared floor into that + * method is therefore the entire integration: no searcher changes are required, and a collector + * whose floor never rises behaves exactly like its delegate. + * + *

Three guards keep the shared bound from harming recall or becoming a synchronization hot spot: + * + *

+ * + *

The effective bound derived from the floor is one ulp below the floor itself. The floor is the + * similarity of a real collected hit, and when several hits tie at the cutoff the merged result set + * is decided by tie-breaking, not by score; a search must therefore remain willing to find + * candidates exactly at the floor, otherwise a document that would have won the tie-break could be + * abandoned. Keeping the bound strictly below the floor preserves those ties without publishing + * document identities. + * + *

Instances are confined to a single thread, like every {@link KnnCollector}; only the shared + * {@link GlobalKnnFloor} is touched by multiple threads. + * + * @lucene.experimental + */ +public final class FloorAwareKnnCollector extends KnnCollector.Decorator { + + /** + * Default fraction of the search effort that follows the shared floor rather than the local + * frontier; see the class comment for the roles of the two extremes. The default is deliberately + * conservative. Callers who have verified recall on their own data may trade some of it for fewer + * visits by raising this. + */ + public static final float DEFAULT_GREEDINESS = 0.5f; + + /** + * Default minimum number of non-competitive queue slots, whatever the greediness. The protection + * a graph search needs against a tight external bound is an absolute number of below-bound + * candidates it may keep routing through, not a fraction of k: with a purely fractional clamp, a + * small-k search under high greediness is left a clamp one or two candidates wide, and randomized + * testing showed that costing double-digit recall. A useful side effect is that when k does not + * exceed the configured minimum, the clamp is at least as wide as the local queue and the shared + * floor is neutralized entirely: small-k searches behave exactly like stock search no matter what + * has been advertised. + */ + public static final int DEFAULT_MIN_EXPLORATION_SLOTS = 16; + + /** + * Default number of visited vectors between synchronizations with the shared floor. The value + * balances floor freshness (a staler floor prunes less, costing visits but never recall) against + * cross-thread traffic; it is the interval the removed {@code MultiLeafKnnCollector} shipped + * with. + */ + public static final int DEFAULT_SYNC_INTERVAL = 256; + + private final AbstractKnnCollector subCollector; + private final GlobalKnnFloor globalFloor; + + /** + * Number of locally collected results after which the shared floor engages; the ascent gate's + * threshold. See the class comment. + */ + private final int gateK; + + /** + * Bit mask selecting the visited counts at which to synchronize, {@code syncInterval - 1}; the + * power-of-two requirement on the interval exists so this mask works. + */ + private final int syncIntervalMask; + + /** + * The best {@code max(minExplorationSlots, (1 - greediness) * k)} similarities seen by this + * collector, competitive or not. Its minimum caps the effective bound, implementing the + * greediness clamp. + */ + private final FloatHeap nonCompetitiveQueue; + + /** Similarities observed since the last synchronization, awaiting publication. */ + private final FloatHeap updatesQueue; + + /** Scratch used to drain {@link #updatesQueue} in ascending order for batch publication. */ + private final float[] updatesScratch; + + private boolean gateOpened; + private float cachedGlobalFloor = Float.NEGATIVE_INFINITY; + + /** + * Create a collector applying the {@link #DEFAULT_GREEDINESS default greediness}. + * + * @param subCollector the collector gathering this searcher's local results + * @param globalFloor the floor shared by all searchers of this query + */ + public FloorAwareKnnCollector(AbstractKnnCollector subCollector, GlobalKnnFloor globalFloor) { + this(subCollector, globalFloor, DEFAULT_GREEDINESS); + } + + /** + * Create a collector with an explicit greediness and default slot minimum and sync interval. + * + * @param subCollector the collector gathering this searcher's local results + * @param globalFloor the floor shared by all searchers of this query + * @param greediness fraction of the search effort that follows the shared floor, in {@code [0, + * 1]}; see the class comment + */ + public FloorAwareKnnCollector( + AbstractKnnCollector subCollector, GlobalKnnFloor globalFloor, float greediness) { + this( + subCollector, + globalFloor, + greediness, + DEFAULT_MIN_EXPLORATION_SLOTS, + DEFAULT_SYNC_INTERVAL); + } + + /** + * Create a collector whose ascent gate opens when its local queue fills, with an explicit + * greediness, slot minimum and sync interval. + * + * @param subCollector the collector gathering this searcher's local results + * @param globalFloor the floor shared by all searchers of this query + * @param greediness fraction of the search effort that follows the shared floor, in {@code [0, + * 1]}; see the class comment + * @param minExplorationSlots smallest permitted size of the greediness clamp's queue, whatever + * the greediness; must be at least 1. See {@link #DEFAULT_MIN_EXPLORATION_SLOTS} for the role + * this plays in protecting recall. + * @param syncInterval number of visited vectors between synchronizations with the shared floor; + * must be a power of two. Smaller intervals keep the floor fresher (fewer visits) at the + * price of more cross-thread traffic. + */ + public FloorAwareKnnCollector( + AbstractKnnCollector subCollector, + GlobalKnnFloor globalFloor, + float greediness, + int minExplorationSlots, + int syncInterval) { + this( + subCollector, globalFloor, greediness, minExplorationSlots, syncInterval, subCollector.k()); + } + + /** + * Create a fully configured collector. + * + * @param subCollector the collector gathering this searcher's local results + * @param globalFloor the floor shared by all searchers of this query + * @param greediness fraction of the search effort that follows the shared floor, in {@code [0, + * 1]}; see the class comment + * @param minExplorationSlots smallest permitted size of the greediness clamp's queue, whatever + * the greediness; must be at least 1. See {@link #DEFAULT_MIN_EXPLORATION_SLOTS} for the role + * this plays in protecting recall. + * @param syncInterval number of visited vectors between synchronizations with the shared floor; + * must be a power of two. Smaller intervals keep the floor fresher (fewer visits) at the + * price of more cross-thread traffic. + * @param gateK the number of locally collected results after which the shared floor engages, in + * {@code [1, subCollector.k()]}. The default, used by the other constructors, is {@code + * subCollector.k()}: the floor engages when the local queue fills. Set it below the queue + * size when this collector's expected share of the merged top-k is smaller than the queue it + * collects into; see the class comment on the ascent gate for when that applies. + */ + public FloorAwareKnnCollector( + AbstractKnnCollector subCollector, + GlobalKnnFloor globalFloor, + float greediness, + int minExplorationSlots, + int syncInterval, + int gateK) { + super(subCollector); + if (greediness < 0 || greediness > 1 || Float.isNaN(greediness)) { + throw new IllegalArgumentException("greediness must be in [0,1], got: " + greediness); + } + if (minExplorationSlots < 1) { + throw new IllegalArgumentException( + "minExplorationSlots must be at least 1, got: " + minExplorationSlots); + } + if (syncInterval < 1 || Integer.bitCount(syncInterval) != 1) { + throw new IllegalArgumentException( + "syncInterval must be a power of two, got: " + syncInterval); + } + if (gateK < 1 || gateK > subCollector.k()) { + throw new IllegalArgumentException( + "gateK must be in [1, subCollector.k()=" + subCollector.k() + "], got: " + gateK); + } + this.subCollector = subCollector; + this.globalFloor = globalFloor; + this.gateK = gateK; + this.syncIntervalMask = syncInterval - 1; + this.nonCompetitiveQueue = + new FloatHeap(Math.max(minExplorationSlots, Math.round((1 - greediness) * gateK))); + this.updatesQueue = new FloatHeap(globalFloor.k()); + this.updatesScratch = new float[globalFloor.k()]; + } + + @Override + public boolean collect(int docId, float similarity) { + boolean localSimUpdated = subCollector.collect(docId, similarity); + boolean gateJustOpened = gateOpened == false && subCollector.numCollected() >= gateK; + if (gateJustOpened) { + gateOpened = true; + } + updatesQueue.offer(similarity); + boolean globalSimUpdated = nonCompetitiveQueue.offer(similarity); + + if (gateOpened && (gateJustOpened || (visitedCount() & syncIntervalMask) == 0)) { + // The shared heap requires ascending input; draining the pending min-heap yields exactly + // that. Scores observed before the gate opened are included in the first batch, so nothing + // seen during the ascent is lost to the shared floor. + int len = updatesQueue.size(); + if (len > 0) { + for (int i = 0; i < len; i++) { + updatesScratch[i] = updatesQueue.poll(); + } + assert updatesQueue.size() == 0; + cachedGlobalFloor = globalFloor.offer(updatesScratch, len); + globalSimUpdated = true; + } + } + // Reporting an update whenever the effective bound may have moved (locally or via the shared + // floor) prompts the graph searcher to re-read minCompetitiveSimilarity() and raise its + // termination bar promptly. + return localSimUpdated || globalSimUpdated; + } + + @Override + public float minCompetitiveSimilarity() { + if (gateOpened == false) { + // Ascent gate: until gateK results have been collected, expose only the delegate's bound, + // which is NEGATIVE_INFINITY by the KnnCollector contract. See the class comment. + return subCollector.minCompetitiveSimilarity(); + } + // nextDown keeps the bound strictly below the floor so exact score ties at the cutoff remain + // reachable; nextDown of NEGATIVE_INFINITY is NEGATIVE_INFINITY, so an undefined floor is a + // no-op here. + return Math.max( + subCollector.minCompetitiveSimilarity(), + Math.min(nonCompetitiveQueue.peek(), Math.nextDown(cachedGlobalFloor))); + } + + @Override + public String toString() { + return "FloorAwareKnnCollector[subCollector=" + subCollector + "]"; + } +} diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java new file mode 100644 index 000000000000..71e73a22e4e7 --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java @@ -0,0 +1,181 @@ +/* + * 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.sandbox.search.knn; + +import java.util.concurrent.atomic.LongAccumulator; +import org.apache.lucene.util.NumericUtils; + +/** + * Shared, per-query lower bound on the similarity of the k-th best result of a kNN search whose + * hits are produced by multiple independent searchers (index segments searched concurrently, or + * remote shards in separate JVMs). + * + *

The floor is the k-th highest similarity among all hits observed so far, tracked with a + * bounded min-heap of the k best observed scores. Because the observed hits are always a subset of + * the hits that will ever exist for this query, the k-th best of the observed subset can never + * exceed the k-th best of the complete result set. The floor is therefore, at every moment, a valid + * lower bound on the final top-k similarity cutoff: a candidate that cannot beat the current floor + * can never enter the final merged top-k, and abandoning it loses nothing. This invariant requires + * that every score offered or advertised belongs to a distinct document; feeding duplicate + * documents (for example, hits for the same document from two replicas) may inflate the floor above + * the true cutoff and callers must deduplicate before publishing. + * + *

The floor is monotonic: it starts at {@link Float#NEGATIVE_INFINITY}, becomes defined only + * once k scores have been observed, and afterwards only rises. Monotonicity is what makes the floor + * safe to share across threads and processes with no coordination beyond a max-reduction: updates + * commute, duplicated or reordered deliveries are harmless, and a stale read can only under-prune + * (costing visits), never over-prune (costing correctness). + * + *

There are two ways to feed the floor: + * + *

+ * + *

An instance carries the state of a single query execution and must not be reused across + * queries: a floor derived from one query's hits is meaningless, and unsafe, as a bound for another + * query. + * + * @lucene.experimental + */ +public final class GlobalKnnFloor { + + private final int k; + private final BlockingFloatHeap heap; + + /** + * The current floor as a sortable-int-encoded float (see {@link + * NumericUtils#floatToSortableInt(float)}), widened to a long. The sortable encoding preserves + * float ordering under integer comparison, so a {@code Long::max} accumulator implements a + * lock-free monotonic maximum over float values. + */ + private final LongAccumulator floorBits; + + /** + * Latched to true once the heap has received k scores. Written by whichever publishing thread + * observes the heap reaching size k; the heap never shrinks, so the transition happens once and + * the flag is stable afterwards. Until the latch is set, the heap's minimum is the minimum of + * fewer than k scores, which is not a valid k-th-best bound and must not feed the floor. + */ + private volatile boolean heapFull; + + /** + * Create a floor for a query collecting {@code k} results. + * + * @param k the number of results the query collects; the floor becomes defined once k distinct + * scores have been observed + */ + public GlobalKnnFloor(int k) { + if (k < 1) { + throw new IllegalArgumentException("k must be at least 1, got: " + k); + } + this.k = k; + this.heap = new BlockingFloatHeap(k); + this.floorBits = new LongAccumulator(Long::max, encode(Float.NEGATIVE_INFINITY)); + } + + /** Return the number of results the query collects, which is also the heap capacity. */ + public int k() { + return k; + } + + /** + * Publish a batch of locally observed similarities and return the resulting floor. + * + *

Each published score must belong to a distinct document (across all calls for this query, + * from all publishers). Scores that are not competitive are cheaply discarded by the bounded + * heap; dropping scores never invalidates the floor, it only makes it less tight. + * + * @param scores similarities to publish, sorted in ascending order + * @param len the number of leading entries of {@code scores} to publish, must be positive + * @return the floor after this batch, {@link Float#NEGATIVE_INFINITY} if fewer than k scores have + * been observed so far + */ + public float offer(float[] scores, int len) { + if (len <= 0) { + throw new IllegalArgumentException("len must be positive, got: " + len); + } + assert isSorted(scores, len) : "scores must be sorted in ascending order"; + float heapTop = heap.offer(scores, len); + if (heapFull) { + // The heap already held k scores before this call, so the value returned by offer() is the + // minimum of a full heap: the k-th best of everything observed, a valid bound. + floorBits.accumulate(encode(heapTop)); + } else if (heap.size() >= k) { + heapFull = true; + // The heap may have been filled by a concurrent publisher after our offer() returned, in + // which case our heapTop is the minimum of fewer than k scores and could overshoot the + // true k-th best. Re-read the top of the now-full heap instead of trusting heapTop. + floorBits.accumulate(encode(heap.peek())); + } + return floor(); + } + + /** + * Raise the floor to an externally computed lower bound of the final k-th best similarity, if it + * exceeds the current floor. Values at or below the current floor are ignored, so duplicated or + * reordered deliveries from remote feeders need no special handling. + * + *

Contract: {@code kthBestLowerBound} must not exceed the k-th best similarity of the query's + * final, merged result set. The k-th best similarity among any k or more real, distinct hits for + * this query (for example, a remote shard's converged top-k) always satisfies this. A value that + * violates the contract makes over-pruning, and therefore missing results, possible. + * + * @param kthBestLowerBound the bound to advertise; must not be NaN + */ + public void advertise(float kthBestLowerBound) { + if (Float.isNaN(kthBestLowerBound)) { + throw new IllegalArgumentException("advertised bound must not be NaN"); + } + floorBits.accumulate(encode(kthBestLowerBound)); + } + + /** + * Return the current floor: a lower bound of the final top-k similarity cutoff, or {@link + * Float#NEGATIVE_INFINITY} while fewer than k scores have been observed and nothing has been + * advertised. The returned value never decreases over the lifetime of this object. + */ + public float floor() { + return decode(floorBits.get()); + } + + private static long encode(float value) { + return NumericUtils.floatToSortableInt(value); + } + + private static float decode(long bits) { + return NumericUtils.sortableIntToFloat((int) bits); + } + + private static boolean isSorted(float[] scores, int len) { + for (int i = 1; i < len; i++) { + if (scores[i - 1] > scores[i]) { + return false; + } + } + return true; + } +} diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java new file mode 100644 index 000000000000..81bd8e5ce5bb --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java @@ -0,0 +1,237 @@ +/* + * 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.sandbox.search.knn; + +import java.io.IOException; +import java.util.Objects; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.search.KnnCollector; +import org.apache.lucene.search.TopKnnCollector; +import org.apache.lucene.search.knn.KnnCollectorManager; +import org.apache.lucene.search.knn.KnnSearchStrategy; + +/** + * A {@link KnnCollectorManager} whose collectors share a {@link GlobalKnnFloor}, so that every + * segment of a query prunes against a lower bound of the query's final merged top-k cutoff rather + * than only against its own local top-k. + * + *

This manager reports {@link #isOptimistic()} and provides {@link #newOptimisticCollector}, + * which means the kNN query machinery runs its regular pro-rata collection strategy on top of it: + * each segment initially collects only its statistically expected share of the global top-k, and + * segments whose results prove globally competitive are searched again. The shared floor + * complements that strategy rather than replacing it. During the first pass it lets segments stop + * refining results that the merged cutoff has already outrun, and because the floor object is + * shared across both passes, segments searched again start with the bound the first pass + * established instead of rediscovering it. + * + *

The floor may also be fed from outside this process through {@link + * GlobalKnnFloor#advertise(float)}, allowing several searchers of disjoint indexes, each holding a + * manager wired to the same query, to bound each other's work. Coordinating who advertises what, + * and deduplicating documents that may appear in more than one index, is the caller's + * responsibility; see {@link GlobalKnnFloor} for the exact contract. + * + *

Floor sharing engages only when the query's k reaches an activation threshold; below it this + * manager creates plain, undecorated collectors and the search is exactly stock search. The two + * regimes justify the cutoff. The savings available to a shared bound grow with k: the pro-rata + * strategy sizes each segment's share as {@code k * proportion} plus {@code 16} standard deviations + * of a binomial, so at small k the statistical padding swamps the share and there is little + * redundant work left for a floor to eliminate, while at large k the padding is relatively + * negligible and most per-segment work above the merged cutoff is redundant. The recall risk moves + * the opposite way: the smaller k is, the closer any valid floor sits to scores an incomplete graph + * search routes through. Spending nothing and risking nothing below the threshold is therefore the + * right default, and deployments that have measured recall on their own data can lower it + * explicitly. + * + *

A manager, like the floor it holds, carries the state of a single query execution: create one + * per query, and never share one across queries. Both collector-creation methods may be called + * concurrently, as segments are searched in parallel. + * + * @lucene.experimental + */ +public final class SharedFloorKnnCollectorManager implements KnnCollectorManager { + + /** + * Default smallest k at which floor sharing engages. See the class comment for why small-k + * queries are better served by stock search. + */ + public static final int DEFAULT_FLOOR_ACTIVATION_K = 100; + + private final int k; + private final GlobalKnnFloor globalFloor; + private final float greediness; + private final int floorActivationK; + private final int minExplorationSlots; + private final int syncInterval; + + /** + * Create a manager with its own floor and the {@link FloorAwareKnnCollector#DEFAULT_GREEDINESS + * default greediness}, for queries whose searchers all live in this process. + * + * @param k the number of neighbors the query collects + */ + public SharedFloorKnnCollectorManager(int k) { + this(k, new GlobalKnnFloor(k), FloorAwareKnnCollector.DEFAULT_GREEDINESS); + } + + /** + * Create a manager around an externally provided floor, for queries whose floor is also fed by + * searchers outside this process. + * + * @param k the number of neighbors the query collects + * @param globalFloor the floor shared by all searchers of this query; its {@link + * GlobalKnnFloor#k()} must equal {@code k}, since a floor tracking the wrong result-set size + * is not a valid bound + */ + public SharedFloorKnnCollectorManager(int k, GlobalKnnFloor globalFloor) { + this(k, globalFloor, FloorAwareKnnCollector.DEFAULT_GREEDINESS); + } + + /** + * Create a manager around an externally provided floor with an explicit greediness, applying the + * {@link #DEFAULT_FLOOR_ACTIVATION_K default activation threshold}. + * + * @param k the number of neighbors the query collects + * @param globalFloor the floor shared by all searchers of this query; its {@link + * GlobalKnnFloor#k()} must equal {@code k} + * @param greediness fraction of each segment's search effort that follows the shared floor, in + * {@code [0, 1]}; see {@link FloorAwareKnnCollector} + */ + public SharedFloorKnnCollectorManager(int k, GlobalKnnFloor globalFloor, float greediness) { + this(k, globalFloor, greediness, DEFAULT_FLOOR_ACTIVATION_K); + } + + /** + * Create a manager around an externally provided floor with an explicit greediness and activation + * threshold, applying the default slot minimum and sync interval. + * + * @param k the number of neighbors the query collects + * @param globalFloor the floor shared by all searchers of this query; its {@link + * GlobalKnnFloor#k()} must equal {@code k} + * @param greediness fraction of each segment's search effort that follows the shared floor, in + * {@code [0, 1]}; see {@link FloorAwareKnnCollector} + * @param floorActivationK the smallest k at which floor sharing engages; for smaller k this + * manager creates plain collectors and the search is exactly stock search. See the class + * comment for the reasoning behind the {@link #DEFAULT_FLOOR_ACTIVATION_K default}. + */ + public SharedFloorKnnCollectorManager( + int k, GlobalKnnFloor globalFloor, float greediness, int floorActivationK) { + this( + k, + globalFloor, + greediness, + floorActivationK, + FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, + FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL); + } + + /** + * Create a fully configured manager. Every tuning value the mechanism has is a parameter here; + * the shorter constructors exist only to supply defaults. + * + * @param k the number of neighbors the query collects + * @param globalFloor the floor shared by all searchers of this query; its {@link + * GlobalKnnFloor#k()} must equal {@code k} + * @param greediness fraction of each segment's search effort that follows the shared floor, in + * {@code [0, 1]}; see {@link FloorAwareKnnCollector} + * @param floorActivationK the smallest k at which floor sharing engages; for smaller k this + * manager creates plain collectors and the search is exactly stock search. See the class + * comment for the reasoning behind the {@link #DEFAULT_FLOOR_ACTIVATION_K default}. + * @param minExplorationSlots smallest permitted size of each collector's greediness clamp queue; + * must be at least 1. See {@link FloorAwareKnnCollector#DEFAULT_MIN_EXPLORATION_SLOTS}. + * @param syncInterval number of visited vectors between each collector's synchronizations with + * the shared floor; must be a power of two. See {@link + * FloorAwareKnnCollector#DEFAULT_SYNC_INTERVAL}. + */ + public SharedFloorKnnCollectorManager( + int k, + GlobalKnnFloor globalFloor, + float greediness, + int floorActivationK, + int minExplorationSlots, + int syncInterval) { + if (k < 1) { + throw new IllegalArgumentException("k must be at least 1, got: " + k); + } + Objects.requireNonNull(globalFloor, "globalFloor"); + if (globalFloor.k() != k) { + throw new IllegalArgumentException( + "the floor must track the same result-set size as the query: floor k=" + + globalFloor.k() + + ", query k=" + + k); + } + if (greediness < 0 || greediness > 1 || Float.isNaN(greediness)) { + throw new IllegalArgumentException("greediness must be in [0,1], got: " + greediness); + } + if (floorActivationK < 1) { + throw new IllegalArgumentException( + "floorActivationK must be at least 1, got: " + floorActivationK); + } + if (minExplorationSlots < 1) { + throw new IllegalArgumentException( + "minExplorationSlots must be at least 1, got: " + minExplorationSlots); + } + if (syncInterval < 1 || Integer.bitCount(syncInterval) != 1) { + throw new IllegalArgumentException( + "syncInterval must be a power of two, got: " + syncInterval); + } + this.k = k; + this.globalFloor = globalFloor; + this.greediness = greediness; + this.floorActivationK = floorActivationK; + this.minExplorationSlots = minExplorationSlots; + this.syncInterval = syncInterval; + } + + /** Return the floor shared by this manager's collectors, so that callers may feed or read it. */ + public GlobalKnnFloor getGlobalFloor() { + return globalFloor; + } + + @Override + public KnnCollector newCollector( + int visitedLimit, KnnSearchStrategy searchStrategy, LeafReaderContext context) + throws IOException { + TopKnnCollector collector = new TopKnnCollector(k, visitedLimit, searchStrategy); + if (k < floorActivationK) { + return collector; + } + return new FloorAwareKnnCollector( + collector, globalFloor, greediness, minExplorationSlots, syncInterval); + } + + @Override + public KnnCollector newOptimisticCollector( + int visitedLimit, KnnSearchStrategy searchStrategy, LeafReaderContext context, int perLeafK) + throws IOException { + // The local queue, and with it the ascent gate, is sized to the segment's pro-rata share; the + // floor itself always tracks the full k best across the query. Activation is decided by the + // query's k, not the segment's share: the policy is about the query. + TopKnnCollector collector = new TopKnnCollector(perLeafK, visitedLimit, searchStrategy); + if (k < floorActivationK) { + return collector; + } + return new FloorAwareKnnCollector( + collector, globalFloor, greediness, minExplorationSlots, syncInterval); + } + + @Override + public boolean isOptimistic() { + return true; + } +} diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/package-info.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/package-info.java new file mode 100644 index 000000000000..4ae3ff79cb8b --- /dev/null +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/package-info.java @@ -0,0 +1,24 @@ +/* + * 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. + */ + +/** + * Shared-floor kNN collection: collectors that prune each searcher's graph exploration against a + * lower bound of the query's final merged top-k cutoff, shared across index segments and, through + * {@link org.apache.lucene.sandbox.search.knn.GlobalKnnFloor#advertise(float)}, across searchers in + * other processes. + */ +package org.apache.lucene.sandbox.search.knn; diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java new file mode 100644 index 000000000000..875fd8bae91f --- /dev/null +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java @@ -0,0 +1,100 @@ +/* + * 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.sandbox.search.knn; + +import static com.carrotsearch.randomizedtesting.RandomizedTest.randomIntBetween; + +import java.util.concurrent.CountDownLatch; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.util.SuppressForbidden; + +public class TestBlockingFloatHeap extends LuceneTestCase { + + public void testBasicOperations() { + BlockingFloatHeap heap = new BlockingFloatHeap(3); + heap.offer(2); + heap.offer(4); + heap.offer(1); + heap.offer(3); + assertEquals(3, heap.size()); + assertEquals(2, heap.peek(), 0); + + assertEquals(2, heap.poll(), 0); + assertEquals(3, heap.poll(), 0); + assertEquals(4, heap.poll(), 0); + assertEquals(0, heap.size(), 0); + } + + public void testBasicOperations2() { + int size = atLeast(10); + BlockingFloatHeap heap = new BlockingFloatHeap(size); + double sum = 0, sum2 = 0; + + for (int i = 0; i < size; i++) { + float next = random().nextFloat(100f); + sum += next; + heap.offer(next); + } + + float last = Float.NEGATIVE_INFINITY; + for (long i = 0; i < size; i++) { + float next = heap.poll(); + assertTrue(next >= last); + last = next; + sum2 += last; + } + assertEquals(sum, sum2, 0.01); + } + + @SuppressForbidden(reason = "Thread sleep") + public void testMultipleThreads() throws Exception { + Thread[] threads = new Thread[randomIntBetween(3, 20)]; + final CountDownLatch latch = new CountDownLatch(1); + BlockingFloatHeap globalHeap = new BlockingFloatHeap(1); + + for (int i = 0; i < threads.length; i++) { + threads[i] = + new Thread( + () -> { + try { + latch.await(); + int numIterations = randomIntBetween(10, 100); + float bottomValue = 0; + + while (numIterations-- > 0) { + bottomValue += randomIntBetween(0, 5); + globalHeap.offer(bottomValue); + Thread.sleep(randomIntBetween(0, 50)); + + float globalBottomValue = globalHeap.peek(); + assertTrue(globalBottomValue >= bottomValue); + bottomValue = globalBottomValue; + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + threads[i].start(); + } + + latch.countDown(); + for (Thread t : threads) { + t.join(); + } + } +} diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java new file mode 100644 index 000000000000..efda19b5dee2 --- /dev/null +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java @@ -0,0 +1,360 @@ +/* + * 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.sandbox.search.knn; + +import org.apache.lucene.search.TopKnnCollector; +import org.apache.lucene.tests.util.LuceneTestCase; + +public class TestFloorAwareKnnCollector extends LuceneTestCase { + + public void testInvalidGreediness() { + GlobalKnnFloor floor = new GlobalKnnFloor(4); + TopKnnCollector delegate = new TopKnnCollector(4, Integer.MAX_VALUE); + expectThrows( + IllegalArgumentException.class, () -> new FloorAwareKnnCollector(delegate, floor, -0.1f)); + expectThrows( + IllegalArgumentException.class, () -> new FloorAwareKnnCollector(delegate, floor, 1.1f)); + expectThrows( + IllegalArgumentException.class, + () -> new FloorAwareKnnCollector(delegate, floor, Float.NaN)); + } + + public void testInvalidMinExplorationSlots() { + GlobalKnnFloor floor = new GlobalKnnFloor(4); + TopKnnCollector delegate = new TopKnnCollector(4, Integer.MAX_VALUE); + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, floor, 0.5f, 0, FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL)); + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, floor, 0.5f, -1, FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL)); + } + + public void testInvalidSyncInterval() { + GlobalKnnFloor floor = new GlobalKnnFloor(4); + TopKnnCollector delegate = new TopKnnCollector(4, Integer.MAX_VALUE); + // The interval is applied as a bit mask over the visited count, hence the power-of-two + // requirement; anything else would synchronize at irregular, surprising points. + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, 0)); + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, 100)); + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, -8)); + } + + public void testInvalidGateK() { + GlobalKnnFloor floor = new GlobalKnnFloor(8); + TopKnnCollector delegate = new TopKnnCollector(8, Integer.MAX_VALUE); + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, + floor, + 0.5f, + FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, + FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL, + 0)); + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, + floor, + 0.5f, + FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, + FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL, + -1)); + // A gate above the queue size could never open: the queue's size saturates at its capacity. + expectThrows( + IllegalArgumentException.class, + () -> + new FloorAwareKnnCollector( + delegate, + floor, + 0.5f, + FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, + FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL, + 9)); + } + + public void testGateBelowQueueSizeOpensAtGateKResults() { + // A share-sized gate: the local queue holds 32 results but the collector's expected share of + // the merged top-k is only 8, so the floor must engage after 8 collected results, long before + // the queue fills. The local k-th best is still undefined at that point, so the exposed bound + // comes entirely from min(clamp, floor). + int queueSize = 32; + int gateK = 8; + GlobalKnnFloor floor = new GlobalKnnFloor(queueSize); + floor.advertise(1000f); + TopKnnCollector delegate = new TopKnnCollector(queueSize, Integer.MAX_VALUE); + // greediness 1 with a slot minimum of 2 keeps the two best scores seen as exploration slots. + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector( + delegate, floor, 1f, 2, FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL, gateK); + + for (int doc = 0; doc < gateK - 1; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + assertEquals( + "the shared floor must be invisible until gateK results are collected", + Float.NEGATIVE_INFINITY, + collector.minCompetitiveSimilarity(), + 0.0f); + } + + collector.incVisitedCount(1); + collector.collect(gateK - 1, (float) gateK); + // Scores are 1..8: the clamp queue keeps {7, 8}, so the bound is min(7, nextDown(1000)) = 7, + // while the delegate's own bound is still NEGATIVE_INFINITY because its queue is not full. + assertEquals(Float.NEGATIVE_INFINITY, delegate.minCompetitiveSimilarity(), 0.0f); + assertEquals( + "once the gate opens, the bound must come from the clamp and the floor", + 7f, + collector.minCompetitiveSimilarity(), + 0.0f); + } + + public void testGreedinessClampIsSizedFromGateK() { + // With a queue of 40 but a gate of 8 at greediness 0.5, the clamp must keep (1 - 0.5) * 8 = 4 + // slots, not (1 - 0.5) * 40 = 20: the clamp protects the share-sized search the gate defines, + // not the queue's capacity. + int queueSize = 40; + int gateK = 8; + GlobalKnnFloor floor = new GlobalKnnFloor(queueSize); + floor.advertise(1000f); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector( + new TopKnnCollector(queueSize, Integer.MAX_VALUE), + floor, + 0.5f, + 1, + FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL, + gateK); + + for (int doc = 0; doc < gateK; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + } + + // Scores are 1..8 and the clamp keeps the best 4 of them, {5, 6, 7, 8}: the bound must be the + // clamp's minimum, 5. A clamp sized from the queue would hold all 8 scores and expose 1. + assertEquals( + "the clamp must keep (1 - greediness) * gateK slots", + 5f, + collector.minCompetitiveSimilarity(), + 0.0f); + } + + public void testConfigurableSlotMinimumControlsNeutralization() { + // The neutralization property follows the configured minimum, not a baked-in number: with a + // slot minimum of 4 at k=8, the clamp is narrower than the local queue and an advertised + // floor must bind; with a slot minimum of 8 it must not. + int k = 8; + for (int slots : new int[] {4, 8}) { + GlobalKnnFloor floor = new GlobalKnnFloor(k); + floor.advertise(1000f); + TopKnnCollector delegate = new TopKnnCollector(k, Integer.MAX_VALUE); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector( + delegate, floor, 1f, slots, FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL); + for (int doc = 0; doc < k; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + } + if (slots < k) { + assertTrue( + "a clamp narrower than the local queue must let the floor bind", + collector.minCompetitiveSimilarity() > delegate.minCompetitiveSimilarity()); + } else { + assertEquals( + "a clamp as wide as the local queue must neutralize the floor", + delegate.minCompetitiveSimilarity(), + collector.minCompetitiveSimilarity(), + 0.0f); + } + } + } + + public void testAscentGateIgnoresFloorUntilLocalQueueFills() { + int k = FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS + 4; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + // A sibling searcher has already converged and established a high floor. A correct collector + // must not expose it before this searcher has escaped its own ascent, otherwise the graph + // search would be terminated at its entry point before finding anything. + floor.advertise(1000f); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector(new TopKnnCollector(k, Integer.MAX_VALUE), floor); + + for (int doc = 0; doc < k - 1; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + assertEquals( + "the shared floor must be invisible while the local queue is filling", + Float.NEGATIVE_INFINITY, + collector.minCompetitiveSimilarity(), + 0.0f); + } + + collector.incVisitedCount(1); + collector.collect(k - 1, (float) k); + assertTrue( + "once the local queue is full, the shared floor must start binding", + collector.minCompetitiveSimilarity() > Float.NEGATIVE_INFINITY); + // With k exceeding the clamp's minimum size, the bound must exceed the local k-th best (1): + // the floor is genuinely binding, not merely echoing the local queue. + assertTrue(collector.minCompetitiveSimilarity() > 1f); + } + + public void testGreedinessClampCapsTheSharedFloor() { + // k and greediness sized so the fractional clamp exceeds the absolute minimum: 40 collected + // scores with greediness 0.5 keep a non-competitive queue of 20 entries, so the effective + // bound may never exceed the 20th best similarity this collector has seen. + int k = 40; + float greediness = 0.5f; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + floor.advertise(1000f); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector(new TopKnnCollector(k, Integer.MAX_VALUE), floor, greediness); + + for (int doc = 0; doc < k; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + } + + // Scores are 1..40: the local k-th best is 1, the 20th best seen is 21, and the floor is + // 1000. The clamp must win. + assertEquals( + "the bound must be capped by the clamp queue's minimum, not jump to the shared floor", + 21f, + collector.minCompetitiveSimilarity(), + 0.0f); + } + + public void testSharedFloorBindsOneUlpBelowItsValue() { + int localK = 20; + // Size the floor for a larger result set so the local scores cannot define it: the only floor + // source in this test is the advertised bound. + GlobalKnnFloor floor = new GlobalKnnFloor(100); + floor.advertise(2.5f); + // greediness 1 collapses the clamp to its absolute minimum of DEFAULT_MIN_EXPLORATION_SLOTS + // entries. + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector(new TopKnnCollector(localK, Integer.MAX_VALUE), floor, 1f); + + // Three scores below the advertised floor, then 17 above it: the local k-th best (1) stays + // below the floor while the clamp queue's minimum (the 16th best seen, 3.1) rises above it, + // so min(clamp, nextDown(floor)) selects the floor term. + collector.incVisitedCount(1); + collector.collect(0, 1f); + collector.incVisitedCount(1); + collector.collect(1, 1.2f); + collector.incVisitedCount(1); + collector.collect(2, 1.4f); + for (int doc = 3; doc < localK; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, 3f + 0.1f * (doc - 2)); + } + + // The bound must sit strictly below the floor: a hit scoring exactly at the floor may still + // win the merged tie-break and must remain findable. + assertEquals(Math.nextDown(2.5f), collector.minCompetitiveSimilarity(), 0.0f); + assertTrue(collector.minCompetitiveSimilarity() < 2.5f); + } + + public void testFloorIsNeutralizedAtSmallK() { + // When k does not exceed the clamp's absolute minimum, the clamp queue is at least as large + // as the local queue, its minimum can never exceed the local k-th best, and the shared floor + // must have no effect at all: even a hostile advertised bound cannot change the bound stock + // search would have used. + int k = FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS / 2; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + floor.advertise(Float.MAX_VALUE); + TopKnnCollector delegate = new TopKnnCollector(k, Integer.MAX_VALUE); + FloorAwareKnnCollector collector = new FloorAwareKnnCollector(delegate, floor, 1f); + + for (int doc = 0; doc < 3 * k; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, random().nextFloat()); + assertEquals( + "at k <= the clamp's slot minimum the bound must be exactly the delegate's", + delegate.minCompetitiveSimilarity(), + collector.minCompetitiveSimilarity(), + 0.0f); + } + } + + public void testCollectReportsSharedFloorUpdates() { + // k equal to the clamp's absolute minimum makes both local structures the same size, so a + // score rejected by the local queue is also rejected by the clamp queue and cannot report an + // update through either. + int k = FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector(new TopKnnCollector(k, Integer.MAX_VALUE), floor, 0f); + + for (int doc = 0; doc < k; doc++) { + collector.incVisitedCount(1); + assertTrue("a locally accepted hit must report an update", collector.collect(doc, doc + 5f)); + } + + // Both queues hold 5..k+4. A worse score away from a synchronization boundary changes + // nothing and must say so, otherwise the searcher would re-derive its bound for no reason. + collector.incVisitedCount(1); + assertFalse( + "a rejected hit between synchronizations must not report an update", + collector.collect(k, 1f)); + + // Advance to the next synchronization boundary: even a rejected hit must report an update + // there, because the re-read of the shared floor may have moved the effective bound. + collector.incVisitedCount(256 - (k + 1)); + assertEquals(0, collector.visitedCount() & 0xff); + assertTrue( + "a hit on a synchronization boundary must report an update after the floor re-read", + collector.collect(k + 1, 1f)); + } + + public void testDelegationOfCollectorPlumbing() { + GlobalKnnFloor floor = new GlobalKnnFloor(3); + TopKnnCollector delegate = new TopKnnCollector(3, 17); + FloorAwareKnnCollector collector = new FloorAwareKnnCollector(delegate, floor); + assertEquals(3, collector.k()); + assertEquals(17, collector.visitLimit()); + collector.incVisitedCount(5); + assertEquals(5, collector.visitedCount()); + assertEquals(5, delegate.visitedCount()); + assertFalse(collector.earlyTerminated()); + collector.incVisitedCount(12); + assertTrue( + "the visit limit must keep terminating through the decorator", collector.earlyTerminated()); + } +} diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java new file mode 100644 index 000000000000..bb28c40f5419 --- /dev/null +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java @@ -0,0 +1,213 @@ +/* + * 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.sandbox.search.knn; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.TestUtil; +import org.apache.lucene.util.NamedThreadFactory; + +public class TestGlobalKnnFloor extends LuceneTestCase { + + public void testInvalidK() { + IllegalArgumentException e = + expectThrows(IllegalArgumentException.class, () -> new GlobalKnnFloor(0)); + assertTrue(e.getMessage(), e.getMessage().contains("k must be at least 1")); + expectThrows(IllegalArgumentException.class, () -> new GlobalKnnFloor(-1)); + } + + public void testFloorUndefinedUntilKScoresObserved() { + int k = TestUtil.nextInt(random(), 1, 50); + GlobalKnnFloor floor = new GlobalKnnFloor(k); + assertEquals(Float.NEGATIVE_INFINITY, floor.floor(), 0.0f); + for (int i = 0; i < k - 1; i++) { + float returned = floor.offer(new float[] {random().nextFloat()}, 1); + assertEquals( + "floor must stay undefined while fewer than k scores have been observed", + Float.NEGATIVE_INFINITY, + returned, + 0.0f); + } + float kthArrival = random().nextFloat(); + float returned = floor.offer(new float[] {kthArrival}, 1); + assertTrue( + "floor must be defined once k scores have been observed", + returned > Float.NEGATIVE_INFINITY); + } + + public void testFloorIsKthBestOfEverythingOffered() { + int k = TestUtil.nextInt(random(), 1, 32); + GlobalKnnFloor floor = new GlobalKnnFloor(k); + int totalScores = TestUtil.nextInt(random(), k, 500); + List allScores = new ArrayList<>(totalScores); + int offered = 0; + while (offered < totalScores) { + int batchSize = TestUtil.nextInt(random(), 1, Math.min(16, totalScores - offered)); + float[] batch = new float[batchSize]; + for (int i = 0; i < batchSize; i++) { + batch[i] = random().nextFloat() * 10; + allScores.add(batch[i]); + } + Arrays.sort(batch); + floor.offer(batch, batchSize); + offered += batchSize; + } + allScores.sort(Comparator.reverseOrder()); + float expectedKthBest = allScores.get(k - 1); + assertEquals(expectedKthBest, floor.floor(), 0.0f); + } + + public void testFloorIsMonotonic() { + int k = TestUtil.nextInt(random(), 1, 16); + GlobalKnnFloor floor = new GlobalKnnFloor(k); + float previous = floor.floor(); + for (int batch = 0; batch < 50; batch++) { + int batchSize = TestUtil.nextInt(random(), 1, 8); + float[] scores = new float[batchSize]; + for (int i = 0; i < batchSize; i++) { + scores[i] = (random().nextFloat() - 0.5f) * 100; + } + Arrays.sort(scores); + float current = floor.offer(scores, batchSize); + assertTrue("floor must never decrease", current >= previous); + previous = current; + } + } + + public void testAdvertiseIsIndependentOfHeapFill() { + // An advertised bound is valid on its own authority; it must define the floor even when no + // local scores have been observed yet. + GlobalKnnFloor floor = new GlobalKnnFloor(10); + floor.advertise(0.5f); + assertEquals(0.5f, floor.floor(), 0.0f); + } + + public void testAdvertiseBelowCurrentFloorIsIgnored() { + GlobalKnnFloor floor = new GlobalKnnFloor(10); + floor.advertise(0.5f); + floor.advertise(0.4f); + assertEquals( + "a lower advertised bound must not lower the floor: duplicated or reordered remote " + + "deliveries rely on this", + 0.5f, + floor.floor(), + 0.0f); + floor.advertise(0.6f); + assertEquals(0.6f, floor.floor(), 0.0f); + } + + public void testAdvertiseCombinesWithLocalScores() { + int k = 4; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + float[] scores = new float[] {0.1f, 0.2f, 0.3f, 0.4f}; + floor.offer(scores, scores.length); + assertEquals(0.1f, floor.floor(), 0.0f); + // A remote bound above the local k-th best takes over. + floor.advertise(0.25f); + assertEquals(0.25f, floor.floor(), 0.0f); + // Local scores that push the local k-th best above the advertised bound take over again. + floor.offer(new float[] {0.5f, 0.6f, 0.7f}, 3); + // Heap now holds {0.4, 0.5, 0.6, 0.7}: local k-th best is 0.4. + assertEquals(0.4f, floor.floor(), 0.0f); + } + + public void testAdvertiseRejectsNaN() { + GlobalKnnFloor floor = new GlobalKnnFloor(10); + expectThrows(IllegalArgumentException.class, () -> floor.advertise(Float.NaN)); + } + + public void testOfferRejectsNonPositiveLen() { + GlobalKnnFloor floor = new GlobalKnnFloor(10); + expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {1f}, 0)); + expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {1f}, -1)); + } + + public void testConcurrentPublishersConvergeToKthBest() throws Exception { + int k = 64; + int numThreads = TestUtil.nextInt(random(), 2, 8); + int batchesPerThread = 50; + int maxBatchSize = 16; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + + // Pre-generate every thread's scores on the test thread so the expected k-th best can be + // computed independently of scheduling. + float[][][] batches = new float[numThreads][batchesPerThread][]; + List allScores = new ArrayList<>(); + for (int t = 0; t < numThreads; t++) { + for (int b = 0; b < batchesPerThread; b++) { + int batchSize = TestUtil.nextInt(random(), 1, maxBatchSize); + float[] batch = new float[batchSize]; + for (int i = 0; i < batchSize; i++) { + batch[i] = random().nextFloat(); + allScores.add(batch[i]); + } + Arrays.sort(batch); + batches[t][b] = batch; + } + } + allScores.sort(Comparator.reverseOrder()); + float expectedKthBest = allScores.get(k - 1); + + ExecutorService executor = + Executors.newFixedThreadPool(numThreads, new NamedThreadFactory("global-knn-floor-test")); + try { + CountDownLatch startingGun = new CountDownLatch(1); + List> futures = new ArrayList<>(numThreads); + for (int t = 0; t < numThreads; t++) { + final int thread = t; + futures.add( + executor.submit( + () -> { + try { + startingGun.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + float previous = Float.NEGATIVE_INFINITY; + for (float[] batch : batches[thread]) { + float current = floor.offer(batch, batch.length); + assertTrue( + "floor must appear monotonic to every publisher", current >= previous); + previous = current; + } + })); + } + startingGun.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdown(); + assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS)); + } + assertEquals( + "after all publishers finish, the floor must be the k-th best of every score offered", + expectedKthBest, + floor.floor(), + 0.0f); + } +} diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnSearch.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnSearch.java new file mode 100644 index 000000000000..f5275849fe45 --- /dev/null +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnSearch.java @@ -0,0 +1,538 @@ +/* + * 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.sandbox.search.knn; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.KnnFloatVectorField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.FloatVectorValues; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.KnnVectorValues; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.NoMergePolicy; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.search.DocIdSetIterator; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.KnnFloatVectorQuery; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.TopDocs; +import org.apache.lucene.search.knn.KnnCollectorManager; +import org.apache.lucene.store.Directory; +import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.util.NamedThreadFactory; + +/** + * End-to-end tests of kNN search through {@link SharedFloorKnnCollectorManager}: the shared floor + * must reduce work without changing what stock search would have found. + * + *

Queries here activate the floor at every k (see {@link SharedFloorKnnQuery}), because the + * point is to exercise the floor path; the activation default is covered by its own test. The k + * values exceed {@link FloorAwareKnnCollector#DEFAULT_MIN_EXPLORATION_SLOTS}, since at or below it + * the clamp neutralizes the floor by design and the tests would not be testing anything. + */ +public class TestSharedFloorKnnSearch extends LuceneTestCase { + + private static final String FIELD = "vector"; + private static final VectorSimilarityFunction SIMILARITY = VectorSimilarityFunction.EUCLIDEAN; + + /** + * The floor must not cost recall relative to stock search, regardless of how the index is cut + * into segments. + */ + public void testRecallParityAcrossSegmentCounts() throws IOException { + int dim = 16; + int numDocs = 1200; + int k = 64; + int numQueries = 10; + for (int segments : new int[] {1, 2, 5}) { + float[][] vectors = new float[numDocs][]; + for (int i = 0; i < numDocs; i++) { + vectors[i] = randomVector(dim); + } + try (Directory dir = newDirectory()) { + indexInSegments(dir, vectors, segments); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + assertEquals(segments, reader.leaves().size()); + IndexSearcher searcher = new IndexSearcher(reader); + double stockRecallSum = 0; + double flooredRecallSum = 0; + for (int i = 0; i < numQueries; i++) { + float[] query = randomVector(dim); + Set truth = exactTopK(reader, query, k); + stockRecallSum += + recall(searcher.search(new KnnFloatVectorQuery(FIELD, query, k), k), truth, k); + flooredRecallSum += + recall(searcher.search(new SharedFloorKnnQuery(FIELD, query, k), k), truth, k); + } + double stockRecall = stockRecallSum / numQueries; + double flooredRecall = flooredRecallSum / numQueries; + assertTrue( + "shared-floor search lost recall at " + + segments + + " segments: stock=" + + stockRecall + + " floored=" + + flooredRecall, + flooredRecall >= stockRecall - 0.05); + } + } + } + } + + /** + * With no executor, the leaves of a query are searched one after another, and a segment that + * converges early establishes a floor before later segments have collected anything. This is the + * harshest ordering for a shared bound, and the ascent gate is what makes it safe. The scenario + * is made adversarial: the first segment is a tight cluster of mediocre near-duplicates that + * converges quickly to a high local cutoff, while every true neighbor lives in later segments, + * still unsearched when that cutoff is published. + */ + public void testDecoyFirstSegmentDoesNotStarveLaterSegments() throws IOException { + int dim = 16; + int k = 64; + int trueNeighborSegments = 4; + int trueNeighborsPerSegment = 16; + int backgroundPerSegment = 300; + int decoyCount = 400; + + float[] center = randomVector(dim); + float[] decoyDirection = randomUnitVector(dim); + + // Segment 0: the decoy cluster, at moderate distance from the query, internally very dense. + List decoySegment = new ArrayList<>(decoyCount); + for (int i = 0; i < decoyCount; i++) { + decoySegment.add(displaced(center, decoyDirection, 2f, 0.02f)); + } + + // Segments 1..4: a few true nearest neighbors each, hidden among distant background vectors. + List> laterSegments = new ArrayList<>(trueNeighborSegments); + for (int s = 0; s < trueNeighborSegments; s++) { + List segment = new ArrayList<>(trueNeighborsPerSegment + backgroundPerSegment); + for (int i = 0; i < trueNeighborsPerSegment; i++) { + segment.add( + displaced(center, randomUnitVector(dim), 0.9f + random().nextFloat() * 0.2f, 0f)); + } + for (int i = 0; i < backgroundPerSegment; i++) { + segment.add(displaced(center, randomUnitVector(dim), 5f + random().nextFloat(), 0f)); + } + laterSegments.add(segment); + } + + try (Directory dir = newDirectory()) { + try (IndexWriter writer = + new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) { + addSegment(writer, decoySegment); + for (List segment : laterSegments) { + addSegment(writer, segment); + } + writer.commit(); + } + try (DirectoryReader reader = DirectoryReader.open(dir)) { + assertEquals(1 + trueNeighborSegments, reader.leaves().size()); + IndexSearcher searcher = new IndexSearcher(reader); + int numQueries = 5; + double stockRecallSum = 0; + double flooredRecallSum = 0; + for (int i = 0; i < numQueries; i++) { + float[] query = displaced(center, randomUnitVector(dim), 0.05f, 0f); + Set truth = exactTopK(reader, query, k); + stockRecallSum += + recall(searcher.search(new KnnFloatVectorQuery(FIELD, query, k), k), truth, k); + flooredRecallSum += + recall(searcher.search(new SharedFloorKnnQuery(FIELD, query, k), k), truth, k); + } + double stockRecall = stockRecallSum / numQueries; + double flooredRecall = flooredRecallSum / numQueries; + assertTrue( + "an early-converging decoy segment starved the segments holding the true neighbors: " + + "stock=" + + stockRecall + + " floored=" + + flooredRecall, + flooredRecall >= stockRecall - 0.05); + } + } + } + + /** + * Without an executor, execution is single-threaded and the floor evolves identically on every + * run, so two executions of the same search must return exactly the same documents and scores. + */ + public void testSequentialSearchIsDeterministic() throws IOException { + int dim = 16; + int numDocs = 1000; + int k = 32; + float[][] vectors = new float[numDocs][]; + for (int i = 0; i < numDocs; i++) { + vectors[i] = randomVector(dim); + } + try (Directory dir = newDirectory()) { + indexInSegments(dir, vectors, 4); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher searcher = new IndexSearcher(reader); + for (int i = 0; i < 5; i++) { + float[] query = randomVector(dim); + // Fresh query objects: a manager and its floor carry single-execution state. + TopDocs first = searcher.search(new SharedFloorKnnQuery(FIELD, query, k), k); + TopDocs second = searcher.search(new SharedFloorKnnQuery(FIELD, query, k), k); + assertEquals(first.scoreDocs.length, second.scoreDocs.length); + for (int j = 0; j < first.scoreDocs.length; j++) { + assertEquals("doc at rank " + j, first.scoreDocs[j].doc, second.scoreDocs[j].doc); + assertEquals( + "score at rank " + j, first.scoreDocs[j].score, second.scoreDocs[j].score, 0.0f); + } + } + } + } + } + + /** + * Under an executor the leaves are searched concurrently and the floor's evolution depends on + * thread interleaving, which may legitimately vary visit counts; result quality must not suffer. + */ + public void testParallelRecallMatchesSequential() throws Exception { + int dim = 16; + int numDocs = 1200; + int k = 64; + int numQueries = 10; + float[][] vectors = new float[numDocs][]; + for (int i = 0; i < numDocs; i++) { + vectors[i] = randomVector(dim); + } + ExecutorService executor = + Executors.newFixedThreadPool(4, new NamedThreadFactory("shared-floor-knn-test")); + try (Directory dir = newDirectory()) { + indexInSegments(dir, vectors, 5); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher sequentialSearcher = new IndexSearcher(reader); + IndexSearcher parallelSearcher = new IndexSearcher(reader, executor); + double sequentialRecallSum = 0; + double parallelRecallSum = 0; + for (int i = 0; i < numQueries; i++) { + float[] query = randomVector(dim); + Set truth = exactTopK(reader, query, k); + sequentialRecallSum += + recall( + sequentialSearcher.search(new SharedFloorKnnQuery(FIELD, query, k), k), truth, k); + parallelRecallSum += + recall( + parallelSearcher.search(new SharedFloorKnnQuery(FIELD, query, k), k), truth, k); + } + double sequentialRecall = sequentialRecallSum / numQueries; + double parallelRecall = parallelRecallSum / numQueries; + assertTrue( + "parallel execution lost recall: sequential=" + + sequentialRecall + + " parallel=" + + parallelRecall, + parallelRecall >= sequentialRecall - 0.05); + } + } finally { + executor.shutdown(); + assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS)); + } + } + + /** + * A bound advertised from outside the searching process with realistic slack, here the query's + * exact similarity at global rank 4k, must not cost recall. Tightness, not validity, is what + * risks recall with an externally advertised bound: a bar close to the final cutoff, imposed + * before the graph search has discovered its neighborhood, can sever the paths through mediocre + * intermediate nodes that graph navigation depends on. An advertiser is therefore expected to + * leave rank headroom (for example, a scout advertising its k'-th best for k' several times k), + * and this test pins down that a bound with such headroom is harmless. + */ + public void testSlackAdvertisedBoundPreservesRecall() throws IOException { + int dim = 16; + int numDocs = 1200; + int k = 64; + int numQueries = 10; + float[][] vectors = new float[numDocs][]; + for (int i = 0; i < numDocs; i++) { + vectors[i] = randomVector(dim); + } + try (Directory dir = newDirectory()) { + indexInSegments(dir, vectors, 4); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher searcher = new IndexSearcher(reader); + double unadvertisedRecallSum = 0; + double advertisedRecallSum = 0; + for (int i = 0; i < numQueries; i++) { + float[] query = randomVector(dim); + Set truth = exactTopK(reader, query, k); + unadvertisedRecallSum += + recall(searcher.search(new SharedFloorKnnQuery(FIELD, query, k), k), truth, k); + + SharedFloorKnnQuery advertisedQuery = new SharedFloorKnnQuery(FIELD, query, k); + advertisedQuery + .manager + .getGlobalFloor() + .advertise(exactKthBestScore(reader, query, 4 * k)); + advertisedRecallSum += recall(searcher.search(advertisedQuery, k), truth, k); + } + double unadvertisedRecall = unadvertisedRecallSum / numQueries; + double advertisedRecall = advertisedRecallSum / numQueries; + assertTrue( + "an advertised bound with rank headroom cost recall: unadvertised=" + + unadvertisedRecall + + " advertised=" + + advertisedRecall, + advertisedRecall >= unadvertisedRecall - 0.05); + } + } + } + + /** + * At {@code greediness = 0} the non-competitive queue is at least as large as the local queue, so + * the effective bound can never exceed what the local search would have imposed on itself: the + * shared floor is fully neutralized. Recall must then match stock search even under the tightest + * bound that exists, the query's exact final k-th best similarity, advertised before the search + * starts. This pins down the greediness dial's safe endpoint; the recall cost of tighter settings + * under tight bounds is a measured trade, not a correctness property. + */ + public void testZeroGreedinessNeutralizesTightestBound() throws IOException { + int dim = 16; + int numDocs = 1200; + int k = 64; + int numQueries = 10; + float[][] vectors = new float[numDocs][]; + for (int i = 0; i < numDocs; i++) { + vectors[i] = randomVector(dim); + } + try (Directory dir = newDirectory()) { + indexInSegments(dir, vectors, 4); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher searcher = new IndexSearcher(reader); + double stockRecallSum = 0; + double advertisedRecallSum = 0; + for (int i = 0; i < numQueries; i++) { + float[] query = randomVector(dim); + Set truth = exactTopK(reader, query, k); + stockRecallSum += + recall(searcher.search(new KnnFloatVectorQuery(FIELD, query, k), k), truth, k); + + SharedFloorKnnQuery advertisedQuery = new SharedFloorKnnQuery(FIELD, query, k, 0f); + advertisedQuery.manager.getGlobalFloor().advertise(exactKthBestScore(reader, query, k)); + advertisedRecallSum += recall(searcher.search(advertisedQuery, k), truth, k); + } + double stockRecall = stockRecallSum / numQueries; + double advertisedRecall = advertisedRecallSum / numQueries; + assertTrue( + "greediness 0 must neutralize even the tightest advertised bound: stock=" + + stockRecall + + " advertised=" + + advertisedRecall, + advertisedRecall >= stockRecall - 0.05); + } + } + } + + /** + * Below the activation threshold the manager creates plain collectors, so the search must be + * stock search to the last bit: identical documents and scores, even when a hostile (invalid) + * bound has been advertised. This is the policy layer that keeps small-k queries, where a floor + * has little to save and the most recall to lose, entirely out of the mechanism. + */ + public void testBelowActivationThresholdSearchIsExactlyStock() throws IOException { + int dim = 16; + int numDocs = 1000; + int k = 10; + float[][] vectors = new float[numDocs][]; + for (int i = 0; i < numDocs; i++) { + vectors[i] = randomVector(dim); + } + assertTrue( + "this test requires k below the default activation threshold", + k < SharedFloorKnnCollectorManager.DEFAULT_FLOOR_ACTIVATION_K); + try (Directory dir = newDirectory()) { + indexInSegments(dir, vectors, 4); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + IndexSearcher searcher = new IndexSearcher(reader); + for (int i = 0; i < 5; i++) { + float[] query = randomVector(dim); + TopDocs stock = searcher.search(new KnnFloatVectorQuery(FIELD, query, k), k); + + SharedFloorKnnQuery flooredQuery = + new SharedFloorKnnQuery( + FIELD, + query, + k, + FloorAwareKnnCollector.DEFAULT_GREEDINESS, + SharedFloorKnnCollectorManager.DEFAULT_FLOOR_ACTIVATION_K); + // Deliberately invalid: far above any real similarity. Below the activation threshold + // it must not matter, because no collector ever consults the floor. + flooredQuery.manager.getGlobalFloor().advertise(Float.MAX_VALUE); + TopDocs floored = searcher.search(flooredQuery, k); + + assertEquals(stock.scoreDocs.length, floored.scoreDocs.length); + for (int j = 0; j < stock.scoreDocs.length; j++) { + assertEquals("doc at rank " + j, stock.scoreDocs[j].doc, floored.scoreDocs[j].doc); + assertEquals( + "score at rank " + j, stock.scoreDocs[j].score, floored.scoreDocs[j].score, 0.0f); + } + } + } + } + } + + /** + * A {@link KnnFloatVectorQuery} routed through a {@link SharedFloorKnnCollectorManager}. The + * manager is created with the query and returned for both collection passes, so both share one + * floor; consequently a query instance carries single-execution state and must not be reused. + * Unless a threshold is given, the floor activates at every k, because these tests exist to + * exercise the floor path. + */ + private static class SharedFloorKnnQuery extends KnnFloatVectorQuery { + final SharedFloorKnnCollectorManager manager; + + SharedFloorKnnQuery(String field, float[] target, int k) { + this(field, target, k, FloorAwareKnnCollector.DEFAULT_GREEDINESS); + } + + SharedFloorKnnQuery(String field, float[] target, int k, float greediness) { + this(field, target, k, greediness, 1); + } + + SharedFloorKnnQuery( + String field, float[] target, int k, float greediness, int floorActivationK) { + super(field, target, k); + this.manager = + new SharedFloorKnnCollectorManager( + k, new GlobalKnnFloor(k), greediness, floorActivationK); + } + + @Override + protected KnnCollectorManager getKnnCollectorManager(int k, IndexSearcher searcher) { + return manager; + } + } + + private float[] randomVector(int dim) { + float[] vector = new float[dim]; + for (int i = 0; i < dim; i++) { + vector[i] = (float) random().nextGaussian(); + } + return vector; + } + + private float[] randomUnitVector(int dim) { + float[] vector = randomVector(dim); + double norm = 0; + for (float component : vector) { + norm += component * component; + } + norm = Math.sqrt(norm); + for (int i = 0; i < dim; i++) { + vector[i] /= (float) norm; + } + return vector; + } + + /** + * Return {@code center + distance * direction + jitter}, where the jitter is a Gaussian + * perturbation of the given magnitude in each dimension. + */ + private float[] displaced(float[] center, float[] direction, float distance, float jitter) { + float[] vector = new float[center.length]; + for (int i = 0; i < center.length; i++) { + vector[i] = center[i] + distance * direction[i] + jitter * (float) random().nextGaussian(); + } + return vector; + } + + private void indexInSegments(Directory dir, float[][] vectors, int segments) throws IOException { + try (IndexWriter writer = + new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) { + int docsPerSegment = (vectors.length + segments - 1) / segments; + int written = 0; + while (written < vectors.length) { + List segment = new ArrayList<>(docsPerSegment); + for (int i = 0; i < docsPerSegment && written < vectors.length; i++, written++) { + segment.add(vectors[written]); + } + addSegment(writer, segment); + } + writer.commit(); + } + } + + private void addSegment(IndexWriter writer, List vectors) throws IOException { + for (float[] vector : vectors) { + Document doc = new Document(); + doc.add(new KnnFloatVectorField(FIELD, vector, SIMILARITY)); + writer.addDocument(doc); + } + writer.flush(); + } + + private record DocScore(int doc, float score) {} + + private List exactSearch(IndexReader reader, float[] query) throws IOException { + List scored = new ArrayList<>(); + for (LeafReaderContext ctx : reader.leaves()) { + FloatVectorValues values = ctx.reader().getFloatVectorValues(FIELD); + assertNotNull(values); + KnnVectorValues.DocIndexIterator iterator = values.iterator(); + for (int doc = iterator.nextDoc(); + doc != DocIdSetIterator.NO_MORE_DOCS; + doc = iterator.nextDoc()) { + float score = SIMILARITY.compare(query, values.vectorValue(iterator.index())); + scored.add(new DocScore(ctx.docBase + doc, score)); + } + } + scored.sort( + Comparator.comparingDouble(DocScore::score).reversed().thenComparingInt(DocScore::doc)); + return scored; + } + + private Set exactTopK(IndexReader reader, float[] query, int k) throws IOException { + List scored = exactSearch(reader, query); + Set topK = new HashSet<>(); + for (int i = 0; i < k && i < scored.size(); i++) { + topK.add(scored.get(i).doc()); + } + return topK; + } + + private float exactKthBestScore(IndexReader reader, float[] query, int k) throws IOException { + List scored = exactSearch(reader, query); + assertTrue(scored.size() >= k); + return scored.get(k - 1).score(); + } + + private static double recall(TopDocs topDocs, Set truth, int k) { + int hits = 0; + for (ScoreDoc scoreDoc : topDocs.scoreDocs) { + if (truth.contains(scoreDoc.doc)) { + hits++; + } + } + return hits / (double) k; + } +} From 49445437ee693af55a8124e2ae2e690175091fd8 Mon Sep 17 00:00:00 2001 From: Kristian Rickert Date: Mon, 6 Jul 2026 22:18:00 -0400 Subject: [PATCH 2/4] Harden shared-floor kNN collection: fill race, per-shard gating, publish-once Four fixes from review, each with tests: - GlobalKnnFloor.offer read the heap-fullness flag after offering, so a concurrent publisher filling the heap in that window could trick a thread into publishing a partial-heap top that overshoots the true k-th best (over-pruning). The flag is now read before the offer. - BlockingFloatHeap.poll checked emptiness outside the lock, letting two concurrent polls of a one-element heap both pass and corrupt the heap. - FloorAwareKnnCollector's synchronization trigger was an exact bit-mask match over the visited count, evaluated only inside collect(); boundaries falling between two collects were skipped entirely, starving the floor in the late phase of the search where few candidates beat the bar. The schedule is now a visited-count threshold, which also lifts the power-of-two restriction on syncInterval. - SharedFloorKnnCollectorManager gains globalShare and a perShardGate helper mirroring the optimistic strategy's quota formula: a shard of a larger corpus now gates at its expected contribution to the merged top-k instead of at full k (its own index looks like the whole corpus to it, so it could never derive this locally). Full-k collectors over multi-segment indexes likewise gate at each leaf's pro-rata share. The manager also enforces publish-once-per-leaf: the optimistic second pass re-collects the same documents, and republishing them would put duplicate scores in the floor's heap, which can lift the floor above the true merged cutoff. Also documents the greediness/gateK interaction: a low greediness with a below-queue gate sizes the clamp at (1-g)*gateK, which can silently reduce the adaptive floor to a fixed per-shard quota. --- lucene/CHANGES.txt | 6 + .../sandbox/search/knn/BlockingFloatHeap.java | 22 +- .../search/knn/FloorAwareKnnCollector.java | 138 +++++-- .../sandbox/search/knn/GlobalKnnFloor.java | 14 +- .../knn/SharedFloorKnnCollectorManager.java | 160 +++++++- .../search/knn/TestBlockingFloatHeap.java | 57 +++ .../knn/TestFloorAwareKnnCollector.java | 87 ++++- .../search/knn/TestGlobalKnnFloor.java | 67 ++++ .../TestSharedFloorKnnCollectorManager.java | 347 ++++++++++++++++++ 9 files changed, 825 insertions(+), 73 deletions(-) create mode 100644 lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnCollectorManager.java diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index 72fcd7427cdb..1ec0df6898ec 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -103,6 +103,12 @@ API Changes New Features --------------------- +* GITHUB#16357: Add shared-floor kNN collection to the sandbox module: GlobalKnnFloor, + FloorAwareKnnCollector and SharedFloorKnnCollectorManager let concurrent searchers of one query + (segments in one process, or shards across processes via GlobalKnnFloor#advertise) prune graph + exploration against a shared lower bound of the query's final merged top-k cutoff. Opt-in, no + changes to core search. (Kristian Rickert) + * GITHUB#16241: Add HangulCompositionCharFilter to analysis-nori for opt-in composition of modern Hangul conjoining jamo before KoreanTokenizer. (Mingi Jeong) diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java index 5118feea0f63..1475edcd38d3 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java @@ -102,21 +102,21 @@ public float offer(float[] values, int len) { * @throws IllegalStateException if the heap is empty */ public float poll() { - if (size > 0) { - float result; - - lock.lock(); - try { - result = heap[1]; // save first value + lock.lock(); + try { + // The emptiness check must happen under the lock: checking outside lets two concurrent + // polls of a one-element heap both pass, and the second corrupts the heap state. + if (size > 0) { + float result = heap[1]; // save first value heap[1] = heap[size]; // move last to first size--; downHeap(1); // adjust heap - } finally { - lock.unlock(); + return result; + } else { + throw new IllegalStateException("The heap is empty"); } - return result; - } else { - throw new IllegalStateException("The heap is empty"); + } finally { + lock.unlock(); } } diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java index 0ce7fa73107f..5a87c95a38a0 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java @@ -60,13 +60,22 @@ * greediness = 0} the floor has no effect; at {@code greediness = 1} the collector retains * only the absolute minimum of exploration slots. See {@link #DEFAULT_MIN_EXPLORATION_SLOTS} * for why the clamp has an absolute lower bound. + *

Caution: because the clamp is sized from {@code gateK}, a low greediness combined + * with a gate well below the local queue size can leave the clamp as the binding constraint + * for the entire search, silently reducing the collector to a fixed per-searcher quota of + * {@code (1 - greediness) * gateK} results — the floor's adaptivity is neutralized, and + * measurements of such a configuration measure the quota, not the floor. (Benchmarked + * directly: {@code greediness = 0.5, gateK = 6000} on a 16-shard k=10000 search reproduced a + * static per-shard quota of 3000 result-for-result.) When configuring a gate below the queue + * size, prefer {@code greediness >= 0.9}, and verify that {@code (1 - greediness) * gateK} is + * small relative to the per-searcher share of the merged top-k. *

  • Batched synchronization. Scores are published to the shared floor, and the floor is - * re-read, only when the local queue first fills and every {@code syncInterval} visited - * vectors afterwards (default {@value #DEFAULT_SYNC_INTERVAL}), so the shared state is - * touched a constant number of times per few hundred scored candidates rather than once per - * candidate. A stale floor can only delay termination, never cause a wrong result, so the - * interval trades a bounded amount of extra work for the absence of cross-thread traffic in - * the scoring loop. + * re-read, when the ascent gate opens and each time {@code syncInterval} further vectors have + * been visited (default {@value #DEFAULT_SYNC_INTERVAL}), so the shared state is touched a + * constant number of times per few hundred scored candidates rather than once per candidate. + * A stale floor can only delay termination, never cause a wrong result, so the interval + * trades a bounded amount of extra work for the absence of cross-thread traffic in the + * scoring loop. * * *

    The effective bound derived from the floor is one ulp below the floor itself. The floor is the @@ -120,11 +129,18 @@ public final class FloorAwareKnnCollector extends KnnCollector.Decorator { */ private final int gateK; + /** Number of visited vectors between synchronizations with the shared floor. */ + private final int syncInterval; + /** - * Bit mask selecting the visited counts at which to synchronize, {@code syncInterval - 1}; the - * power-of-two requirement on the interval exists so this mask works. + * Whether this collector publishes its observed similarities into the shared floor. A + * non-publishing collector still reads the floor and prunes against it; it only never feeds it. + * Used when the same documents' scores have already been published by an earlier search of the + * same leaf (the optimistic strategy's second pass), where publishing again would insert + * duplicate scores into the floor's heap and could inflate the floor above the true merged + * cutoff, violating {@link GlobalKnnFloor}'s distinct-document contract. */ - private final int syncIntervalMask; + private final boolean publishToFloor; /** * The best {@code max(minExplorationSlots, (1 - greediness) * k)} similarities seen by this @@ -133,13 +149,29 @@ public final class FloorAwareKnnCollector extends KnnCollector.Decorator { */ private final FloatHeap nonCompetitiveQueue; - /** Similarities observed since the last synchronization, awaiting publication. */ + /** + * Similarities observed since the last synchronization, awaiting publication; {@code null} for a + * non-publishing collector. + */ private final FloatHeap updatesQueue; - /** Scratch used to drain {@link #updatesQueue} in ascending order for batch publication. */ + /** + * Scratch used to drain {@link #updatesQueue} in ascending order for batch publication; {@code + * null} for a non-publishing collector. + */ private final float[] updatesScratch; private boolean gateOpened; + + /** + * The visited count at or beyond which the next synchronization with the shared floor happens. + * Deliberately a threshold rather than an exact boundary: {@link #collect} is invoked only for + * candidates that beat the current bound, so visited counts are consulted at irregular strides + * and an equality test would skip every boundary that falls between two collects — precisely in + * the late phase of the search, when few candidates beat the bar and the floor matters most. + */ + private long nextSyncAt; + private float cachedGlobalFloor = Float.NEGATIVE_INFINITY; /** @@ -182,8 +214,8 @@ public FloorAwareKnnCollector( * the greediness; must be at least 1. See {@link #DEFAULT_MIN_EXPLORATION_SLOTS} for the role * this plays in protecting recall. * @param syncInterval number of visited vectors between synchronizations with the shared floor; - * must be a power of two. Smaller intervals keep the floor fresher (fewer visits) at the - * price of more cross-thread traffic. + * must be positive. Smaller intervals keep the floor fresher (fewer visits) at the price of + * more cross-thread traffic. */ public FloorAwareKnnCollector( AbstractKnnCollector subCollector, @@ -206,13 +238,14 @@ public FloorAwareKnnCollector( * the greediness; must be at least 1. See {@link #DEFAULT_MIN_EXPLORATION_SLOTS} for the role * this plays in protecting recall. * @param syncInterval number of visited vectors between synchronizations with the shared floor; - * must be a power of two. Smaller intervals keep the floor fresher (fewer visits) at the - * price of more cross-thread traffic. + * must be positive. Smaller intervals keep the floor fresher (fewer visits) at the price of + * more cross-thread traffic. * @param gateK the number of locally collected results after which the shared floor engages, in * {@code [1, subCollector.k()]}. The default, used by the other constructors, is {@code * subCollector.k()}: the floor engages when the local queue fills. Set it below the queue * size when this collector's expected share of the merged top-k is smaller than the queue it - * collects into; see the class comment on the ascent gate for when that applies. + * collects into; see the class comment on the ascent gate for when that applies, and the + * greediness clamp caution for the interaction between a below-queue gate and low greediness. */ public FloorAwareKnnCollector( AbstractKnnCollector subCollector, @@ -221,6 +254,22 @@ public FloorAwareKnnCollector( int minExplorationSlots, int syncInterval, int gateK) { + this(subCollector, globalFloor, greediness, minExplorationSlots, syncInterval, gateK, true); + } + + /** + * Create a fully configured collector that may be barred from publishing. Package-private: used + * by {@link SharedFloorKnnCollectorManager} to enforce {@link GlobalKnnFloor}'s distinct-document + * contract by publishing each leaf's scores at most once per query; see {@link #publishToFloor}. + */ + FloorAwareKnnCollector( + AbstractKnnCollector subCollector, + GlobalKnnFloor globalFloor, + float greediness, + int minExplorationSlots, + int syncInterval, + int gateK, + boolean publishToFloor) { super(subCollector); if (greediness < 0 || greediness > 1 || Float.isNaN(greediness)) { throw new IllegalArgumentException("greediness must be in [0,1], got: " + greediness); @@ -229,9 +278,8 @@ public FloorAwareKnnCollector( throw new IllegalArgumentException( "minExplorationSlots must be at least 1, got: " + minExplorationSlots); } - if (syncInterval < 1 || Integer.bitCount(syncInterval) != 1) { - throw new IllegalArgumentException( - "syncInterval must be a power of two, got: " + syncInterval); + if (syncInterval < 1) { + throw new IllegalArgumentException("syncInterval must be positive, got: " + syncInterval); } if (gateK < 1 || gateK > subCollector.k()) { throw new IllegalArgumentException( @@ -240,11 +288,22 @@ public FloorAwareKnnCollector( this.subCollector = subCollector; this.globalFloor = globalFloor; this.gateK = gateK; - this.syncIntervalMask = syncInterval - 1; + this.syncInterval = syncInterval; + this.publishToFloor = publishToFloor; this.nonCompetitiveQueue = new FloatHeap(Math.max(minExplorationSlots, Math.round((1 - greediness) * gateK))); - this.updatesQueue = new FloatHeap(globalFloor.k()); - this.updatesScratch = new float[globalFloor.k()]; + this.updatesQueue = publishToFloor ? new FloatHeap(globalFloor.k()) : null; + this.updatesScratch = publishToFloor ? new float[globalFloor.k()] : null; + } + + /** The ascent gate's threshold; package-private, for tests and the manager. */ + int gateK() { + return gateK; + } + + /** Whether this collector feeds the shared floor; package-private, for tests. */ + boolean publishesToFloor() { + return publishToFloor; } @Override @@ -254,21 +313,32 @@ public boolean collect(int docId, float similarity) { if (gateJustOpened) { gateOpened = true; } - updatesQueue.offer(similarity); + if (publishToFloor) { + updatesQueue.offer(similarity); + } boolean globalSimUpdated = nonCompetitiveQueue.offer(similarity); - if (gateOpened && (gateJustOpened || (visitedCount() & syncIntervalMask) == 0)) { - // The shared heap requires ascending input; draining the pending min-heap yields exactly - // that. Scores observed before the gate opened are included in the first batch, so nothing - // seen during the ascent is lost to the shared floor. - int len = updatesQueue.size(); - if (len > 0) { - for (int i = 0; i < len; i++) { - updatesScratch[i] = updatesQueue.poll(); + if (gateOpened && (gateJustOpened || visitedCount() >= nextSyncAt)) { + nextSyncAt = visitedCount() + syncInterval; + if (publishToFloor) { + // The shared heap requires ascending input; draining the pending min-heap yields exactly + // that. Scores observed before the gate opened are included in the first batch, so nothing + // seen during the ascent is lost to the shared floor. + int len = updatesQueue.size(); + if (len > 0) { + for (int i = 0; i < len; i++) { + updatesScratch[i] = updatesQueue.poll(); + } + assert updatesQueue.size() == 0; + cachedGlobalFloor = globalFloor.offer(updatesScratch, len); + globalSimUpdated = true; + } + } else { + float refreshed = globalFloor.floor(); + if (refreshed > cachedGlobalFloor) { + cachedGlobalFloor = refreshed; + globalSimUpdated = true; } - assert updatesQueue.size() == 0; - cachedGlobalFloor = globalFloor.offer(updatesScratch, len); - globalSimUpdated = true; } } // Reporting an update whenever the effective bound may have moved (locally or via the shared diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java index 71e73a22e4e7..30b9502d39e2 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java @@ -119,16 +119,22 @@ public float offer(float[] scores, int len) { throw new IllegalArgumentException("len must be positive, got: " + len); } assert isSorted(scores, len) : "scores must be sorted in ascending order"; + // The flag must be read BEFORE the offer. heapTop is only a valid k-th-best bound if the heap + // was already full when our scores went in; reading the flag afterwards would let a concurrent + // publisher fill the heap and set the flag in between, tricking this thread into publishing a + // heapTop computed over fewer than k scores, which can overshoot the true k-th best and cause + // over-pruning. + boolean wasFull = heapFull; float heapTop = heap.offer(scores, len); - if (heapFull) { + if (wasFull) { // The heap already held k scores before this call, so the value returned by offer() is the // minimum of a full heap: the k-th best of everything observed, a valid bound. floorBits.accumulate(encode(heapTop)); } else if (heap.size() >= k) { heapFull = true; - // The heap may have been filled by a concurrent publisher after our offer() returned, in - // which case our heapTop is the minimum of fewer than k scores and could overshoot the - // true k-th best. Re-read the top of the now-full heap instead of trusting heapTop. + // The heap was not full before this call but is now (filled by this batch, a concurrent + // publisher, or both). heapTop may predate the fill, so re-read the top of the now-full + // heap instead of trusting it. floorBits.accumulate(encode(heap.peek())); } return floor(); diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java index 81bd8e5ce5bb..f1945dbf6aa7 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java @@ -19,6 +19,8 @@ import java.io.IOException; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.apache.lucene.index.LeafReaderContext; import org.apache.lucene.search.KnnCollector; import org.apache.lucene.search.TopKnnCollector; @@ -45,6 +47,23 @@ * and deduplicating documents that may appear in more than one index, is the caller's * responsibility; see {@link GlobalKnnFloor} for the exact contract. * + *

    When the index this manager searches is one shard of a larger corpus, pass the shard's {@code + * globalShare} (the fraction of the corpus it holds). The manager then opens each collector's + * ascent gate at the shard's statistically expected contribution to the merged top-k, computed by + * {@link #perShardGate(int, double)}, instead of at the full local queue size. Without this, a + * shard's local search cannot know it is one of many — its own index looks like the whole corpus — + * so it pays the full cost of collecting k results before the shared floor is allowed to end its + * search, which forfeits most of the cross-shard saving. With the gate at the expected share, a + * shard pays only the share's fill cost, and then continues past it exactly as long as the shared + * floor says its results remain globally competitive: hot shards run long, cold shards stop early. + * + *

    Each leaf's scores are published into the shared floor at most once per query. The optimistic + * strategy searches competitive segments a second time, re-collecting the same documents; if the + * second pass republished them, the floor's heap would hold duplicate scores for the same + * documents, and a floor over a multiset with duplicates can exceed the true merged cutoff, + * breaking the bound's safety (see {@link GlobalKnnFloor}'s distinct-document contract). Collectors + * created for a leaf that has already been searched therefore read the floor without feeding it. + * *

    Floor sharing engages only when the query's k reaches an activation threshold; below it this * manager creates plain, undecorated collectors and the search is exactly stock search. The two * regimes justify the cutoff. The savings available to a shared bound grow with k: the pro-rata @@ -71,12 +90,29 @@ public final class SharedFloorKnnCollectorManager implements KnnCollectorManager */ public static final int DEFAULT_FLOOR_ACTIVATION_K = 100; + /** + * The number of standard deviations of statistical padding {@link #perShardGate(int, double)} + * adds to a shard's expected share of the merged top-k; the same value the optimistic + * multi-segment strategy uses to size per-segment collection ({@code LAMBDA} in {@code + * AbstractKnnVectorQuery}). + */ + private static final int LAMBDA = 16; + private final int k; private final GlobalKnnFloor globalFloor; private final float greediness; private final int floorActivationK; private final int minExplorationSlots; private final int syncInterval; + private final float globalShare; + + /** + * The leaves whose scores have been claimed for publication into the shared floor, so that a + * second search of the same leaf gets a non-publishing collector; see the class comment. Identity + * semantics are correct here: a manager lives for one query execution against one reader, and the + * optimistic strategy passes the same {@link LeafReaderContext} instances to both passes. + */ + private final Set publishedLeaves = ConcurrentHashMap.newKeySet(); /** * Create a manager with its own floor and the {@link FloorAwareKnnCollector#DEFAULT_GREEDINESS @@ -140,8 +176,8 @@ public SharedFloorKnnCollectorManager( } /** - * Create a fully configured manager. Every tuning value the mechanism has is a parameter here; - * the shorter constructors exist only to supply defaults. + * Create a fully configured manager for an index holding the whole corpus. Equivalent to the + * seven-argument constructor with {@code globalShare = 1}. * * @param k the number of neighbors the query collects * @param globalFloor the floor shared by all searchers of this query; its {@link @@ -154,7 +190,7 @@ public SharedFloorKnnCollectorManager( * @param minExplorationSlots smallest permitted size of each collector's greediness clamp queue; * must be at least 1. See {@link FloorAwareKnnCollector#DEFAULT_MIN_EXPLORATION_SLOTS}. * @param syncInterval number of visited vectors between each collector's synchronizations with - * the shared floor; must be a power of two. See {@link + * the shared floor; must be positive. See {@link * FloorAwareKnnCollector#DEFAULT_SYNC_INTERVAL}. */ public SharedFloorKnnCollectorManager( @@ -164,6 +200,44 @@ public SharedFloorKnnCollectorManager( int floorActivationK, int minExplorationSlots, int syncInterval) { + this(k, globalFloor, greediness, floorActivationK, minExplorationSlots, syncInterval, 1f); + } + + /** + * Create a fully configured manager. Every tuning value the mechanism has is a parameter here; + * the shorter constructors exist only to supply defaults. + * + * @param k the number of neighbors the query collects + * @param globalFloor the floor shared by all searchers of this query; its {@link + * GlobalKnnFloor#k()} must equal {@code k} + * @param greediness fraction of each segment's search effort that follows the shared floor, in + * {@code [0, 1]}; see {@link FloorAwareKnnCollector}. When {@code globalShare} is below 1, + * prefer values of {@code 0.9} or above: the greediness clamp is sized from the (now small) + * gate, and a low greediness can leave the clamp as the binding constraint for the whole + * search, reducing the adaptive floor to a fixed quota. See the caution on {@link + * FloorAwareKnnCollector}'s greediness clamp. + * @param floorActivationK the smallest k at which floor sharing engages; for smaller k this + * manager creates plain collectors and the search is exactly stock search. See the class + * comment for the reasoning behind the {@link #DEFAULT_FLOOR_ACTIVATION_K default}. + * @param minExplorationSlots smallest permitted size of each collector's greediness clamp queue; + * must be at least 1. See {@link FloorAwareKnnCollector#DEFAULT_MIN_EXPLORATION_SLOTS}. + * @param syncInterval number of visited vectors between each collector's synchronizations with + * the shared floor; must be positive. See {@link + * FloorAwareKnnCollector#DEFAULT_SYNC_INTERVAL}. + * @param globalShare the fraction of the whole corpus held by the index this manager searches, in + * {@code (0, 1]}. Pass a value below 1 when this index is one shard of a sharded corpus whose + * searchers share the floor across processes; each collector's ascent gate then opens at the + * shard's expected contribution to the merged top-k instead of at its full local queue. See + * the class comment. + */ + public SharedFloorKnnCollectorManager( + int k, + GlobalKnnFloor globalFloor, + float greediness, + int floorActivationK, + int minExplorationSlots, + int syncInterval, + float globalShare) { if (k < 1) { throw new IllegalArgumentException("k must be at least 1, got: " + k); } @@ -186,9 +260,11 @@ public SharedFloorKnnCollectorManager( throw new IllegalArgumentException( "minExplorationSlots must be at least 1, got: " + minExplorationSlots); } - if (syncInterval < 1 || Integer.bitCount(syncInterval) != 1) { - throw new IllegalArgumentException( - "syncInterval must be a power of two, got: " + syncInterval); + if (syncInterval < 1) { + throw new IllegalArgumentException("syncInterval must be positive, got: " + syncInterval); + } + if (globalShare <= 0 || globalShare > 1 || Float.isNaN(globalShare)) { + throw new IllegalArgumentException("globalShare must be in (0,1], got: " + globalShare); } this.k = k; this.globalFloor = globalFloor; @@ -196,6 +272,34 @@ public SharedFloorKnnCollectorManager( this.floorActivationK = floorActivationK; this.minExplorationSlots = minExplorationSlots; this.syncInterval = syncInterval; + this.globalShare = globalShare; + } + + /** + * The number of results a searcher holding fraction {@code share} of the corpus is expected to + * contribute to the merged top-k, with the same statistical padding the optimistic multi-segment + * strategy applies: under random sharding the number of global top-k hits in the shard is + * binomial, and the gate is sized {@code k * share} plus {@value #LAMBDA} standard deviations, + * clamped to {@code [1, k]}. This is where a shard's ascent gate should open: below it the shard + * has not yet collected its statistically due contribution and the shared floor must not end its + * search; above it, continuing is worthwhile exactly as long as the shard's results remain + * globally competitive. + * + * @param k the number of neighbors the query collects, at least 1 + * @param share the fraction of the corpus the searcher holds, in {@code (0, 1]} + * @return the gate size, in {@code [1, k]} + */ + public static int perShardGate(int k, double share) { + if (k < 1) { + throw new IllegalArgumentException("k must be at least 1, got: " + k); + } + if (share <= 0 || share > 1 || Double.isNaN(share)) { + throw new IllegalArgumentException("share must be in (0,1], got: " + share); + } + // Mirrors perLeafTopKCalculation in AbstractKnnVectorQuery so that a gate computed for a + // shard equals the quota the optimistic strategy would give a same-sized segment. + int gate = (int) Math.max(1, k * share + LAMBDA * Math.sqrt(k * share * (1 - share))); + return Math.min(k, gate); } /** Return the floor shared by this manager's collectors, so that callers may feed or read it. */ @@ -211,23 +315,55 @@ public KnnCollector newCollector( if (k < floorActivationK) { return collector; } - return new FloorAwareKnnCollector( - collector, globalFloor, greediness, minExplorationSlots, syncInterval); + return floorAware(collector, context); } @Override public KnnCollector newOptimisticCollector( int visitedLimit, KnnSearchStrategy searchStrategy, LeafReaderContext context, int perLeafK) throws IOException { - // The local queue, and with it the ascent gate, is sized to the segment's pro-rata share; the - // floor itself always tracks the full k best across the query. Activation is decided by the - // query's k, not the segment's share: the policy is about the query. + // The local queue is sized to the segment's pro-rata share; the floor itself always tracks + // the full k best across the query. Activation is decided by the query's k, not the segment's + // share: the policy is about the query. TopKnnCollector collector = new TopKnnCollector(perLeafK, visitedLimit, searchStrategy); if (k < floorActivationK) { return collector; } + return floorAware(collector, context); + } + + private FloorAwareKnnCollector floorAware(TopKnnCollector collector, LeafReaderContext context) { + // Publish each leaf's scores at most once per query; a second search of the same leaf + // re-collects the same documents, and republishing them would violate the floor's + // distinct-document contract. See the class comment. + boolean publish = context == null || publishedLeaves.add(context); return new FloorAwareKnnCollector( - collector, globalFloor, greediness, minExplorationSlots, syncInterval); + collector, + globalFloor, + greediness, + minExplorationSlots, + syncInterval, + gateFor(context, collector.k()), + publish); + } + + /** + * The ascent gate for a collector over {@code context}: the leaf's statistically expected + * contribution to the merged top-k, never more than the local queue it collects into. The leaf's + * share of the whole corpus is its share of this index scaled by the index's {@code globalShare}. + * When that combined share is 1 — a single-segment index holding the entire corpus — the gate is + * the queue size and behavior is exactly the default: the local queue is the query's own top-k, + * and only external advertisements could justify ending the search below it. + */ + private int gateFor(LeafReaderContext context, int queueSize) { + double leafGlobalShare = globalShare; + if (context != null && context.parent != null) { + leafGlobalShare *= context.reader().maxDoc() / (double) context.parent.reader().maxDoc(); + } + if (leafGlobalShare <= 0 || leafGlobalShare >= 1) { + return queueSize; + } + return Math.min(queueSize, perShardGate(k, leafGlobalShare)); } @Override diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java index 875fd8bae91f..77febb7edd6f 100644 --- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java @@ -19,7 +19,9 @@ import static com.carrotsearch.randomizedtesting.RandomizedTest.randomIntBetween; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.lucene.tests.util.LuceneTestCase; import org.apache.lucene.util.SuppressForbidden; @@ -97,4 +99,59 @@ public void testMultipleThreads() throws Exception { t.join(); } } + + public void testConcurrentPollsDoNotCorruptTheHeap() throws Exception { + // Twice as many pollers as elements: the surplus polls must fail cleanly with + // IllegalStateException, never pass an emptiness check raced by another poller and corrupt + // the heap's size or return garbage. Every successfully polled value must be one of the + // values actually offered, each exactly once. + int size = 8; + int pollers = 2 * size; + BlockingFloatHeap heap = new BlockingFloatHeap(size); + for (int i = 0; i < size; i++) { + heap.offer(i + 1f); + } + + CountDownLatch startingGun = new CountDownLatch(1); + ConcurrentLinkedQueue polled = new ConcurrentLinkedQueue<>(); + AtomicInteger emptyPolls = new AtomicInteger(); + Thread[] threads = new Thread[pollers]; + for (int i = 0; i < pollers; i++) { + threads[i] = + new Thread( + () -> { + try { + startingGun.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + try { + polled.add(heap.poll()); + } catch ( + @SuppressWarnings("unused") + IllegalStateException e) { + emptyPolls.incrementAndGet(); + } + }); + threads[i].start(); + } + startingGun.countDown(); + for (Thread t : threads) { + t.join(); + } + + assertEquals("every element must be polled exactly once", size, polled.size()); + assertEquals("every surplus poll must fail cleanly", pollers - size, emptyPolls.get()); + assertEquals(0, heap.size()); + float[] values = new float[polled.size()]; + int i = 0; + for (float value : polled) { + values[i++] = value; + } + java.util.Arrays.sort(values); + for (int v = 0; v < size; v++) { + assertEquals("polled values must be exactly the offered values", v + 1f, values[v], 0.0f); + } + } } diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java index efda19b5dee2..d03ad7280afe 100644 --- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java @@ -52,23 +52,20 @@ public void testInvalidMinExplorationSlots() { public void testInvalidSyncInterval() { GlobalKnnFloor floor = new GlobalKnnFloor(4); TopKnnCollector delegate = new TopKnnCollector(4, Integer.MAX_VALUE); - // The interval is applied as a bit mask over the visited count, hence the power-of-two - // requirement; anything else would synchronize at irregular, surprising points. expectThrows( IllegalArgumentException.class, () -> new FloorAwareKnnCollector( delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, 0)); - expectThrows( - IllegalArgumentException.class, - () -> - new FloorAwareKnnCollector( - delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, 100)); expectThrows( IllegalArgumentException.class, () -> new FloorAwareKnnCollector( delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, -8)); + // Any positive interval is valid: synchronization is scheduled against a visited-count + // threshold, not a bit mask, so no power-of-two restriction applies. + new FloorAwareKnnCollector( + delegate, floor, 0.5f, FloorAwareKnnCollector.DEFAULT_MIN_EXPLORATION_SLOTS, 100); } public void testInvalidGateK() { @@ -334,15 +331,81 @@ public void testCollectReportsSharedFloorUpdates() { "a rejected hit between synchronizations must not report an update", collector.collect(k, 1f)); - // Advance to the next synchronization boundary: even a rejected hit must report an update - // there, because the re-read of the shared floor may have moved the effective bound. - collector.incVisitedCount(256 - (k + 1)); - assertEquals(0, collector.visitedCount() & 0xff); + // Advance a full sync interval past the gate-open synchronization: even a rejected hit must + // report an update there, because the re-read of the shared floor may have moved the + // effective bound. + collector.incVisitedCount(FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL); assertTrue( - "a hit on a synchronization boundary must report an update after the floor re-read", + "a hit past the synchronization threshold must report an update after the floor re-read", collector.collect(k + 1, 1f)); } + public void testSyncHappensOnceIntervalElapsesRegardlessOfAlignment() { + // The synchronization schedule must be a threshold over the visited count, not an exact + // boundary match: collect() is invoked only for candidates that beat the current bound, so + // visited counts arrive at irregular strides, and a boundary that falls between two collects + // must trigger on the next collect rather than being skipped. Skipped boundaries starve the + // floor exactly in the late phase of the search, when few candidates beat the bar. + int k = 4; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector( + new TopKnnCollector(k, Integer.MAX_VALUE), floor, 0.5f, 1, 256, k); + + // Fill to the gate: the first batch publishes and defines the floor at the k-th best, 1. + for (int doc = 0; doc < k; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + } + assertEquals(1f, floor.floor(), 0.0f); + + // A better score arrives, then the visited count jumps far past the next synchronization + // threshold without ever landing on a multiple of the interval. + collector.incVisitedCount(1); + collector.collect(k, 100f); + collector.incVisitedCount(300); + assertTrue("test setup: land off any interval multiple", collector.visitedCount() % 256 != 0); + collector.incVisitedCount(1); + collector.collect(k + 1, 101f); + + assertTrue( + "a collect past the synchronization threshold must publish the pending scores", + floor.floor() > 1f); + } + + public void testNonPublishingCollectorReadsButNeverFeedsTheFloor() { + int k = 20; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + FloorAwareKnnCollector collector = + new FloorAwareKnnCollector( + new TopKnnCollector(k, Integer.MAX_VALUE), floor, 1f, 2, 256, k, false); + assertFalse(collector.publishesToFloor()); + + // Collect a full queue of scores: a publishing collector would have defined the floor here. + for (int doc = 0; doc < k; doc++) { + collector.incVisitedCount(1); + collector.collect(doc, doc + 1f); + } + assertEquals( + "a non-publishing collector must never feed the shared floor", + Float.NEGATIVE_INFINITY, + floor.floor(), + 0.0f); + + // The floor rises externally; the collector must still read it and prune against it. After + // the final collect the clamp holds the two best scores seen ({20, 30}), so the bound is + // max(local k-th best = 2, min(clamp = 20, nextDown(1000))) = 20 — above anything the local + // queue alone would justify, provable only by a floor that was actually read. + floor.advertise(1000f); + collector.incVisitedCount(FloorAwareKnnCollector.DEFAULT_SYNC_INTERVAL); + collector.collect(k, 30f); + assertEquals( + "a non-publishing collector must still prune against the shared floor", + 20f, + collector.minCompetitiveSimilarity(), + 0.0f); + } + public void testDelegationOfCollectorPlumbing() { GlobalKnnFloor floor = new GlobalKnnFloor(3); TopKnnCollector delegate = new TopKnnCollector(3, 17); diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java index bb28c40f5419..e48699434c85 100644 --- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java @@ -210,4 +210,71 @@ public void testConcurrentPublishersConvergeToKthBest() throws Exception { floor.floor(), 0.0f); } + + public void testConcurrentFillNeverOvershootsTrueKthBest() throws Exception { + // Regression tripwire for a heap-fill race: a publisher whose batch lands while the heap is + // not yet full must not treat its returned heap top as a valid bound, even if a concurrent + // publisher fills the heap in the window between the offer returning and the fullness flag + // being read. The scenario is adversarial by construction: one thread offers high scores that + // do not fill the heap on their own, the other offers low scores that complete the fill. The + // true k-th best is a low score, so a stale partial-heap top (a high score) published as the + // floor overshoots it — and because the floor is a monotonic maximum, a single overshoot + // survives to the final assertion. The race window is narrow, hence the many short rounds. + int rounds = 500; + int k = 32; + ExecutorService executor = + Executors.newFixedThreadPool(2, new NamedThreadFactory("global-knn-floor-race-test")); + try { + for (int round = 0; round < rounds; round++) { + GlobalKnnFloor floor = new GlobalKnnFloor(k); + float[] highs = new float[k - 1]; + for (int i = 0; i < highs.length; i++) { + highs[i] = 0.9f + i * 0.001f; + } + float[] lows = new float[k]; + for (int i = 0; i < lows.length; i++) { + lows[i] = 0.1f + i * 0.001f; + } + // Top-k of the union: all k-1 highs plus the single best low, whose score is the k-th + // best and the highest floor any correct execution can reach. + float trueKthBest = lows[lows.length - 1]; + + CountDownLatch startingGun = new CountDownLatch(1); + Future highPublisher = + executor.submit( + () -> { + try { + startingGun.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + floor.offer(highs, highs.length); + }); + Future lowPublisher = + executor.submit( + () -> { + try { + startingGun.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + floor.offer(lows, lows.length); + }); + startingGun.countDown(); + highPublisher.get(); + lowPublisher.get(); + + assertEquals( + "round " + round + ": the floor must be exactly the k-th best of everything offered", + trueKthBest, + floor.floor(), + 0.0f); + } + } finally { + executor.shutdown(); + assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS)); + } + } } diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnCollectorManager.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnCollectorManager.java new file mode 100644 index 000000000000..8c0e0271f55e --- /dev/null +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestSharedFloorKnnCollectorManager.java @@ -0,0 +1,347 @@ +/* + * 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.sandbox.search.knn; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.KnnFloatVectorField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.FloatVectorValues; +import org.apache.lucene.index.IndexReader; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.KnnVectorValues; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.NoMergePolicy; +import org.apache.lucene.index.VectorSimilarityFunction; +import org.apache.lucene.search.DocIdSetIterator; +import org.apache.lucene.search.IndexSearcher; +import org.apache.lucene.search.KnnFloatVectorQuery; +import org.apache.lucene.search.ScoreDoc; +import org.apache.lucene.search.TopDocs; +import org.apache.lucene.search.knn.KnnCollectorManager; +import org.apache.lucene.store.Directory; +import org.apache.lucene.tests.util.LuceneTestCase; + +/** + * Unit tests of {@link SharedFloorKnnCollectorManager}'s collector-creation policy: which gate its + * collectors get, which of them may publish into the shared floor, and how a shard's {@code + * globalShare} shapes both. The end-to-end behavior of the collectors themselves is covered by + * {@link TestSharedFloorKnnSearch}. + */ +public class TestSharedFloorKnnCollectorManager extends LuceneTestCase { + + private static final String FIELD = "vector"; + private static final VectorSimilarityFunction SIMILARITY = VectorSimilarityFunction.EUCLIDEAN; + + public void testPerShardGateMatchesOptimisticQuota() { + // The gate must equal the quota the optimistic multi-segment strategy would give a segment of + // the same proportion (perLeafTopKCalculation in AbstractKnnVectorQuery): k * share plus 16 + // standard deviations of the binomial, truncated. The two spot values below are the gates for + // a 1-of-16 shard at the two k values of the benchmark this parameter exists for. + assertEquals(1012, SharedFloorKnnCollectorManager.perShardGate(10000, 1 / 16.0)); + assertEquals(184, SharedFloorKnnCollectorManager.perShardGate(1000, 1 / 16.0)); + // A searcher holding the whole corpus is due all of k, with no padding to add. + assertEquals(10000, SharedFloorKnnCollectorManager.perShardGate(10000, 1.0)); + // The gate never exceeds k, however much statistical padding the formula would add. + assertEquals(100, SharedFloorKnnCollectorManager.perShardGate(100, 0.99)); + // The gate is at least 1, however small the share. + assertEquals(1, SharedFloorKnnCollectorManager.perShardGate(1, 1e-9)); + + expectThrows( + IllegalArgumentException.class, () -> SharedFloorKnnCollectorManager.perShardGate(0, 0.5)); + expectThrows( + IllegalArgumentException.class, + () -> SharedFloorKnnCollectorManager.perShardGate(100, 0.0)); + expectThrows( + IllegalArgumentException.class, + () -> SharedFloorKnnCollectorManager.perShardGate(100, 1.1)); + expectThrows( + IllegalArgumentException.class, + () -> SharedFloorKnnCollectorManager.perShardGate(100, Double.NaN)); + } + + public void testInvalidGlobalShare() { + int k = 1000; + GlobalKnnFloor floor = new GlobalKnnFloor(k); + for (float share : new float[] {0f, -0.5f, 1.1f, Float.NaN}) { + expectThrows( + IllegalArgumentException.class, + () -> new SharedFloorKnnCollectorManager(k, floor, 0.9f, 1, 16, 256, share)); + } + } + + public void testGlobalShareOpensTheGateAtTheExpectedShare() throws IOException { + // A single-segment index declared to hold 1/16 of the corpus: collectors must open their + // ascent gate at the shard's expected contribution to the merged top-k, not at the full local + // queue — a shard's own index looks like its entire corpus, so without globalShare the gate + // would sit at k and the floor could never end the fill. + int k = 1000; + try (Directory dir = newDirectory()) { + indexInSegments(dir, randomVectors(20, 8), 1); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReaderContext context = reader.leaves().get(0); + + SharedFloorKnnCollectorManager sharded = + new SharedFloorKnnCollectorManager(k, new GlobalKnnFloor(k), 0.9f, 1, 16, 256, 1f / 16); + FloorAwareKnnCollector collector = + (FloorAwareKnnCollector) sharded.newCollector(Integer.MAX_VALUE, null, context); + assertEquals( + "the gate must open at the shard's expected share of the merged top-k", + SharedFloorKnnCollectorManager.perShardGate(k, 1.0 / 16), + collector.gateK()); + + SharedFloorKnnCollectorManager whole = + new SharedFloorKnnCollectorManager(k, new GlobalKnnFloor(k), 0.9f, 1, 16, 256, 1f); + FloorAwareKnnCollector wholeCollector = + (FloorAwareKnnCollector) whole.newCollector(Integer.MAX_VALUE, null, context); + assertEquals( + "an index holding the whole corpus must gate at the full queue", + k, + wholeCollector.gateK()); + } + } + } + + public void testMultiSegmentGateIsProRataPerLeaf() throws IOException { + // Full-k collectors over a multi-segment index (the optimistic strategy's second pass) must + // gate at the leaf's pro-rata share, not at k: the floor established during the first pass is + // otherwise barred from ending the second pass's re-fill until it has collected all of k. + int k = 1000; + int segments = 4; + try (Directory dir = newDirectory()) { + indexInSegments(dir, randomVectors(40, 8), segments); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + assertEquals("test setup: segment count", segments, reader.leaves().size()); + SharedFloorKnnCollectorManager manager = + new SharedFloorKnnCollectorManager(k, new GlobalKnnFloor(k), 0.9f, 1, 16, 256); + int totalDocs = reader.maxDoc(); + for (LeafReaderContext context : reader.leaves()) { + FloorAwareKnnCollector collector = + (FloorAwareKnnCollector) manager.newCollector(Integer.MAX_VALUE, null, context); + double leafShare = context.reader().maxDoc() / (double) totalDocs; + assertEquals( + "leaf " + context.ord + " must gate at its pro-rata share", + SharedFloorKnnCollectorManager.perShardGate(k, leafShare), + collector.gateK()); + assertTrue(collector.gateK() < k); + } + } + } + } + + public void testEachLeafPublishesIntoTheFloorAtMostOnce() throws IOException { + // The optimistic strategy searches a competitive leaf twice, re-collecting the same + // documents. Republishing them would put duplicate scores in the floor's heap, and a k-th + // best over a multiset with duplicates can exceed the true merged cutoff — so the second + // collector for the same leaf must read the floor without feeding it. + int k = 1000; + try (Directory dir = newDirectory()) { + indexInSegments(dir, randomVectors(30, 8), 2); + try (DirectoryReader reader = DirectoryReader.open(dir)) { + SharedFloorKnnCollectorManager manager = + new SharedFloorKnnCollectorManager(k, new GlobalKnnFloor(k), 0.9f, 1, 16, 256); + LeafReaderContext first = reader.leaves().get(0); + LeafReaderContext second = reader.leaves().get(1); + + FloorAwareKnnCollector pass1 = + (FloorAwareKnnCollector) + manager.newOptimisticCollector(Integer.MAX_VALUE, null, first, 100); + assertTrue("the first search of a leaf must publish", pass1.publishesToFloor()); + + FloorAwareKnnCollector pass2 = + (FloorAwareKnnCollector) manager.newCollector(Integer.MAX_VALUE, null, first); + assertFalse("a second search of the same leaf must not publish", pass2.publishesToFloor()); + + FloorAwareKnnCollector otherLeaf = + (FloorAwareKnnCollector) manager.newCollector(Integer.MAX_VALUE, null, second); + assertTrue( + "the first search of a different leaf must publish", otherLeaf.publishesToFloor()); + } + } + } + + /** + * The distributed wiring this manager exists for, in miniature: two disjoint indexes standing in + * for two shards of one corpus, each searched through its own manager with its true {@code + * globalShare}, both wired to one shared floor. The merged result must not lose recall against + * stock search of the same two indexes. + */ + public void testDisjointIndexesSharingOneFloorKeepMergedRecall() throws IOException { + int dim = 16; + int docsPerShard = 600; + int k = 64; + float[][] shardA = randomVectors(docsPerShard, dim); + float[][] shardB = randomVectors(docsPerShard, dim); + + try (Directory dirA = newDirectory(); + Directory dirB = newDirectory()) { + indexInSegments(dirA, shardA, 1); + indexInSegments(dirB, shardB, 1); + try (DirectoryReader readerA = DirectoryReader.open(dirA); + DirectoryReader readerB = DirectoryReader.open(dirB)) { + IndexSearcher searcherA = new IndexSearcher(readerA); + IndexSearcher searcherB = new IndexSearcher(readerB); + + double stockRecallSum = 0; + double flooredRecallSum = 0; + int queries = 10; + for (int i = 0; i < queries; i++) { + float[] query = randomVector(dim); + Set truth = mergedExactTopK(readerA, readerB, query, k); + + TopDocs stockA = searcherA.search(new KnnFloatVectorQuery(FIELD, query, k), k); + TopDocs stockB = searcherB.search(new KnnFloatVectorQuery(FIELD, query, k), k); + stockRecallSum += mergedRecall(stockA, stockB, docsPerShard, truth, k); + + // One floor for the query; each shard's manager knows its share of the whole corpus. + GlobalKnnFloor floor = new GlobalKnnFloor(k); + TopDocs flooredA = + searcherA.search(new SharedFloorShardQuery(FIELD, query, k, floor, 0.5f), k); + TopDocs flooredB = + searcherB.search(new SharedFloorShardQuery(FIELD, query, k, floor, 0.5f), k); + flooredRecallSum += mergedRecall(flooredA, flooredB, docsPerShard, truth, k); + } + double stockRecall = stockRecallSum / queries; + double flooredRecall = flooredRecallSum / queries; + assertTrue( + "cross-shard floor sharing lost recall: stock=" + + stockRecall + + " floored=" + + flooredRecall, + flooredRecall >= stockRecall - 0.05); + } + } + } + + /** + * A {@link KnnFloatVectorQuery} searching one shard of a two-shard corpus: its manager is wired + * to the query's shared floor and declares this shard's share of the whole corpus. A query + * instance carries single-execution state and must not be reused. + */ + private static class SharedFloorShardQuery extends KnnFloatVectorQuery { + private final SharedFloorKnnCollectorManager manager; + + SharedFloorShardQuery(String field, float[] target, int k, GlobalKnnFloor floor, float share) { + super(field, target, k); + this.manager = new SharedFloorKnnCollectorManager(k, floor, 0.9f, 1, 16, 256, share); + } + + @Override + protected KnnCollectorManager getKnnCollectorManager(int k, IndexSearcher searcher) { + return manager; + } + } + + private float[][] randomVectors(int count, int dim) { + float[][] vectors = new float[count][]; + for (int i = 0; i < count; i++) { + vectors[i] = randomVector(dim); + } + return vectors; + } + + private float[] randomVector(int dim) { + float[] vector = new float[dim]; + for (int i = 0; i < dim; i++) { + vector[i] = (float) random().nextGaussian(); + } + return vector; + } + + private void indexInSegments(Directory dir, float[][] vectors, int segments) throws IOException { + try (IndexWriter writer = + new IndexWriter(dir, new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) { + int docsPerSegment = (vectors.length + segments - 1) / segments; + for (int i = 0; i < vectors.length; i++) { + Document doc = new Document(); + doc.add(new KnnFloatVectorField(FIELD, vectors[i], SIMILARITY)); + writer.addDocument(doc); + if ((i + 1) % docsPerSegment == 0) { + writer.flush(); + } + } + writer.commit(); + } + } + + private record DocScore(int globalDoc, float score) {} + + /** + * Exact top-k over both shards, in a merged id space where shard A's docs keep their ids and + * shard B's are offset by A's shard size. + */ + private Set mergedExactTopK( + IndexReader readerA, IndexReader readerB, float[] query, int k) throws IOException { + List scored = new ArrayList<>(); + collectExact(readerA, query, 0, scored); + collectExact(readerB, query, readerA.maxDoc(), scored); + scored.sort( + Comparator.comparingDouble(DocScore::score) + .reversed() + .thenComparingInt(DocScore::globalDoc)); + Set topK = new HashSet<>(); + for (int i = 0; i < k && i < scored.size(); i++) { + topK.add(scored.get(i).globalDoc()); + } + return topK; + } + + private void collectExact(IndexReader reader, float[] query, int offset, List out) + throws IOException { + for (LeafReaderContext ctx : reader.leaves()) { + FloatVectorValues values = ctx.reader().getFloatVectorValues(FIELD); + assertNotNull(values); + KnnVectorValues.DocIndexIterator iterator = values.iterator(); + for (int doc = iterator.nextDoc(); + doc != DocIdSetIterator.NO_MORE_DOCS; + doc = iterator.nextDoc()) { + float score = SIMILARITY.compare(query, values.vectorValue(iterator.index())); + out.add(new DocScore(offset + ctx.docBase + doc, score)); + } + } + } + + /** Merge the two shards' results by score, keep the top k, and score recall against truth. */ + private static double mergedRecall( + TopDocs shardA, TopDocs shardB, int shardBOffset, Set truth, int k) { + List merged = new ArrayList<>(); + for (ScoreDoc scoreDoc : shardA.scoreDocs) { + merged.add(new DocScore(scoreDoc.doc, scoreDoc.score)); + } + for (ScoreDoc scoreDoc : shardB.scoreDocs) { + merged.add(new DocScore(shardBOffset + scoreDoc.doc, scoreDoc.score)); + } + merged.sort( + Comparator.comparingDouble(DocScore::score) + .reversed() + .thenComparingInt(DocScore::globalDoc)); + int hits = 0; + for (int i = 0; i < k && i < merged.size(); i++) { + if (truth.contains(merged.get(i).globalDoc())) { + hits++; + } + } + return hits / (double) k; + } +} From 299c943f23c7334bac67e4984b699f341e6980eb Mon Sep 17 00:00:00 2001 From: Kristian Rickert Date: Tue, 7 Jul 2026 05:25:06 -0400 Subject: [PATCH 3/4] Derive greediness from a target clamp width What protects recall is the clamp's absolute width, (1 - greediness) * gateK, not the greediness fraction: the same 0.9 that leaves a 600-slot clamp at gateK 6000 collapses to the bare minimum at gateK 184, which benchmarked at double-digit recall loss. greedinessForClamp(gateK, clampSlots) translates a required width into the greediness that produces it, and returns 0 (floor neutralized) when the width cannot fit inside the gate. Javadoc on both classes now points width-first configuration at the helper. --- .../search/knn/FloorAwareKnnCollector.java | 32 +++++++++++++++++-- .../knn/SharedFloorKnnCollectorManager.java | 9 +++--- .../knn/TestFloorAwareKnnCollector.java | 24 ++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java index 5a87c95a38a0..b5e657c1fbee 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java @@ -67,8 +67,8 @@ * measurements of such a configuration measure the quota, not the floor. (Benchmarked * directly: {@code greediness = 0.5, gateK = 6000} on a 16-shard k=10000 search reproduced a * static per-shard quota of 3000 result-for-result.) When configuring a gate below the queue - * size, prefer {@code greediness >= 0.9}, and verify that {@code (1 - greediness) * gateK} is - * small relative to the per-searcher share of the merged top-k. + * size, derive greediness from the exploration width the graph needs via {@link + * #greedinessForClamp(int, int)} instead of choosing a constant fraction. *

  • Batched synchronization. Scores are published to the shared floor, and the floor is * re-read, when the ascent gate opens and each time {@code syncInterval} further vectors have * been visited (default {@value #DEFAULT_SYNC_INTERVAL}), so the shared state is touched a @@ -120,6 +120,34 @@ public final class FloorAwareKnnCollector extends KnnCollector.Decorator { */ public static final int DEFAULT_SYNC_INTERVAL = 256; + /** + * The greediness at which the clamp keeps {@code clampSlots} exploration slots against a gate of + * {@code gateK}: {@code 1 - clampSlots / gateK}, or 0 when the requested width is the whole gate + * or more (the floor is then fully neutralized). + * + *

    Prefer deriving greediness through this method over choosing a constant. What protects + * recall is the clamp's absolute width, not the greediness fraction: the same {@code + * greediness = 0.9} that leaves a comfortable 600-slot clamp at {@code gateK = 6000} collapses to + * the bare minimum at {@code gateK = 184}, which benchmarked at double-digit recall loss. A + * caller who instead fixes the width it needs, and lets this method translate it, is safe at + * every gate size. + * + * @param gateK the ascent gate the collector will use, at least 1 + * @param clampSlots the exploration width to preserve, at least 1 + */ + public static float greedinessForClamp(int gateK, int clampSlots) { + if (gateK < 1) { + throw new IllegalArgumentException("gateK must be at least 1, got: " + gateK); + } + if (clampSlots < 1) { + throw new IllegalArgumentException("clampSlots must be at least 1, got: " + clampSlots); + } + if (clampSlots >= gateK) { + return 0f; + } + return 1f - clampSlots / (float) gateK; + } + private final AbstractKnnCollector subCollector; private final GlobalKnnFloor globalFloor; diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java index f1945dbf6aa7..0ab47f7510e1 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java @@ -212,10 +212,11 @@ public SharedFloorKnnCollectorManager( * GlobalKnnFloor#k()} must equal {@code k} * @param greediness fraction of each segment's search effort that follows the shared floor, in * {@code [0, 1]}; see {@link FloorAwareKnnCollector}. When {@code globalShare} is below 1, - * prefer values of {@code 0.9} or above: the greediness clamp is sized from the (now small) - * gate, and a low greediness can leave the clamp as the binding constraint for the whole - * search, reducing the adaptive floor to a fixed quota. See the caution on {@link - * FloorAwareKnnCollector}'s greediness clamp. + * the gate is small and the clamp {@code (1 - greediness) * gateK} with it: derive this value + * from the exploration width the graph needs via {@link + * FloorAwareKnnCollector#greedinessForClamp(int, int)} (with {@code gateK} from {@link + * #perShardGate(int, double)}) rather than choosing a constant. See the caution on {@link + * FloorAwareKnnCollector}'s greediness clamp for what goes wrong with constants. * @param floorActivationK the smallest k at which floor sharing engages; for smaller k this * manager creates plain collectors and the search is exactly stock search. See the class * comment for the reasoning behind the {@link #DEFAULT_FLOOR_ACTIVATION_K default}. diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java index d03ad7280afe..a899370993f4 100644 --- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestFloorAwareKnnCollector.java @@ -104,6 +104,30 @@ public void testInvalidGateK() { 9)); } + public void testGreedinessForClampRoundTrips() { + // The helper must invert the clamp sizing: constructing a collector with the derived + // greediness yields a clamp of exactly the requested width, at any gate size. This is the + // property that makes width-first configuration safe where a constant greediness is not. + int[][] cases = {{6000, 600}, {184, 64}, {1012, 101}, {117, 58}, {10000, 16}}; + for (int[] c : cases) { + int gateK = c[0]; + int clampSlots = c[1]; + float greediness = FloorAwareKnnCollector.greedinessForClamp(gateK, clampSlots); + assertEquals( + "gateK=" + gateK + " clampSlots=" + clampSlots, + clampSlots, + Math.round((1 - greediness) * gateK)); + } + // A width of the whole gate or more means the floor cannot bind: greediness 0, stock search. + assertEquals(0f, FloorAwareKnnCollector.greedinessForClamp(44, 64), 0.0f); + assertEquals(0f, FloorAwareKnnCollector.greedinessForClamp(64, 64), 0.0f); + + expectThrows( + IllegalArgumentException.class, () -> FloorAwareKnnCollector.greedinessForClamp(0, 16)); + expectThrows( + IllegalArgumentException.class, () -> FloorAwareKnnCollector.greedinessForClamp(100, 0)); + } + public void testGateBelowQueueSizeOpensAtGateKResults() { // A share-sized gate: the local queue holds 32 results but the collector's expected share of // the merged top-k is only 8, so the floor must engage after 8 collected results, long before From 5698054071153bb20512e0ed1ee7e53700b5aab6 Mon Sep 17 00:00:00 2001 From: Kristian Rickert Date: Fri, 10 Jul 2026 02:58:59 -0400 Subject: [PATCH 4/4] Validate shared-floor inputs and clarify the approximate-search contract Reject unsorted, NaN, or over-length score batches in GlobalKnnFloor.offer, including a NaN in the trailing position where Arrays.sort places it and where a pairwise comparison cannot catch it. Make BlockingFloatHeap package-private and validate its capacity and batch length. Document that floor pruning is an approximate HNSW optimization rather than a recall proof, that the manager is scoped to one query execution, that distributed callers own deduplication before advertising, and that filtered queries may fall back to exact search in core. --- lucene/CHANGES.txt | 5 +-- .../sandbox/search/knn/BlockingFloatHeap.java | 9 +++++- .../search/knn/FloorAwareKnnCollector.java | 6 ++-- .../sandbox/search/knn/GlobalKnnFloor.java | 31 ++++++++++++++++--- .../knn/SharedFloorKnnCollectorManager.java | 10 +++++- .../search/knn/TestBlockingFloatHeap.java | 5 +++ .../search/knn/TestGlobalKnnFloor.java | 14 +++++++++ 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index 1ec0df6898ec..ee713a89d58f 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -106,8 +106,9 @@ New Features * GITHUB#16357: Add shared-floor kNN collection to the sandbox module: GlobalKnnFloor, FloorAwareKnnCollector and SharedFloorKnnCollectorManager let concurrent searchers of one query (segments in one process, or shards across processes via GlobalKnnFloor#advertise) prune graph - exploration against a shared lower bound of the query's final merged top-k cutoff. Opt-in, no - changes to core search. (Kristian Rickert) + exploration against a shared lower bound of the query's final merged top-k cutoff. This remains + an approximate-search optimization: deployments must measure its recall/visit trade-off. Opt-in, + no changes to core search. (Kristian Rickert) * GITHUB#16241: Add HangulCompositionCharFilter to analysis-nori for opt-in composition of modern Hangul conjoining jamo before KoreanTokenizer. (Mingi Jeong) diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java index 1475edcd38d3..4c2246d51ed6 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/BlockingFloatHeap.java @@ -28,13 +28,16 @@ * * @lucene.internal */ -public final class BlockingFloatHeap { +final class BlockingFloatHeap { private final int maxSize; private final float[] heap; private final ReentrantLock lock; private int size; public BlockingFloatHeap(int maxSize) { + if (maxSize < 1) { + throw new IllegalArgumentException("maxSize must be at least 1, got: " + maxSize); + } this.maxSize = maxSize; this.heap = new float[maxSize + 1]; this.lock = new ReentrantLock(); @@ -76,6 +79,10 @@ public float offer(float value) { * @return the new 'top' element in the queue. */ public float offer(float[] values, int len) { + if (len < 0 || len > values.length) { + throw new IllegalArgumentException( + "len must be in [0, values.length=" + values.length + "], got: " + len); + } lock.lock(); try { for (int i = len - 1; i >= 0; i--) { diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java index b5e657c1fbee..1adcd635d151 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/FloorAwareKnnCollector.java @@ -24,8 +24,10 @@ /** * A {@link org.apache.lucene.search.KnnCollector.Decorator} that raises its {@link * #minCompetitiveSimilarity()} using a {@link GlobalKnnFloor} shared with the other searchers of - * the same query, so that a graph search stops exploring candidates that cannot enter the query's - * final, merged top-k, not merely candidates that cannot enter this collector's local top-k. + * the same query, so that a graph search can reduce exploration of candidates whose score cannot + * enter the query's final, merged top-k, not merely candidates that cannot enter this collector's + * local top-k. Because HNSW may need low-scoring bridge nodes to reach better neighborhoods, this + * is an approximate-search optimization: the exploration clamp controls its recall/visit trade-off. * *

    The graph searcher already re-reads {@link #minCompetitiveSimilarity()} whenever a collected * hit reports an improvement, and uses it both to stop the search when the best remaining candidate diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java index 30b9502d39e2..12f6562acf9e 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/GlobalKnnFloor.java @@ -17,6 +17,7 @@ package org.apache.lucene.sandbox.search.knn; +import java.util.Objects; import java.util.concurrent.atomic.LongAccumulator; import org.apache.lucene.util.NumericUtils; @@ -30,8 +31,11 @@ * the hits that will ever exist for this query, the k-th best of the observed subset can never * exceed the k-th best of the complete result set. The floor is therefore, at every moment, a valid * lower bound on the final top-k similarity cutoff: a candidate that cannot beat the current floor - * can never enter the final merged top-k, and abandoning it loses nothing. This invariant requires - * that every score offered or advertised belongs to a distinct document; feeding duplicate + * can never enter the final merged top-k. This is a score-bound invariant, not a proof of HNSW + * recall: graph navigation may need to traverse a low-scoring bridge node to reach a better region. + * {@link FloorAwareKnnCollector} therefore applies the bound through a configurable exploration + * clamp, whose recall/visit trade-off must be measured for the target corpus. This invariant + * requires that every score offered or advertised belongs to a distinct document; feeding duplicate * documents (for example, hits for the same document from two replicas) may inflate the floor above * the true cutoff and callers must deduplicate before publishing. * @@ -52,7 +56,10 @@ * similarity found by a remote shard for the same query. It may be called from any thread at * any time, typically a transport handler reacting to a message from another JVM. The caller * is responsible for the value actually being a valid lower bound of the final k-th best - * similarity; the k-th best of any set of real, distinct hits for this query qualifies. + * similarity; the k-th best of any set of real, distinct hits for this query qualifies. A + * distributed coordinator should aggregate score batches from distinct document IDs before + * advertising its floor; this class deliberately does not define transport or replica + * deduplication. * * *

    An instance carries the state of a single query execution and must not be reused across @@ -115,10 +122,18 @@ public int k() { * been observed so far */ public float offer(float[] scores, int len) { + Objects.requireNonNull(scores, "scores"); if (len <= 0) { throw new IllegalArgumentException("len must be positive, got: " + len); } - assert isSorted(scores, len) : "scores must be sorted in ascending order"; + if (len > scores.length) { + throw new IllegalArgumentException( + "len must not exceed scores.length: len=" + len + ", scores.length=" + scores.length); + } + if (isSorted(scores, len) == false) { + throw new IllegalArgumentException( + "scores must be sorted in ascending order and must not contain NaN"); + } // The flag must be read BEFORE the offer. heapTop is only a valid k-th-best bound if the heap // was already full when our scores went in; reading the flag afterwards would let a concurrent // publisher fill the heap and set the flag in between, tricking this thread into publishing a @@ -177,8 +192,14 @@ private static float decode(long bits) { } private static boolean isSorted(float[] scores, int len) { + // The last element needs an explicit NaN check: NaN compares false against everything, so a + // trailing NaN never trips the pairwise comparison below, and Arrays.sort places NaN exactly + // there. + if (Float.isNaN(scores[len - 1])) { + return false; + } for (int i = 1; i < len; i++) { - if (scores[i - 1] > scores[i]) { + if (Float.isNaN(scores[i - 1]) || scores[i - 1] > scores[i]) { return false; } } diff --git a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java index 0ab47f7510e1..47c46b57b38c 100644 --- a/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java +++ b/lucene/sandbox/src/java/org/apache/lucene/sandbox/search/knn/SharedFloorKnnCollectorManager.java @@ -78,7 +78,15 @@ * *

    A manager, like the floor it holds, carries the state of a single query execution: create one * per query, and never share one across queries. Both collector-creation methods may be called - * concurrently, as segments are searched in parallel. + * concurrently, as segments are searched in parallel. The same execution-scoped manager must also + * be returned for optimistic reentry; callers should construct their query and manager together for + * one search rather than retaining either as reusable query state. + * + *

    Filtered kNN queries may fall back to exact search in core when floor pruning intentionally + * returns fewer than the core per-leaf quota. This preserves correctness but can erase the expected + * visit reduction. Until core exposes an intentional-partial-result contract, deployments should + * measure filtered queries separately and disable floor sharing where that fallback is + * unacceptable. * * @lucene.experimental */ diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java index 77febb7edd6f..d4a1f8770f4c 100644 --- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestBlockingFloatHeap.java @@ -27,6 +27,11 @@ public class TestBlockingFloatHeap extends LuceneTestCase { + public void testRejectsInvalidCapacity() { + expectThrows(IllegalArgumentException.class, () -> new BlockingFloatHeap(0)); + expectThrows(IllegalArgumentException.class, () -> new BlockingFloatHeap(-1)); + } + public void testBasicOperations() { BlockingFloatHeap heap = new BlockingFloatHeap(3); heap.offer(2); diff --git a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java index e48699434c85..6519fc02355d 100644 --- a/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java +++ b/lucene/sandbox/src/test/org/apache/lucene/sandbox/search/knn/TestGlobalKnnFloor.java @@ -145,6 +145,20 @@ public void testOfferRejectsNonPositiveLen() { expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {1f}, -1)); } + public void testOfferValidatesItsPublicBatchContract() { + GlobalKnnFloor floor = new GlobalKnnFloor(2); + expectThrows(NullPointerException.class, () -> floor.offer(null, 1)); + expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {1f}, 2)); + expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {2f, 1f}, 2)); + expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {Float.NaN}, 1)); + // A trailing NaN is the layout Arrays.sort produces and never trips a pairwise comparison, + // so it needs its own rejection path. Encoded, NaN sorts above positive infinity: letting one + // into a full heap would publish a floor no real score can beat. + expectThrows(IllegalArgumentException.class, () -> floor.offer(new float[] {1f, Float.NaN}, 2)); + expectThrows( + IllegalArgumentException.class, () -> floor.offer(new float[] {1f, 2f, Float.NaN}, 3)); + } + public void testConcurrentPublishersConvergeToKthBest() throws Exception { int k = 64; int numThreads = TestUtil.nextInt(random(), 2, 8);