diff --git a/docs/modules/gigamap/pages/indexing/jvector/advanced.adoc b/docs/modules/gigamap/pages/indexing/jvector/advanced.adoc index ae2b6193..e142929b 100644 --- a/docs/modules/gigamap/pages/indexing/jvector/advanced.adoc +++ b/docs/modules/gigamap/pages/indexing/jvector/advanced.adoc @@ -102,9 +102,9 @@ The FusedPQ implementation requires exactly `maxDegree=32`. This is a fixed cons When PQ compression is active, searches use a **two-phase approach** to balance speed and accuracy: -**Phase 1 -- Approximate candidate retrieval:** The HNSW graph is traversed using the FusedPQ-compressed vectors for fast approximate distance computation. This phase fetches `2 * k` candidates (twice the requested result count) to ensure the true top-k results are captured despite the approximation error introduced by quantization. +**Phase 1 -- Approximate candidate retrieval:** The HNSW graph is traversed using the FusedPQ-compressed vectors for fast approximate distance computation. This phase fetches `max(2 * k, minSearchBeamWidth)` candidates — at least twice the requested result count to absorb quantization error, and never less than the configured search beam width (see xref:indexing/jvector/configuration.adoc#_basic_hnsw_parameters[`minSearchBeamWidth`]) so the top-k stays stable across different `k` values. The per-query `search(query, k, searchBeamWidth)` overload raises this floor for a single call when higher recall is needed. -**Phase 2 -- Exact reranking:** The `2 * k` approximate candidates are then re-scored using the full-precision inline vectors stored in the graph file. The exact scores are sorted and the best _k_ results are returned. +**Phase 2 -- Exact reranking:** The approximate candidates are then re-scored using the full-precision inline vectors stored in the graph file. The exact scores are sorted and the best _k_ results are returned. This two-phase approach achieves nearly the same recall as an uncompressed search while benefiting from the speed and memory advantages of PQ during graph traversal. diff --git a/docs/modules/gigamap/pages/indexing/jvector/configuration.adoc b/docs/modules/gigamap/pages/indexing/jvector/configuration.adoc index 8813273a..5d15a1a2 100644 --- a/docs/modules/gigamap/pages/indexing/jvector/configuration.adoc +++ b/docs/modules/gigamap/pages/indexing/jvector/configuration.adoc @@ -95,7 +95,11 @@ NOTE: The `jdk.incubator.vector` module is an incubator feature in Java 17-21. S |`beamWidth` |100 -|Search beam width during index construction. Higher values improve recall during construction. +|Beam width during index construction (HNSW _efConstruction_). Higher values improve graph quality but slow down construction. Has no effect at query time. + +|`minSearchBeamWidth` +|100 +|Minimum beam width during search (HNSW _efSearch_ floor). The effective beam width is `max(k, minSearchBeamWidth)`. Keeps the top-k stable across different `k` values; set to `1` to disable the floor and make the beam width equal to the requested `k`. Independent of `beamWidth`. Can be overridden per query via `search(query, k, searchBeamWidth)`. |`neighborOverflow` |1.2 diff --git a/docs/modules/gigamap/pages/indexing/jvector/index.adoc b/docs/modules/gigamap/pages/indexing/jvector/index.adoc index 7c28f549..9bc22adf 100644 --- a/docs/modules/gigamap/pages/indexing/jvector/index.adoc +++ b/docs/modules/gigamap/pages/indexing/jvector/index.adoc @@ -187,6 +187,19 @@ List topDocs = result.stream() .toList(); ---- +=== Tuning search effort per query + +Every call uses a minimum beam width (HNSW _efSearch_) configured via `minSearchBeamWidth` (default 100). The effective beam width is `max(k, minSearchBeamWidth)` and keeps the top-k stable regardless of the requested `k`. A per-query overload lets you override this floor for a single call — useful to widen exploration for higher recall, or narrow it for lower latency when reproducibility across different `k` values is not required. + +[source, java] +---- +// Widen exploration for this query (higher recall, higher latency) +VectorSearchResult highRecall = index.search(queryVector, 10, 500); + +// Narrow exploration for this query (lower latency, may differ from higher-k results) +VectorSearchResult fast = index.search(queryVector, 10, 10); +---- + == Similarity Functions The following similarity functions are available: diff --git a/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/PQCompressionManager.java b/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/PQCompressionManager.java index fa94216c..dbdc9eb9 100644 --- a/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/PQCompressionManager.java +++ b/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/PQCompressionManager.java @@ -84,6 +84,7 @@ interface PQCompressionManager * * @param query the query vector * @param k the number of results to return + * @param rerankK minimum beam width (search effort) for the HNSW search * @param searcher the graph searcher to use * @param ravv random access vector values for exact reranking * @param similarityFunction the similarity function to use @@ -92,6 +93,7 @@ interface PQCompressionManager public SearchResult searchWithRerank( VectorFloat query , int k , + int rerankK , GraphSearcher searcher , RandomAccessVectorValues ravv , VectorSimilarityFunction similarityFunction @@ -238,13 +240,14 @@ private void trainPQ() public SearchResult searchWithRerank( final VectorFloat query , final int k , + final int rerankK , final GraphSearcher searcher , final RandomAccessVectorValues ravv , final VectorSimilarityFunction similarityFunction ) { // Search with PQ for approximate results (fetch more candidates for reranking) - final int candidateCount = k * PQ_RERANK_MULTIPLIER; + final int candidateCount = Math.max(k * PQ_RERANK_MULTIPLIER, rerankK); // Use exact vectors for search but rerank with exact vectors final SearchScoreProvider ssp = DefaultSearchScoreProvider.exact( @@ -253,7 +256,7 @@ public SearchResult searchWithRerank( ravv ); - final SearchResult result = searcher.search(ssp, candidateCount, Bits.ALL); + final SearchResult result = searcher.search(ssp, candidateCount, candidateCount, 0f, 0f, Bits.ALL); // Rerank with exact vectors to get the best k final List reranked = new ArrayList<>(); diff --git a/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndex.java b/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndex.java index 8ae0d089..92e88470 100644 --- a/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndex.java +++ b/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndex.java @@ -46,6 +46,8 @@ import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.IntStream; +import static org.eclipse.serializer.math.XMath.positive; + /** * A vector index that enables k-nearest-neighbor (k-NN) similarity search on entities. *

@@ -366,6 +368,31 @@ public default boolean isSuitableAsUniqueConstraint() */ public VectorSearchResult search(float[] queryVector, int k); + /** + * Searches for the k nearest neighbors with an explicit per-query search beam width + * (HNSW efSearch). + *

+ * This overload overrides the configured floor from + * {@link VectorIndexConfiguration#minSearchBeamWidth()} for a single call. The effective + * beam width is {@code max(k, searchBeamWidth)} because jvector requires the beam width + * to be at least as large as the requested {@code k}. + *

+ * Use this to widen exploration (e.g. {@code searchBeamWidth=500} for higher recall) or + * to narrow it (e.g. {@code searchBeamWidth=k} for minimum latency when reproducibility + * across different {@code k} values is not required). + * + * @param queryVector the query vector; must have exactly + * {@link VectorIndexConfiguration#dimension()} elements + * @param k the number of nearest neighbors to return; must be positive + * @param searchBeamWidth the beam width to use for this query; must be positive + * @return the search result + * @throws IllegalArgumentException if queryVector is null, has wrong dimension, or + * {@code k} / {@code searchBeamWidth} are not positive + * @see #search(float[], int) + * @see VectorIndexConfiguration#minSearchBeamWidth() + */ + public VectorSearchResult search(float[] queryVector, int k, int searchBeamWidth); + /** * Searches for the k nearest neighbors to the given entity's vector. *

@@ -405,6 +432,21 @@ public default VectorSearchResult search(final E queryEntity, final int k) return this.search(this.vectorizer().vectorize(queryEntity), k); } + /** + * Searches for the k nearest neighbors to the given entity's vector with an explicit + * per-query search beam width. + * + * @param queryEntity the query entity whose vector will be extracted via the vectorizer + * @param k the number of nearest neighbors to return; must be positive + * @param searchBeamWidth the beam width to use for this query; must be positive + * @return the search result + * @see #search(float[], int, int) + */ + public default VectorSearchResult search(final E queryEntity, final int k, final int searchBeamWidth) + { + return this.search(this.vectorizer().vectorize(queryEntity), k, searchBeamWidth); + } + /** * Performs cleanup and optimization of the index graph structure. *

@@ -1520,6 +1562,17 @@ public float[] getVector(final long entityId) @Override public VectorSearchResult search(final float[] queryVector, final int k) + { + return this.doSearch(queryVector, k, this.computeRerankK(k)); + } + + @Override + public VectorSearchResult search(final float[] queryVector, final int k, final int searchBeamWidth) + { + return this.doSearch(queryVector, k, Math.max(k, positive(searchBeamWidth))); + } + + private VectorSearchResult doSearch(final float[] queryVector, final int k, final int rerankK) { this.validateDimension(queryVector); @@ -1538,15 +1591,15 @@ public VectorSearchResult search(final float[] queryVector, final int k) final SearchResult result; if (this.incrementalMode) { - result = this.searchIncremental(query, k); + result = this.searchIncremental(query, k, rerankK); } else if (this.diskManager != null && this.diskManager.isLoaded() && this.diskManager.getDiskIndex() != null) { - result = this.searchDiskIndex(query, k); + result = this.searchDiskIndex(query, k, rerankK); } else { - result = this.searchInMemoryIndex(query, k); + result = this.searchInMemoryIndex(query, k, rerankK); } return this.convertSearchResult(result); @@ -1557,10 +1610,20 @@ else if (this.diskManager != null && this.diskManager.isLoaded() && this.diskMan } } + /** + * Computes the search beam width (rerankK), ensuring a minimum exploration effort + * regardless of how small k is. This prevents the HNSW search from returning + * different top-k results depending on the requested k value. + */ + private int computeRerankK(final int k) + { + return Math.max(k, this.configuration.minSearchBeamWidth()); + } + /** * Searches the in-memory index using a pooled GraphSearcher. */ - private SearchResult searchInMemoryIndex(final VectorFloat query, final int k) + private SearchResult searchInMemoryIndex(final VectorFloat query, final int k, final int rerankK) { final SearchScoreProvider scoreProvider = DefaultSearchScoreProvider.exact( query, @@ -1578,13 +1641,13 @@ private SearchResult searchInMemoryIndex(final VectorFloat query, final int k searcher.setView(view); } final Bits acceptBits = view != null ? view.liveNodes() : Bits.ALL; - return searcher.search(scoreProvider, k, acceptBits); + return searcher.search(scoreProvider, k, rerankK, 0f, 0f, acceptBits); } /** * Searches the on-disk index using a pooled GraphSearcher, with optional PQ-based approximate search and reranking. */ - private SearchResult searchDiskIndex(final VectorFloat query, final int k) + private SearchResult searchDiskIndex(final VectorFloat query, final int k, final int rerankK) { // If PQ is available, use compressed scoring with reranking if(this.pqManager != null && this.pqManager.isTrained() && this.pqManager.getCompressedVectors() != null) @@ -1593,6 +1656,7 @@ private SearchResult searchDiskIndex(final VectorFloat query, final int k) return this.pqManager.searchWithRerank( query, k, + rerankK, searcher, this.createCachingVectorValues(), this.jvectorSimilarityFunction() @@ -1607,14 +1671,14 @@ private SearchResult searchDiskIndex(final VectorFloat query, final int k) ); final GraphSearcher searcher = this.inMemorySearcherPool.get(); - return searcher.search(scoreProvider, k, Bits.ALL); + return searcher.search(scoreProvider, k, rerankK, 0f, 0f, Bits.ALL); } /** * Searches in incremental mode: queries both the disk graph (for existing data) * and the in-memory builder graph (for new mutations), then merges results. */ - private SearchResult searchIncremental(final VectorFloat query, final int k) + private SearchResult searchIncremental(final VectorFloat query, final int k, final int rerankK) { final SearchScoreProvider scoreProvider = DefaultSearchScoreProvider.exact( query, @@ -1623,12 +1687,13 @@ private SearchResult searchIncremental(final VectorFloat query, final int k) ); // 1. Search disk graph (excluding deleted/updated ordinals) + // Use rerankK as topK to give the merge a richer candidate pool SearchResult diskResult = null; if(this.diskSearcherPool != null) { final GraphSearcher diskSearcher = this.diskSearcherPool.get(); final Bits acceptBits = this.createDiskAcceptBits(); - diskResult = diskSearcher.search(scoreProvider, k, acceptBits); + diskResult = diskSearcher.search(scoreProvider, rerankK, rerankK, 0f, 0f, acceptBits); } // 2. Search in-memory graph (new mutations only) @@ -1636,23 +1701,27 @@ private SearchResult searchIncremental(final VectorFloat query, final int k) if(this.inMemorySearcherPool != null && this.index != null && this.index.size(0) > 0) { final GraphSearcher memSearcher = this.inMemorySearcherPool.get(); - // Refresh view so the searcher sees nodes added since pool initialization - memSearcher.setView(this.index.getView()); - memResult = memSearcher.search(scoreProvider, k, this.index.getView().liveNodes()); + // Capture the view once so setView(...) and liveNodes() agree on the same + // snapshot (ConcurrentGraphIndexView uses snapshot isolation — two separate + // getView() calls could return different snapshots). + final var view = this.index.getView(); + memSearcher.setView(view); + memResult = memSearcher.search(scoreProvider, rerankK, rerankK, 0f, 0f, view.liveNodes()); } - // 3. Merge results + // 3. Merge results — truncate single-source results to k since sub-graphs + // over-fetch to provide the merge with a richer candidate pool if(diskResult == null && memResult == null) { return new SearchResult(new SearchResult.NodeScore[0], 0, 0, 0, 0, 0f); } if(diskResult == null) { - return memResult; + return this.truncateResult(memResult, k); } if(memResult == null) { - return diskResult; + return this.truncateResult(diskResult, k); } return this.mergeSearchResults(diskResult, memResult, k); @@ -1707,6 +1776,23 @@ private Bits createDiskAcceptBits() return i -> i < 0 || i >= deletedMask.length || !deletedMask[i]; } + /** + * Truncates a SearchResult to at most k entries. Used when a single sub-graph + * provided all results and the over-fetched candidate pool needs trimming. + */ + private SearchResult truncateResult(final SearchResult result, final int k) + { + final SearchResult.NodeScore[] nodes = result.getNodes(); + if(nodes.length <= k) + { + return result; + } + return new SearchResult( + Arrays.copyOf(nodes, k), + result.getVisitedCount(), 0, 0, 0, 0f + ); + } + /** * Merges two SearchResults: combines nodes, deduplicates by ordinal * (keeping higher score), sorts by score descending, and takes top-k. diff --git a/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndexConfiguration.java b/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndexConfiguration.java index c1fd4428..5fb18d64 100644 --- a/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndexConfiguration.java +++ b/gigamap/jvector/src/main/java/org/eclipse/store/gigamap/jvector/VectorIndexConfiguration.java @@ -200,6 +200,37 @@ public interface VectorIndexConfiguration */ public int beamWidth(); + /** + * Returns the minimum beam width used during search (HNSW efSearch floor). + *

+ * This parameter controls the minimum number of candidate nodes the HNSW graph traversal + * explores when answering a query, independent of the requested {@code k}. The effective + * search beam width is {@code max(k, minSearchBeamWidth())}. + *

+ * Difference from {@link #beamWidth()}: the existing {@code beamWidth()} controls + * construction effort (HNSW efConstruction) and has no effect at query time. + * {@code minSearchBeamWidth()} controls search effort (HNSW efSearch) and has + * no effect during construction. They are orthogonal. + *

+ * Why a floor matters: when the beam width equals a small {@code k}, the traversal + * explores too few candidates and the resulting top-k can vary depending on the requested + * {@code k} (e.g. {@code search(q, 5)} and the first 5 results of {@code search(q, 50)} + * may differ). Enforcing a floor keeps the top-k stable regardless of the requested {@code k}. + *

+ * Tuning: + *

    + *
  • Higher values (200-500): better recall and more consistent results, + * at the cost of query latency.
  • + *
  • Lower values (down to 1): faster queries at small {@code k}. A value of + * {@code 1} effectively disables the floor — the beam width equals the requested {@code k}.
  • + *
+ *

+ * For per-query overrides, see {@link VectorIndex#search(float[], int, int)}. + * + * @return the minimum search beam width (default: 100) + */ + public int minSearchBeamWidth(); + /** * Returns the neighbor overflow factor for temporary neighbor storage during construction. *

@@ -771,6 +802,7 @@ public static Builder builderForHighPrecision(final int dimension) *

  • {@code similarityFunction}: {@link VectorSimilarityFunction#COSINE}
  • *
  • {@code maxDegree}: 16
  • *
  • {@code beamWidth}: 100
  • + *
  • {@code minSearchBeamWidth}: 100
  • *
  • {@code neighborOverflow}: 1.2
  • *
  • {@code alpha}: 1.2
  • * @@ -817,6 +849,18 @@ public static interface Builder */ public Builder beamWidth(int beamWidth); + /** + * Sets the minimum search beam width (HNSW efSearch floor). + *

    + * Pass {@code 1} to disable the floor so the beam width equals the requested {@code k}. + * + * @param minSearchBeamWidth the minimum search beam width (must be positive) + * @return this builder for method chaining + * @throws IllegalArgumentException if minSearchBeamWidth is not positive + * @see VectorIndexConfiguration#minSearchBeamWidth() + */ + public Builder minSearchBeamWidth(int minSearchBeamWidth); + /** * Sets the neighbor overflow factor for construction. * @@ -990,6 +1034,7 @@ public static class Default implements Builder private VectorSimilarityFunction similarityFunction ; private int maxDegree ; private int beamWidth ; + private int minSearchBeamWidth ; private float neighborOverflow ; private float alpha ; private boolean onDisk ; @@ -1011,6 +1056,7 @@ public static class Default implements Builder this.similarityFunction = VectorSimilarityFunction.COSINE; this.maxDegree = 16; this.beamWidth = 100; + this.minSearchBeamWidth = 100; this.neighborOverflow = 1.2f; this.alpha = 1.2f; this.onDisk = false; @@ -1055,6 +1101,13 @@ public Builder beamWidth(final int beamWidth) return this; } + @Override + public Builder minSearchBeamWidth(final int minSearchBeamWidth) + { + this.minSearchBeamWidth = positive(minSearchBeamWidth); + return this; + } + @Override public Builder neighborOverflow(final float neighborOverflow) { @@ -1209,6 +1262,7 @@ public VectorIndexConfiguration build() this.similarityFunction, this.maxDegree, this.beamWidth, + this.minSearchBeamWidth, this.neighborOverflow, this.alpha, this.onDisk, @@ -1240,6 +1294,7 @@ public static class Default implements VectorIndexConfiguration private final VectorSimilarityFunction similarityFunction ; private final int maxDegree ; private final int beamWidth ; + private final int minSearchBeamWidth ; private final float neighborOverflow ; private final float alpha ; private final boolean onDisk ; @@ -1260,6 +1315,7 @@ public static class Default implements VectorIndexConfiguration final VectorSimilarityFunction similarityFunction , final int maxDegree , final int beamWidth , + final int minSearchBeamWidth , final float neighborOverflow , final float alpha , final boolean onDisk , @@ -1280,6 +1336,7 @@ public static class Default implements VectorIndexConfiguration this.similarityFunction = similarityFunction ; this.maxDegree = maxDegree ; this.beamWidth = beamWidth ; + this.minSearchBeamWidth = minSearchBeamWidth ; this.neighborOverflow = neighborOverflow ; this.alpha = alpha ; this.onDisk = onDisk ; @@ -1320,6 +1377,12 @@ public int beamWidth() return this.beamWidth; } + @Override + public int minSearchBeamWidth() + { + return this.minSearchBeamWidth; + } + @Override public float neighborOverflow() { diff --git a/gigamap/jvector/src/test/java/org/eclipse/store/gigamap/jvector/VectorIndexTest.java b/gigamap/jvector/src/test/java/org/eclipse/store/gigamap/jvector/VectorIndexTest.java index 3d4babdd..d2800d62 100644 --- a/gigamap/jvector/src/test/java/org/eclipse/store/gigamap/jvector/VectorIndexTest.java +++ b/gigamap/jvector/src/test/java/org/eclipse/store/gigamap/jvector/VectorIndexTest.java @@ -2386,4 +2386,150 @@ void testGetVectorAfterUpdate() gigaMap.set(0, new Document("doc1-updated", new float[]{0.0f, 1.0f, 0.0f})); assertArrayEquals(new float[]{0.0f, 1.0f, 0.0f}, index.getVector(0)); } + + /** + * Verifies that the top-k results are consistent regardless of the requested k value. + * Previously, searching with k=5 and k=50 could produce different top-5 results because + * the HNSW beam width was coupled to k, causing insufficient exploration for small k. + */ + @Test + void searchTopKConsistentRegardlessOfRequestedK() + { + final int dimension = 32; + final int vectorCount = 200; + final Random random = new Random(42); + + final GigaMap gigaMap = GigaMap.New(); + final VectorIndices vectorIndices = gigaMap.index().register(VectorIndices.Category()); + vectorIndices.add("embeddings", + VectorIndexConfiguration.builder() + .dimension(dimension) + .similarityFunction(VectorSimilarityFunction.COSINE) + .maxDegree(16) + .beamWidth(100) + .build(), + new DocumentVectorizer32() + ); + + for(int i = 0; i < vectorCount; i++) + { + gigaMap.add(new Document("doc_" + i, randomVector(random, dimension))); + } + + final VectorIndex index = vectorIndices.get("embeddings"); + final float[] queryVector = randomVector(new Random(99), dimension); + + // Search with different k values + final VectorSearchResult result5 = index.search(queryVector, 5); + final VectorSearchResult result50 = index.search(queryVector, 50); + + // Extract top-5 entity IDs from each + final List top5from5 = result5.stream() + .map(VectorSearchResult.Entry::entityId) + .toList(); + final List top5from50 = result50.stream() + .limit(5) + .map(VectorSearchResult.Entry::entityId) + .toList(); + + assertEquals(top5from5, top5from50, + "Top-5 results should be identical regardless of k parameter"); + } + + /** + * Verifies that configuring a low minSearchBeamWidth is respected — i.e. the search beam + * width floor does not silently override a lower user setting. Confirms the fix for the + * issue that the previous hardcoded MIN_SEARCH_BEAM_WIDTH=100 caused. + */ + @Test + void minSearchBeamWidthRespectsLowerUserSetting() + { + final int dimension = 32; + final int vectorCount = 500; + final Random random = new Random(42); + + final GigaMap gigaMap = GigaMap.New(); + final VectorIndices vectorIndices = gigaMap.index().register(VectorIndices.Category()); + vectorIndices.add("embeddings", + VectorIndexConfiguration.builder() + .dimension(dimension) + .similarityFunction(VectorSimilarityFunction.COSINE) + .maxDegree(16) + .beamWidth(100) + .minSearchBeamWidth(1) // disable the floor + .build(), + new DocumentVectorizer32() + ); + + for(int i = 0; i < vectorCount; i++) + { + gigaMap.add(new Document("doc_" + i, randomVector(random, dimension))); + } + + final VectorIndex index = vectorIndices.get("embeddings"); + final float[] queryVector = randomVector(new Random(99), dimension); + + // With the floor disabled, the default search(q, k) must behave exactly like the + // explicit overload search(q, k, k) — both use the same effective beam width (k). + // Deterministic: if a silent floor of 100 were still being applied, search(q, 5) + // would explore 100 candidates while search(q, 5, 5) would explore 5, and the + // top-5 would diverge. + final List top5floorDisabled = index.search(queryVector, 5).stream() + .map(VectorSearchResult.Entry::entityId) + .toList(); + final List top5explicitBeamWidth = index.search(queryVector, 5, 5).stream() + .map(VectorSearchResult.Entry::entityId) + .toList(); + + assertEquals(top5explicitBeamWidth, top5floorDisabled, + "With minSearchBeamWidth=1, search(q, k) must match search(q, k, k) rather than " + + "silently applying a larger configured floor"); + } + + /** + * Verifies that the per-query searchBeamWidth overload overrides the configured floor. + * Compares search(q, k, N) against search(q, N).limit(k) — both should explore N candidates + * and produce the same top-k. + */ + @Test + void perQuerySearchBeamWidthOverridesConfig() + { + final int dimension = 32; + final int vectorCount = 500; + final Random random = new Random(42); + + final GigaMap gigaMap = GigaMap.New(); + final VectorIndices vectorIndices = gigaMap.index().register(VectorIndices.Category()); + vectorIndices.add("embeddings", + VectorIndexConfiguration.builder() + .dimension(dimension) + .similarityFunction(VectorSimilarityFunction.COSINE) + .maxDegree(16) + .beamWidth(100) + // default minSearchBeamWidth = 100 + .build(), + new DocumentVectorizer32() + ); + + for(int i = 0; i < vectorCount; i++) + { + gigaMap.add(new Document("doc_" + i, randomVector(random, dimension))); + } + + final VectorIndex index = vectorIndices.get("embeddings"); + final float[] queryVector = randomVector(new Random(99), dimension); + + // search(q, 5, 500) should explore 500 candidates and return the same top-5 as + // search(q, 500).limit(5), because both effectively run an HNSW search with rerankK=500. + final List top5override = index.search(queryVector, 5, 500).stream() + .map(VectorSearchResult.Entry::entityId) + .toList(); + final List top5fromFull = index.search(queryVector, 500).stream() + .limit(5) + .map(VectorSearchResult.Entry::entityId) + .toList(); + + assertEquals(top5fromFull, top5override, + "Per-query searchBeamWidth=500 must widen exploration beyond the default floor of 100"); + } }