diff --git a/.gitignore b/.gitignore
index f7ee48d3..d288869d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,14 @@
# SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION.
# SPDX-License-Identifier: Apache-2.0
+# Maven
*.jar
-target
-**/.DS_Store
-bin
+target/
+
+# IDE - IntelliJ
+.idea/
+*.iml
+
+# IDE - Eclipse
.project
cuvs-workdir
diff --git a/RAPIDS_BRANCH b/RAPIDS_BRANCH
index ba2906d0..5595d1b3 100644
--- a/RAPIDS_BRANCH
+++ b/RAPIDS_BRANCH
@@ -1 +1 @@
-main
+pull-request/2035
diff --git a/README.md b/README.md
index 4162e19d..34ca8efa 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,21 @@ The example below plugs the GPU-accelerated HNSW codec into a standard Lucene `I
Before running it, make sure cuVS is installed and available on your system library load path. The cuVS [tarball install instructions](https://docs.rapids.ai/api/cuvs/stable/build/#download-extract) show how to set this up.
+### RMM async allocation for GPU search
+
+Applications using `CuVS2510GPUSearchCodec` can opt into RMM's stream-ordered asynchronous device
+allocator during startup:
+
+```java
+CuVSProvider.provider().enableRMMAsyncMemory();
+```
+
+Call this before creating any cuVS resources, codecs, writers, or readers. The setting affects the
+entire process on the current CUDA device, so allocator policy belongs to the application rather
+than an individual Lucene codec. Async allocation is optional for correctness and recommended for
+GPU workloads with repeated device allocations, especially concurrent or multi-stream searches.
+Applications that do not opt in use the default RMM device-memory resource.
+
In a Maven project that includes the `cuvs-lucene` dependency shown above, create `src/main/java/com/nvidia/cuvs/lucene/examples/HelloCuvsLucene.java`:
```java
diff --git a/bench/pom.xml b/bench/pom.xml
index d4bc7fbc..f5955873 100644
--- a/bench/pom.xml
+++ b/bench/pom.xml
@@ -11,7 +11,7 @@
com.nvidia.cuvs.lucene.benchmarkscuvs-lucene-benchmarks
- 26.10.0
+ 26.10.0-SNAPSHOTjarcuvs-lucene-benchmarks
@@ -85,7 +85,7 @@
com.nvidia.cuvs.lucenecuvs-lucene
- 26.10.0
+ 26.10.0-SNAPSHOTcommons-io
diff --git a/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraIndexingBenchmarks.java b/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraIndexingBenchmarks.java
index 119f95a1..01cf540c 100644
--- a/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraIndexingBenchmarks.java
+++ b/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraIndexingBenchmarks.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.nvidia.cuvs.lucene.benchmarks;
@@ -9,6 +9,7 @@
import static com.nvidia.cuvs.lucene.benchmarks.Utils.index;
import com.nvidia.cuvs.lucene.CuVS2510GPUSearchCodec;
+import com.nvidia.cuvs.spi.CuVSProvider;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -57,6 +58,7 @@ public class CagraIndexingBenchmarks {
@Setup(Level.Trial)
public void setup() throws Exception {
+ CuVSProvider.provider().enableRMMAsyncMemory();
random = new Random(222);
indexDirPath = Paths.get(UUID.randomUUID().toString());
codec = new CuVS2510GPUSearchCodec();
diff --git a/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraSearchBenchmarks.java b/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraSearchBenchmarks.java
index 45f35caf..111d8c14 100644
--- a/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraSearchBenchmarks.java
+++ b/bench/src/main/java/com/nvidia/cuvs/lucene/benchmarks/CagraSearchBenchmarks.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.nvidia.cuvs.lucene.benchmarks;
@@ -11,6 +11,7 @@
import com.nvidia.cuvs.lucene.CuVS2510GPUSearchCodec;
import com.nvidia.cuvs.lucene.GPUKnnFloatVectorQuery;
+import com.nvidia.cuvs.spi.CuVSProvider;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -62,6 +63,7 @@ public class CagraSearchBenchmarks {
@Setup(Level.Trial)
public void setup() throws Exception {
+ CuVSProvider.provider().enableRMMAsyncMemory();
random = new Random(222);
indexDirPath = Paths.get(UUID.randomUUID().toString());
codec = new CuVS2510GPUSearchCodec();
diff --git a/build.sh b/build.sh
index 0ae9381a..e7bfe225 100755
--- a/build.sh
+++ b/build.sh
@@ -8,7 +8,7 @@ set -e -u -o pipefail
ARGS="$*"
NUMARGS=$#
-VERSION="26.10.0" # Note: The version is updated automatically when ci/release/update-version.sh is invoked
+VERSION="26.10.0-SNAPSHOT" # Note: The version is updated automatically when ci/release/update-version.sh is invoked
GROUP_ID="com.nvidia.cuvs.lucene"
function hasArg {
diff --git a/examples/pom.xml b/examples/pom.xml
index 9b8fcaaf..50e06c3a 100644
--- a/examples/pom.xml
+++ b/examples/pom.xml
@@ -11,7 +11,7 @@
com.nvidia.cuvs.lucene.examplesexamples
- 26.10.0
+ 26.10.0-SNAPSHOTexamples
@@ -100,7 +100,7 @@
com.nvidia.cuvs.lucenecuvs-lucene
- 26.10.0
+ 26.10.0-SNAPSHOT
diff --git a/examples/src/main/java/com/nvidia/cuvs/lucene/examples/IndexAndSearchonGPUExample.java b/examples/src/main/java/com/nvidia/cuvs/lucene/examples/IndexAndSearchonGPUExample.java
index 834544fe..1fd0a39f 100644
--- a/examples/src/main/java/com/nvidia/cuvs/lucene/examples/IndexAndSearchonGPUExample.java
+++ b/examples/src/main/java/com/nvidia/cuvs/lucene/examples/IndexAndSearchonGPUExample.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.nvidia.cuvs.lucene.examples;
@@ -10,6 +10,7 @@
import com.nvidia.cuvs.lucene.CuVS2510GPUSearchCodec;
import com.nvidia.cuvs.lucene.GPUKnnFloatVectorQuery;
import com.nvidia.cuvs.lucene.GPUSearchParams;
+import com.nvidia.cuvs.spi.CuVSProvider;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -30,9 +31,12 @@
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReader;
import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.KnnFloatVectorQuery;
+import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
@@ -46,6 +50,9 @@ public class IndexAndSearchonGPUExample {
public static void main(String[] args) throws Exception {
+ // Select the process-wide RMM allocator before creating any cuVS resources or codecs.
+ CuVSProvider.provider().enableRMMAsyncMemory();
+
GPUSearchParams params = new GPUSearchParams.Builder().build();
Codec codec = new CuVS2510GPUSearchCodec(params);
IndexWriterConfig config = new IndexWriterConfig().setCodec(codec).setUseCompoundFile(false);
@@ -56,6 +63,7 @@ public static void main(String[] args) throws Exception {
final int COMMIT_FREQ = 2000;
final String ID_FIELD = "id";
final String VECTOR_FIELD = "vector_field";
+ final String STATUS_FIELD = "status";
int numDocs = 2000;
int dimension = 32;
@@ -70,6 +78,9 @@ public static void main(String[] args) throws Exception {
Document document = new Document();
document.add(new StringField(ID_FIELD, Integer.toString(i), Field.Store.YES));
document.add(new KnnFloatVectorField(VECTOR_FIELD, dataset[i], EUCLIDEAN));
+ // Alternatively flip the status's for documents as an example.
+ document.add(
+ new StringField(STATUS_FIELD, i % 2 == 0 ? "active" : "in-active", Field.Store.YES));
indexWriter.addDocument(document);
count -= 1;
if (count == 0) {
@@ -103,8 +114,11 @@ public static void main(String[] args) throws Exception {
float[] queryVector = generateDataset(random, 1, dimension)[0];
log.log(Level.FINE, "Query vector: " + Arrays.toString(queryVector));
+ // Make a filter query
+ Query filter = new TermQuery(new Term(STATUS_FIELD, "active"));
+
KnnFloatVectorQuery query =
- new GPUKnnFloatVectorQuery(VECTOR_FIELD, queryVector, topK, null, topK, 1);
+ new GPUKnnFloatVectorQuery(VECTOR_FIELD, queryVector, topK, filter, topK, 1);
TopDocs results = searcher.search(query, topK);
log.log(Level.FINE, "Search results (" + results.totalHits + " total hits):");
@@ -113,12 +127,15 @@ public static void main(String[] args) throws Exception {
ScoreDoc scoreDoc = results.scoreDocs[i];
Document doc = searcher.storedFields().document(scoreDoc.doc);
String id = doc.get(ID_FIELD);
+ String status = doc.get(STATUS_FIELD);
log.log(
Level.FINE,
" Rank "
+ (i + 1)
+ ": doc "
+ scoreDoc.doc
+ + " status "
+ + status
+ " (id="
+ id
+ "), score="
diff --git a/license-header.txt b/license-header.txt
new file mode 100644
index 00000000..f9b093f8
--- /dev/null
+++ b/license-header.txt
@@ -0,0 +1,4 @@
+/*
+ * SPDX-FileCopyrightText: Copyright (c) $YEAR, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
diff --git a/pom.xml b/pom.xml
index 70f246d2..1676478f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,7 +12,7 @@
com.nvidia.cuvs.lucenecuvs-lucene
- 26.10.0
+ 26.10.0-SNAPSHOTcuvs-lucenejar
@@ -36,6 +36,18 @@
https://github.com/rapidsai/cuvs-lucene
+
+
+ rapidsai
+ rapidsai
+ cuvs@rapids.ai
+
+ Maintainer
+ Committer
+
+
+
+
2222
@@ -68,6 +80,12 @@
false
+
+ central-snapshots
+ https://central.sonatype.com/repository/maven-snapshots/
+ true
+ false
+
@@ -129,10 +147,17 @@
com.nvidia.cuvscuvs-java
- 26.10.0
+ 26.10.0-SNAPSHOT
+
+
+ central
+ https://central.sonatype.com/repository/maven-snapshots/
+
+
+
@@ -158,6 +183,9 @@
truefalse
+
+ ${project.basedir}/license-header.txt
+
@@ -219,6 +247,7 @@
jar
+ com.nvidia.cuvs.luceneall,-missing${project.build.directory}/javadocs
diff --git a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUSearchCodec.java b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUSearchCodec.java
index 2de761a7..45a16740 100644
--- a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUSearchCodec.java
+++ b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUSearchCodec.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.nvidia.cuvs.lucene;
@@ -29,8 +29,11 @@ public class CuVS2510GPUSearchCodec extends FilterCodec {
* @throws Exception
*/
public CuVS2510GPUSearchCodec() throws Exception {
- this(NAME, LuceneProvider.getCodec("101"));
- initializeFormat(new GPUSearchParams.Builder().build());
+ this(
+ NAME,
+ LuceneProvider.getCodec("101"),
+ new GPUSearchParams.Builder().build(),
+ FilterBitsetCacheConfig.DEFAULT);
}
/**
@@ -41,8 +44,11 @@ public CuVS2510GPUSearchCodec() throws Exception {
* @param delegate the delegate codec
*/
public CuVS2510GPUSearchCodec(String name, Codec delegate) {
- super(name, delegate);
- initializeFormat(new GPUSearchParams.Builder().build());
+ this(
+ name,
+ delegate,
+ new GPUSearchParams.Builder().build(),
+ FilterBitsetCacheConfig.DEFAULT);
}
/**
@@ -53,18 +59,48 @@ public CuVS2510GPUSearchCodec(String name, Codec delegate) {
* @throws Exception Exception raised when initializing the codec
*/
public CuVS2510GPUSearchCodec(GPUSearchParams params) throws Exception {
- this(NAME, LuceneProvider.getCodec("101"));
- initializeFormat(params);
+ this(NAME, LuceneProvider.getCodec("101"), params, FilterBitsetCacheConfig.DEFAULT);
+ }
+
+ /**
+ * Initialize the codec with GPU search and filter-bitset-cache parameters.
+ *
+ * @param params GPU index and search parameters
+ * @param filterCacheConfig filter-bitset-cache configuration
+ * @throws Exception Exception raised when initializing the codec
+ */
+ public CuVS2510GPUSearchCodec(
+ GPUSearchParams params, FilterBitsetCacheConfig filterCacheConfig) throws Exception {
+ this(NAME, LuceneProvider.getCodec("101"), params, filterCacheConfig);
+ }
+
+ /**
+ * Initialize a named codec with explicit delegate, GPU search, and filter-cache parameters.
+ *
+ * @param name the name of the codec
+ * @param delegate the delegate codec
+ * @param params GPU index and search parameters
+ * @param filterCacheConfig filter-bitset-cache configuration
+ */
+ public CuVS2510GPUSearchCodec(
+ String name,
+ Codec delegate,
+ GPUSearchParams params,
+ FilterBitsetCacheConfig filterCacheConfig) {
+ super(name, delegate);
+ initializeFormat(params, filterCacheConfig);
}
/**
* Initialize the {@link CuVS2510GPUVectorsFormat} instance using {@link GPUSearchParams}.
*
* @param params an instance of {@link GPUSearchParams}
+ * @param filterCacheConfig filter-bitset-cache configuration
*/
- private void initializeFormat(GPUSearchParams params) {
+ private void initializeFormat(
+ GPUSearchParams params, FilterBitsetCacheConfig filterCacheConfig) {
try {
- format = new CuVS2510GPUVectorsFormat(params);
+ format = new CuVS2510GPUVectorsFormat(params, filterCacheConfig);
setKnnFormat(format);
} catch (LibraryException ex) {
log.log(
diff --git a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsFormat.java b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsFormat.java
index ccc61eae..3e62bd71 100644
--- a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsFormat.java
+++ b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsFormat.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.nvidia.cuvs.lucene;
@@ -35,7 +35,8 @@ public class CuVS2510GPUVectorsFormat extends KnnVectorsFormat {
public static final int VERSION_START = 0;
public static final int VERSION_CURRENT = VERSION_START;
- private GPUSearchParams gpuSearchParams;
+ private final GPUSearchParams gpuSearchParams;
+ private final FilterBitsetCache filterBitsetCache;
static {
try {
@@ -53,7 +54,7 @@ public class CuVS2510GPUVectorsFormat extends KnnVectorsFormat {
* @throws LibraryException if the native library fails to load
*/
public CuVS2510GPUVectorsFormat() {
- this(new GPUSearchParams.Builder().build());
+ this(new GPUSearchParams.Builder().build(), FilterBitsetCacheConfig.DEFAULT);
}
/**
@@ -63,8 +64,21 @@ public CuVS2510GPUVectorsFormat() {
* @throws LibraryException if the native library fails to load
*/
public CuVS2510GPUVectorsFormat(GPUSearchParams gpuSearchParams) {
+ this(gpuSearchParams, FilterBitsetCacheConfig.DEFAULT);
+ }
+
+ /**
+ * Initializes the format with GPU search and filter-bitset-cache parameters.
+ *
+ * @param gpuSearchParams GPU index and search parameters
+ * @param filterCacheConfig filter-bitset-cache configuration
+ * @throws LibraryException if the native library fails to load
+ */
+ public CuVS2510GPUVectorsFormat(
+ GPUSearchParams gpuSearchParams, FilterBitsetCacheConfig filterCacheConfig) {
super("CuVS2510GPUVectorsFormat");
this.gpuSearchParams = gpuSearchParams;
+ this.filterBitsetCache = new FilterBitsetCache(filterCacheConfig);
}
/**
@@ -83,7 +97,8 @@ public KnnVectorsWriter fieldsWriter(SegmentWriteState state) throws IOException
@Override
public KnnVectorsReader fieldsReader(SegmentReadState state) throws IOException {
assertIsSupported();
- return new CuVS2510GPUVectorsReader(state, FLAT_VECTORS_FORMAT.fieldsReader(state));
+ return new CuVS2510GPUVectorsReader(
+ state, FLAT_VECTORS_FORMAT.fieldsReader(state), filterBitsetCache);
}
/**
diff --git a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java
index c783660d..4aa8a82a 100644
--- a/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java
+++ b/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java
@@ -1,5 +1,5 @@
/*
- * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION.
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*/
package com.nvidia.cuvs.lucene;
@@ -66,6 +66,7 @@ public class CuVS2510GPUVectorsReader extends KnnVectorsReader {
private final IntObjectHashMap fields;
private final IntObjectHashMap cuvsIndices;
private final IndexInput cuvsIndexInput;
+ private final FilterBitsetCache filterBitsetCache;
static {
try {
@@ -86,7 +87,22 @@ public class CuVS2510GPUVectorsReader extends KnnVectorsReader {
*/
public CuVS2510GPUVectorsReader(SegmentReadState state, FlatVectorsReader flatReader)
throws IOException {
+ this(state, flatReader, new FilterBitsetCache(FilterBitsetCacheConfig.DEFAULT));
+ }
+
+ /**
+ * Initializes the reader with the cache owned by its vectors format.
+ *
+ * @param state instance of the SegmentReadState
+ * @param flatReader instance of the FlatVectorsReader
+ * @param filterBitsetCache filter cache shared by readers from the same vectors format
+ * @throws IOException I/O exception
+ */
+ CuVS2510GPUVectorsReader(
+ SegmentReadState state, FlatVectorsReader flatReader, FilterBitsetCache filterBitsetCache)
+ throws IOException {
this.flatVectorsReader = flatReader;
+ this.filterBitsetCache = filterBitsetCache;
this.fieldInfos = state.fieldInfos;
this.fields = new IntObjectHashMap<>();
String metaFileName =
@@ -430,7 +446,10 @@ public void search(String field, float[] target, KnnCollector knnCollector, Bits
}
}
topK = Math.min(knnCollector.k() + 10, mask[0].cardinality());
- maskLength = mask[0].length();
+ // numDocs must be the total vector count so cuVS sizes the prefilter to cover every ordinal.
+ // BitSet.length() is (highest set bit + 1), which under a selective filter is smaller than the
+ // vector count, leaving the trailing ordinals outside the filter and thus default-accepted.
+ maskLength = acceptedOrds.length();
}
try {
@@ -443,6 +462,9 @@ public void search(String field, float[] target, KnnCollector knnCollector, Bits
new CagraSearchParams.Builder()
.withItopkSize(Math.max(collector.getiTopK(), topK))
.withSearchWidth(collector.getSearchWidth())
+ .withThreadBlockSize(collector.getThreadBlockSize())
+ .withMaxIterations(collector.getMaxIterations())
+ .withAlgo(collector.getSearchAlgo())
.build();
} else {
// Setting itopK as topK because in any case iTopK should be ATLEAST equal to topK
@@ -509,7 +531,11 @@ public void search(String field, float[] target, KnnCollector knnCollector, Bits
if (knnCollector.earlyTerminated()) {
break;
}
- if (ord < 0) {
+ // Empty top-k slots (fewer than k passing candidates) carry a sentinel distance of FLT_MAX.
+ // Prefer this over the neighbor-index sentinel: the index sentinel is not uniform across
+ // CAGRA search algorithms (single-CTA emits 0x7FFFFFFF, multi-CTA 0xFFFFFFFF), so the
+ // distance is the reliable, algorithm-independent signal for an empty slot.
+ if (score == Float.MAX_VALUE || ord < 0) {
continue;
}
float correctedScore = scoreCorrectionFunction.apply(score);
@@ -600,6 +626,27 @@ private static void checkVersion(int versionMeta, int versionVectorData, IndexIn
}
}
+ /**
+ * Returns the {@link CagraIndex} for the given field, or {@code null} if unavailable
+ * (e.g., during a merge or when the field is missing).
+ *
+ * @param field the vector field name
+ * @return the CAGRA index, or {@code null}
+ */
+ public CagraIndex getCagraIndexForField(String field) {
+ if (cuvsIndices == null) return null;
+ FieldInfo info = fieldInfos.fieldInfo(field);
+ if (info == null) return null;
+ GPUIndex gpuIndex = cuvsIndices.get(info.number);
+ if (gpuIndex == null) return null;
+ return gpuIndex.getCagraIndex();
+ }
+
+ /** Returns the filter cache owned by the vectors format that created this reader. */
+ FilterBitsetCache getFilterBitsetCache() {
+ return filterBitsetCache;
+ }
+
/**
* Gets the instance of FieldInfos.
*
diff --git a/src/main/java/com/nvidia/cuvs/lucene/FilterBitsetCache.java b/src/main/java/com/nvidia/cuvs/lucene/FilterBitsetCache.java
new file mode 100644
index 00000000..af30ebef
--- /dev/null
+++ b/src/main/java/com/nvidia/cuvs/lucene/FilterBitsetCache.java
@@ -0,0 +1,318 @@
+/*
+ * SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package com.nvidia.cuvs.lucene;
+
+import com.nvidia.cuvs.FilterBitsetHandle;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.logging.Logger;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.search.Query;
+
+/**
+ * Shared LRU cache mapping (filter Query, single-segment reader key, field) → {@link
+ * FilterBitsetHandle}. One entry per segment, so an index update only invalidates the changed
+ * segments' entries rather than the whole cache.
+ *
+ *
Host-side cache holding packed bitset arrays; the device-side upload is managed inside {@link
+ * FilterBitsetHandle} itself. Entries are evicted in LRU order once the total cached size exceeds
+ * the configured byte budget.
+ *
+ *
Sizing
+ *
+ *
The cache is bounded by a byte budget rather than an entry count, since per-segment bitsets
+ * vary widely in size. The budget covers the host-side packed arrays; the device-side allocation
+ * mirrors it one-for-one, so a single budget bounds both. The default is the fixed {@link
+ * FilterBitsetCacheConfig#DEFAULT_MAX_BYTES} budget and can be overridden through {@link
+ * FilterBitsetCacheConfig}. The budget is not preallocated: packed arrays are retained as entries
+ * are inserted. Actual host usage can exceed the accounted payload bytes because of object
+ * overhead, temporary filter construction, and handles that remain referenced by in-flight
+ * searches after eviction.
+ *
+ *
Concurrency
+ *
+ *
Values are stored as {@link CompletableFuture}s so that concurrent misses on the same key
+ * build the handle exactly once: the first thread inserts an incomplete future and builds; others
+ * find the future and await it (without holding the cache lock, so builds for different keys still
+ * run in parallel).
+ *
+ *
Lifetime is reference-counted (see {@link FilterBitsetHandle}). A cached handle carries one
+ * cache-owned reference, released via {@link FilterBitsetHandle#close()} when the entry is evicted.
+ * {@link #acquire} returns a handle with an additional reference already taken; the caller
+ * must {@link FilterBitsetHandle#decRef()} it after use. Because the free happens only when the last
+ * reference is dropped, an eviction concurrent with an in-flight search cannot free a handle still
+ * in use, and a replaced entry cannot leak.
+ */
+final class FilterBitsetCache {
+
+ private static final Logger LOG = Logger.getLogger(FilterBitsetCache.class.getName());
+
+ /**
+ * Secondary guard on entry count so a pathological stream of tiny entries cannot grow the map
+ * unbounded even when the byte budget is generous. The byte budget is the primary bound.
+ */
+ static final int MAX_ENTRIES_GUARD = 4096;
+
+ /** Builds the handle for a key on a cache miss. */
+ @FunctionalInterface
+ interface FilterBuilder {
+ FilterBitsetHandle build() throws IOException;
+ }
+
+ private record FilterCacheKey(Query filter, Object segReaderKey, String field) {
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof FilterCacheKey other)) return false;
+ return Objects.equals(filter, other.filter)
+ && Objects.equals(segReaderKey, other.segReaderKey)
+ && Objects.equals(field, other.field);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(filter, segReaderKey, field);
+ }
+ }
+
+ /** A cached future together with the byte size charged to the budget for its entry. */
+ private record CacheEntry(CompletableFuture future, long bytes) {}
+
+ private final LinkedHashMap cache =
+ new LinkedHashMap<>(16, 0.75f, /* access-order= */ true);
+
+ // Segment reader keys with a registered close listener, so each reader registers exactly once.
+ private final Set