You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@lucene.apache.org by "atris (via GitHub)" <gi...@apache.org> on 2026/01/26 14:12:15 UTC
[PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
atris opened a new pull request, #15613:
URL: https://github.com/apache/lucene/pull/15613
Adds Lucene99SpannVectorsFormat, implementing Disk-Resident HNSW-IVF (SPANN) to support vector indices larger than available heap.
This PR segregatse the index into a Coarse Quantizer (Centroids in HNSW) and the actual Data (Disk-resident inverted lists).
Writer flow: Buffers vectors in heap, then runs K-Means++ on flush (using reservoir sampling for amortised performance). Writes centroids to the delegate format and vector data sequentially to .spad files.
Reader flow: Performs a two-phase search. First phase queries HNSW for the nearest nprobe partitions. Second phase uses the chosen centroids from first phase and scans the candidate partitions on disk.
Testing strategy includes unit tests for integrity, clustering correctness, and a recall validation test confirming that higher n-probe retrieves better results.
Note: Merging currently uses the default implementation and requires heap proportional to segment size. Disk-based merging is a follow-up.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733152421
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
Review Comment:
The code enforces strict type matching. `search()`explicitly checks the field encoding and throws IllegalArgumentException if you try to query a FLOAT32 field with bytes or vice versa.
`searchFine` accepts both arguments solely to deduplicate the traversal implementation; it strictly relies on the segment's encoding to decide which target to use. There is no implicit conversion or quantization.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "vigyasharma (via GitHub)" <gi...@apache.org>.
vigyasharma commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2728750810
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
Review Comment:
I'm confused on the need for handling these cases - float target with byte vectors. Are you always storing the postings in byte format? (but I see a vice versa conversion as well). From what I know, the only place we have this currently is when vectors are explicitly stored as bytes, e.g. with scalar quantized vectors. In which case, the target is converted to bytes when it is quantized.
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
+ for (int i = 0; i < target.length; i++) {
+ byteTarget[i] = (byte) target[i];
+ }
+ topCentroids = searchCentroids(field, byteTarget, acceptDocs);
+ } else {
+ topCentroids = searchCentroids(field, target, acceptDocs);
+ }
+
+ searchFine(entry, target, topCentroids, knnCollector, acceptDocs);
+ }
+
+ @Override
+ public void search(String field, byte[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ float[] floatTarget = new float[target.length];
+ for (int i = 0; i < target.length; i++) {
+ floatTarget[i] = (float) target[i];
+ }
Review Comment:
Do we need an `i+=4` i.e. jump by 4 bytes when converting it to floats. I've seen us use ByteBuffers in Lucene for these things.
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsWriter.java:
##########
@@ -0,0 +1,246 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnFieldVectorsWriter;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsWriter;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentWriteState;
+import org.apache.lucene.index.Sorter;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Writes vectors in the SPANN (HNSW-IVF) format.
+ *
+ * <p>Centroids are computed via K-Means and indexed into an HNSW-based coarse quantizer. Vector
+ * data is assigned to the nearest centroid and written sequentially in a clustered format.
+ */
+public class Lucene99SpannVectorsWriter extends KnnVectorsWriter {
+
+ private static final int KMEANS_MAX_ITERS = 20;
+
+ private final SegmentWriteState state;
+ private final KnnVectorsWriter centroidDelegate;
+ private final Map<String, SpannFieldVectorsWriter> fieldWriters = new HashMap<>();
+ private final int maxPartitions;
+ private final int clusteringSampleSize;
+
+ public Lucene99SpannVectorsWriter(
+ SegmentWriteState state,
+ KnnVectorsFormat centroidFormat,
+ int maxPartitions,
+ int clusteringSampleSize)
+ throws IOException {
+ this.state = state;
+ this.centroidDelegate = centroidFormat.fieldsWriter(state);
+ this.maxPartitions = maxPartitions;
+ this.clusteringSampleSize = clusteringSampleSize;
+ }
+
+ @Override
+ public KnnFieldVectorsWriter<?> addField(FieldInfo fieldInfo) throws IOException {
+ SpannFieldVectorsWriter writer = new SpannFieldVectorsWriter(fieldInfo);
+ fieldWriters.put(fieldInfo.name, writer);
+
+ if (fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ return new KnnFieldVectorsWriter<byte[]>() {
+ @Override
+ public void addValue(int docID, byte[] vectorValue) throws IOException {
+ float[] floats = new float[vectorValue.length];
+ for (int i = 0; i < vectorValue.length; i++) {
+ floats[i] = (float) vectorValue[i];
+ }
+ writer.addValue(docID, floats);
+ }
+
+ @Override
+ public byte[] copyValue(byte[] vectorValue) {
+ return vectorValue.clone();
+ }
+
+ @Override
+ public long ramBytesUsed() {
+ return 0; // The delegate writer tracks usage
+ }
+ };
+ }
+
+ return writer;
+ }
+
+ @Override
+ public void flush(int maxDoc, Sorter.DocMap sortMap) throws IOException {
+
+ for (Map.Entry<String, SpannFieldVectorsWriter> entry : fieldWriters.entrySet()) {
+ String fieldName = entry.getKey();
+ SpannFieldVectorsWriter writer = entry.getValue();
+ FieldInfo fieldInfo = writer.getFieldInfo();
+
+ if (writer.getVectors().isEmpty()) {
+ continue;
+ }
+
+ float[][] vectorArray = writer.getVectors().toArray(new float[0][]);
+
+ // Cap the number of partitions at configured limit (default 100)
+ int numPartitions = Math.min(vectorArray.length, maxPartitions);
+
+ // Downsample to keep flush time constant
Review Comment:
Minor: Let's treat this downsampling as a "clustering impl" detail and move it into the clustering class?
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsWriter.java:
##########
@@ -0,0 +1,246 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnFieldVectorsWriter;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsWriter;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentWriteState;
+import org.apache.lucene.index.Sorter;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Writes vectors in the SPANN (HNSW-IVF) format.
+ *
+ * <p>Centroids are computed via K-Means and indexed into an HNSW-based coarse quantizer. Vector
+ * data is assigned to the nearest centroid and written sequentially in a clustered format.
+ */
+public class Lucene99SpannVectorsWriter extends KnnVectorsWriter {
+
+ private static final int KMEANS_MAX_ITERS = 20;
+
+ private final SegmentWriteState state;
+ private final KnnVectorsWriter centroidDelegate;
+ private final Map<String, SpannFieldVectorsWriter> fieldWriters = new HashMap<>();
+ private final int maxPartitions;
+ private final int clusteringSampleSize;
+
+ public Lucene99SpannVectorsWriter(
+ SegmentWriteState state,
+ KnnVectorsFormat centroidFormat,
+ int maxPartitions,
+ int clusteringSampleSize)
+ throws IOException {
+ this.state = state;
+ this.centroidDelegate = centroidFormat.fieldsWriter(state);
+ this.maxPartitions = maxPartitions;
+ this.clusteringSampleSize = clusteringSampleSize;
+ }
+
+ @Override
+ public KnnFieldVectorsWriter<?> addField(FieldInfo fieldInfo) throws IOException {
+ SpannFieldVectorsWriter writer = new SpannFieldVectorsWriter(fieldInfo);
+ fieldWriters.put(fieldInfo.name, writer);
+
+ if (fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ return new KnnFieldVectorsWriter<byte[]>() {
+ @Override
+ public void addValue(int docID, byte[] vectorValue) throws IOException {
+ float[] floats = new float[vectorValue.length];
+ for (int i = 0; i < vectorValue.length; i++) {
+ floats[i] = (float) vectorValue[i];
+ }
+ writer.addValue(docID, floats);
+ }
+
+ @Override
+ public byte[] copyValue(byte[] vectorValue) {
+ return vectorValue.clone();
+ }
+
+ @Override
+ public long ramBytesUsed() {
+ return 0; // The delegate writer tracks usage
+ }
+ };
+ }
+
+ return writer;
+ }
+
+ @Override
+ public void flush(int maxDoc, Sorter.DocMap sortMap) throws IOException {
+
+ for (Map.Entry<String, SpannFieldVectorsWriter> entry : fieldWriters.entrySet()) {
+ String fieldName = entry.getKey();
+ SpannFieldVectorsWriter writer = entry.getValue();
+ FieldInfo fieldInfo = writer.getFieldInfo();
+
+ if (writer.getVectors().isEmpty()) {
+ continue;
+ }
+
+ float[][] vectorArray = writer.getVectors().toArray(new float[0][]);
+
+ // Cap the number of partitions at configured limit (default 100)
+ int numPartitions = Math.min(vectorArray.length, maxPartitions);
+
+ // Downsample to keep flush time constant
+ // Cap at max(clusteringSampleSize, 256 * numPartitions)
+ float[][] trainingVectors = vectorArray;
+ if (vectorArray.length > clusteringSampleSize) {
+ int step = vectorArray.length / clusteringSampleSize;
+ trainingVectors = new float[clusteringSampleSize][];
+ for (int i = 0; i < clusteringSampleSize; i++) {
+ trainingVectors[i] = vectorArray[i * step];
+ }
+ }
+
+ float[][] centroids =
+ SpannKMeans.cluster(
+ trainingVectors,
+ numPartitions,
+ fieldInfo.getVectorSimilarityFunction(),
+ KMEANS_MAX_ITERS);
+
+ if (fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ @SuppressWarnings("unchecked")
+ KnnFieldVectorsWriter<byte[]> byteCentroidWriter =
+ (KnnFieldVectorsWriter<byte[]>) centroidDelegate.addField(fieldInfo);
+ for (int partitionId = 0; partitionId < centroids.length; partitionId++) {
+ byte[] byteCentroid = new byte[centroids[partitionId].length];
+ for (int k = 0; k < centroids[partitionId].length; k++) {
+ byteCentroid[k] = (byte) centroids[partitionId][k];
+ }
+ byteCentroidWriter.addValue(partitionId, byteCentroid);
+ }
+ } else {
+ @SuppressWarnings("unchecked")
+ KnnFieldVectorsWriter<float[]> floatCentroidWriter =
+ (KnnFieldVectorsWriter<float[]>) centroidDelegate.addField(fieldInfo);
+ for (int partitionId = 0; partitionId < centroids.length; partitionId++) {
+ floatCentroidWriter.addValue(partitionId, centroids[partitionId]);
+ }
+ }
+
+ List<List<Integer>> partitionDocIds = new ArrayList<>(centroids.length);
+ List<List<float[]>> partitions = new ArrayList<>(centroids.length);
+ for (int i = 0; i < centroids.length; i++) {
+ partitions.add(new ArrayList<>());
+ partitionDocIds.add(new ArrayList<>());
+ }
+
+ VectorSimilarityFunction simFunc = fieldInfo.getVectorSimilarityFunction();
+ List<float[]> vectors = writer.getVectors();
+ List<Integer> docIds = writer.getDocIds();
+ for (int i = 0; i < vectors.size(); i++) {
+ float[] vector = vectors.get(i);
+ int docId = docIds.get(i);
+ int bestCentroid = 0;
+ float bestScore = Float.NEGATIVE_INFINITY;
+
+ for (int c = 0; c < centroids.length; c++) {
+ float score = simFunc.compare(vector, centroids[c]);
Review Comment:
k-means clustering would've done assigned some vectors to partitions already. Do we need to compare every vector against every centroid?
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/SpannFieldVectorsWriter.java:
##########
@@ -0,0 +1,73 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.lucene.codecs.KnnFieldVectorsWriter;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.util.RamUsageEstimator;
+
+/**
+ * Buffers vectors in memory until flush.
+ *
+ * <p>Future improvements could include off-heap or disk-backed buffering (e.g. ByteBlockPool) to
Review Comment:
Minor: How do you feel about keeping these ideas in github issues instead of code comments? IMO it's okay to call out a performance bottleneck TODO in comments, but naming specific techniques leaves future readers wondering whether and where they were implemented.
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
+ for (int i = 0; i < target.length; i++) {
+ byteTarget[i] = (byte) target[i];
+ }
+ topCentroids = searchCentroids(field, byteTarget, acceptDocs);
+ } else {
+ topCentroids = searchCentroids(field, target, acceptDocs);
+ }
+
+ searchFine(entry, target, topCentroids, knnCollector, acceptDocs);
+ }
+
+ @Override
+ public void search(String field, byte[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ float[] floatTarget = new float[target.length];
+ for (int i = 0; i < target.length; i++) {
+ floatTarget[i] = (float) target[i];
+ }
+
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ topCentroids = searchCentroids(field, target, acceptDocs);
+ } else {
+ topCentroids = searchCentroids(field, floatTarget, acceptDocs);
+ }
+
+ searchFine(entry, floatTarget, topCentroids, knnCollector, acceptDocs);
+ }
+
+ private TopDocs searchCentroids(String field, float[] target, AcceptDocs acceptDocs)
+ throws IOException {
+ TopKnnCollector coarseCollector = new TopKnnCollector(nprobe, Integer.MAX_VALUE);
+ centroidDelegate.search(field, target, coarseCollector, acceptDocs);
+ return coarseCollector.topDocs();
+ }
+
+ private TopDocs searchCentroids(String field, byte[] target, AcceptDocs acceptDocs)
+ throws IOException {
+ TopKnnCollector coarseCollector = new TopKnnCollector(nprobe, Integer.MAX_VALUE);
+ centroidDelegate.search(field, target, coarseCollector, acceptDocs);
+ return coarseCollector.topDocs();
+ }
+
+ private void searchFine(
+ SpannFieldEntry entry,
+ float[] target,
+ TopDocs topCentroids,
+ KnnCollector knnCollector,
+ AcceptDocs acceptDocs)
+ throws IOException {
+ IndexInput dataIn = entry.dataIn.clone();
+ for (ScoreDoc centroidDoc : topCentroids.scoreDocs) {
Review Comment:
Can we avoid looking at all the topK centroid postings, and short-circuit if `dist(centroid, target) > nearest_centroid_distance + small_threshold` ?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733123303
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsWriter.java:
##########
@@ -0,0 +1,246 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnFieldVectorsWriter;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsWriter;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentWriteState;
+import org.apache.lucene.index.Sorter;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Writes vectors in the SPANN (HNSW-IVF) format.
+ *
+ * <p>Centroids are computed via K-Means and indexed into an HNSW-based coarse quantizer. Vector
+ * data is assigned to the nearest centroid and written sequentially in a clustered format.
+ */
+public class Lucene99SpannVectorsWriter extends KnnVectorsWriter {
+
+ private static final int KMEANS_MAX_ITERS = 20;
+
+ private final SegmentWriteState state;
+ private final KnnVectorsWriter centroidDelegate;
+ private final Map<String, SpannFieldVectorsWriter> fieldWriters = new HashMap<>();
+ private final int maxPartitions;
+ private final int clusteringSampleSize;
+
+ public Lucene99SpannVectorsWriter(
+ SegmentWriteState state,
+ KnnVectorsFormat centroidFormat,
+ int maxPartitions,
+ int clusteringSampleSize)
+ throws IOException {
+ this.state = state;
+ this.centroidDelegate = centroidFormat.fieldsWriter(state);
+ this.maxPartitions = maxPartitions;
+ this.clusteringSampleSize = clusteringSampleSize;
+ }
+
+ @Override
+ public KnnFieldVectorsWriter<?> addField(FieldInfo fieldInfo) throws IOException {
+ SpannFieldVectorsWriter writer = new SpannFieldVectorsWriter(fieldInfo);
+ fieldWriters.put(fieldInfo.name, writer);
+
+ if (fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ return new KnnFieldVectorsWriter<byte[]>() {
+ @Override
+ public void addValue(int docID, byte[] vectorValue) throws IOException {
+ float[] floats = new float[vectorValue.length];
+ for (int i = 0; i < vectorValue.length; i++) {
+ floats[i] = (float) vectorValue[i];
+ }
+ writer.addValue(docID, floats);
+ }
+
+ @Override
+ public byte[] copyValue(byte[] vectorValue) {
+ return vectorValue.clone();
+ }
+
+ @Override
+ public long ramBytesUsed() {
+ return 0; // The delegate writer tracks usage
+ }
+ };
+ }
+
+ return writer;
+ }
+
+ @Override
+ public void flush(int maxDoc, Sorter.DocMap sortMap) throws IOException {
+
+ for (Map.Entry<String, SpannFieldVectorsWriter> entry : fieldWriters.entrySet()) {
+ String fieldName = entry.getKey();
+ SpannFieldVectorsWriter writer = entry.getValue();
+ FieldInfo fieldInfo = writer.getFieldInfo();
+
+ if (writer.getVectors().isEmpty()) {
+ continue;
+ }
+
+ float[][] vectorArray = writer.getVectors().toArray(new float[0][]);
+
+ // Cap the number of partitions at configured limit (default 100)
+ int numPartitions = Math.min(vectorArray.length, maxPartitions);
+
+ // Downsample to keep flush time constant
Review Comment:
Refactored the reservoir sampling logic out of the Writer and into `SpannKMeans#downsample`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on PR #15613:
URL: https://github.com/apache/lucene/pull/15613#issuecomment-3843331582
> IIUC this doesn't attempt to balance the size of the clusters at all, which is an important property of SPANN because it means that selecting N centroids results in a ~fixed amount of work.
Yeah, the current optimization relies on weighted reclustering to handle distribution changes, but you're right—it doesn't explicitly enforce balancing, so we could see some drift if segments are heavily skewed. I'd prefer to address that in a follow-up if it becomes a real issue (e.g., by adding a splitting pass), rather than touching the merge logic right now.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733152421
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
Review Comment:
On the search side, the code enforces strict type matching. `search()`explicitly checks the field encoding and throws IllegalArgumentException if you try to query a FLOAT32 field with bytes or vice versa.
`searchFine` accepts both arguments solely to deduplicate the traversal implementation; it strictly relies on the segment's encoding to decide which target to use. There is no implicit conversion or quantization.
On the writer side, we lift byte vectors to floats in memory to run K-Means since centroid averaging requires continuous space, then cast back to bytes for the on-disk posting lists. The reader strictness matches the final on-disk format.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733126588
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
+ for (int i = 0; i < target.length; i++) {
+ byteTarget[i] = (byte) target[i];
+ }
+ topCentroids = searchCentroids(field, byteTarget, acceptDocs);
+ } else {
+ topCentroids = searchCentroids(field, target, acceptDocs);
+ }
+
+ searchFine(entry, target, topCentroids, knnCollector, acceptDocs);
+ }
+
+ @Override
+ public void search(String field, byte[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ float[] floatTarget = new float[target.length];
+ for (int i = 0; i < target.length; i++) {
+ floatTarget[i] = (float) target[i];
+ }
Review Comment:
The original loop was casting 8-bit integer dimensions to floats, not reinterpreting a byte stream. However, to eliminate this ambiguity entirely and align with other formats, I’ve removed the mixed-type support.
`search(byte[])` now strictly throws if run against a FLOAT32 index.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733120133
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsReader.java:
##########
@@ -0,0 +1,470 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsReader;
+import org.apache.lucene.index.ByteVectorValues;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FloatVectorValues;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentReadState;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.search.AcceptDocs;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.KnnCollector;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.TopDocs;
+import org.apache.lucene.search.TopKnnCollector;
+import org.apache.lucene.search.VectorScorer;
+import org.apache.lucene.store.IndexInput;
+import org.apache.lucene.util.Bits;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Reader for SPANN (HNSW-IVF) vectors.
+ *
+ * @lucene.experimental
+ */
+public class Lucene99SpannVectorsReader extends KnnVectorsReader {
+
+ private final KnnVectorsReader centroidDelegate;
+ private final Map<String, SpannFieldEntry> fields = new HashMap<>();
+ private final int nprobe;
+
+ public Lucene99SpannVectorsReader(
+ SegmentReadState state, KnnVectorsFormat centroidFormat, int nprobe) throws IOException {
+ this.nprobe = nprobe;
+ this.centroidDelegate = centroidFormat.fieldsReader(state);
+
+ for (FieldInfo fieldInfo : state.fieldInfos) {
+ if (fieldInfo.hasVectorValues()) {
+ String metaFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spam");
+ String dataFileName =
+ IndexFileNames.segmentFileName(
+ state.segmentInfo.name, state.segmentSuffix, fieldInfo.name + ".spad");
+
+ IndexInput metaIn = null;
+ IndexInput dataIn = null;
+ boolean success = false;
+
+ try {
+ // Probe for the "spam" (Meta) file.
+ // If it's missing, this field is likely managed by another codec (e.g., HNSW).
+ // We must catch this to allow mixed-codec segments.
+ metaIn = state.directory.openInput(metaFileName, state.context);
+ } catch (java.nio.file.NoSuchFileException | java.io.FileNotFoundException _) {
+ continue;
+ }
+
+ try {
+ // If we found the meta file, we assume this IS a SPANN field.
+ // Any subsequent missing files (like .spad) or corruption MUST throw an
+ // exception.
+ CodecUtil.checkIndexHeader(
+ metaIn, "Lucene99SpannMeta", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ int totalSize = metaIn.readVInt();
+ Map<Integer, Long> offsets = new HashMap<>();
+ Map<Integer, Long> lengths = new HashMap<>();
+
+ while (metaIn.getFilePointer() < metaIn.length() - CodecUtil.footerLength()) {
+ int partitionId = metaIn.readVInt();
+ long offset = metaIn.readVLong();
+ long length = metaIn.readVLong();
+ offsets.put(partitionId, offset);
+ lengths.put(partitionId, length);
+ }
+
+ dataIn = state.directory.openInput(dataFileName, state.context);
+ CodecUtil.checkIndexHeader(
+ dataIn, "Lucene99SpannData", 0, 0, state.segmentInfo.getId(), state.segmentSuffix);
+
+ // Build ordinal map for random access vectorValue(ord)
+ int[] ordToDocId = new int[totalSize];
+ long[] ordToOffset = new long[totalSize];
+ int currentOrd = 0;
+ IndexInput dataInClone = dataIn.clone();
+ int vectorByteWidth =
+ fieldInfo.getVectorEncoding() == VectorEncoding.BYTE
+ ? fieldInfo.getVectorDimension()
+ : fieldInfo.getVectorDimension() * Float.BYTES;
+
+ for (Integer pId : offsets.keySet()) {
+ long start = offsets.get(pId);
+ long len = lengths.get(pId);
+ dataInClone.seek(start);
+ long end = start + len;
+ while (dataInClone.getFilePointer() < end) {
+ ordToDocId[currentOrd] = dataInClone.readInt();
+ ordToOffset[currentOrd] = dataInClone.getFilePointer();
+ currentOrd++;
+ dataInClone.skipBytes(vectorByteWidth);
+ }
+ }
+
+ fields.put(
+ fieldInfo.name,
+ new SpannFieldEntry(
+ offsets, lengths, dataIn, fieldInfo, totalSize, ordToDocId, ordToOffset));
+ success = true;
+ } finally {
+ if (!success) {
+ IOUtils.closeWhileHandlingException(metaIn, dataIn);
+ } else {
+ IOUtils.closeWhileHandlingException(metaIn);
+ }
+ }
+ }
+ }
+ }
+
+ @Override
+ public void checkIntegrity() throws IOException {
+ centroidDelegate.checkIntegrity();
+
+ for (SpannFieldEntry entry : fields.values()) {
+ // Meta check is done on open
+ CodecUtil.checksumEntireFile(entry.dataIn);
+ }
+ }
+
+ @Override
+ public FloatVectorValues getFloatVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getFloatVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.FLOAT32) {
+ return null;
+ }
+ return new SpannFloatVectorValues(entry);
+ }
+
+ @Override
+ public ByteVectorValues getByteVectorValues(String field) throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ return centroidDelegate.getByteVectorValues(field);
+ }
+ if (entry.fieldInfo.getVectorEncoding() != VectorEncoding.BYTE) {
+ return null;
+ }
+ return new SpannByteVectorValues(entry);
+ }
+
+ @Override
+ public void search(String field, float[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ byte[] byteTarget = new byte[target.length];
+ for (int i = 0; i < target.length; i++) {
+ byteTarget[i] = (byte) target[i];
+ }
+ topCentroids = searchCentroids(field, byteTarget, acceptDocs);
+ } else {
+ topCentroids = searchCentroids(field, target, acceptDocs);
+ }
+
+ searchFine(entry, target, topCentroids, knnCollector, acceptDocs);
+ }
+
+ @Override
+ public void search(String field, byte[] target, KnnCollector knnCollector, AcceptDocs acceptDocs)
+ throws IOException {
+ SpannFieldEntry entry = fields.get(field);
+ if (entry == null) {
+ centroidDelegate.search(field, target, knnCollector, acceptDocs);
+ return;
+ }
+
+ TopDocs topCentroids;
+ float[] floatTarget = new float[target.length];
+ for (int i = 0; i < target.length; i++) {
+ floatTarget[i] = (float) target[i];
+ }
+
+ if (entry.fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ topCentroids = searchCentroids(field, target, acceptDocs);
+ } else {
+ topCentroids = searchCentroids(field, floatTarget, acceptDocs);
+ }
+
+ searchFine(entry, floatTarget, topCentroids, knnCollector, acceptDocs);
+ }
+
+ private TopDocs searchCentroids(String field, float[] target, AcceptDocs acceptDocs)
+ throws IOException {
+ TopKnnCollector coarseCollector = new TopKnnCollector(nprobe, Integer.MAX_VALUE);
+ centroidDelegate.search(field, target, coarseCollector, acceptDocs);
+ return coarseCollector.topDocs();
+ }
+
+ private TopDocs searchCentroids(String field, byte[] target, AcceptDocs acceptDocs)
+ throws IOException {
+ TopKnnCollector coarseCollector = new TopKnnCollector(nprobe, Integer.MAX_VALUE);
+ centroidDelegate.search(field, target, coarseCollector, acceptDocs);
+ return coarseCollector.topDocs();
+ }
+
+ private void searchFine(
+ SpannFieldEntry entry,
+ float[] target,
+ TopDocs topCentroids,
+ KnnCollector knnCollector,
+ AcceptDocs acceptDocs)
+ throws IOException {
+ IndexInput dataIn = entry.dataIn.clone();
+ for (ScoreDoc centroidDoc : topCentroids.scoreDocs) {
Review Comment:
Great catch. I've implemented dynamic pruning in `searchFine`. It now checks if `(bestCentroidScore - currentScore > DYNAMIC_PRUNING_THRESHOLD)` and skip the entire partition if it's significantly worse than the best candidate.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "mikemccand (via GitHub)" <gi...@apache.org>.
mikemccand commented on PR #15613:
URL: https://github.com/apache/lucene/pull/15613#issuecomment-3807449570
> Please share some benchmarks when you have them.
+1 -- luceneutil now supports Cohere v3 1024 dimension vectors, ~40 MM vectors, unit sphere normalized.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on PR #15613:
URL: https://github.com/apache/lucene/pull/15613#issuecomment-3899240459
I finished the 16.41M benchmark on 32GB RAM. SPANN R=1 is the lowest‑latency config observed; recall tops out ~0.847 even with higher NProbe. R=2 fails at full scale due to disk exhaustion. HNSW hits ~0.99 recall but ~2s latency, so not viable for interactive use on this hardware. The recall ceiling is hardware‑bound, not a SPANN limit; higher recall is achievable when replication is feasible (validated at 10M).
Luceneutil summary loglines:
SPANN R=1, 16.41M (NProbe 12/24/48)
SUMMARY: 0.833 46.500 … 16410000 … 64319.01 … SPANN
SUMMARY: 0.844 12.320 … 16410000 … 64319.02 … SPANN
SUMMARY: 0.847 13.400 … 16410000 … 64318.87 … SPANN
SPANN R=2 failure, 16.41M
HNSW, 16.41M
SUMMARY: 0.991 1849.740 … 16410000 … HNSW
SUMMARY: 0.993 2024.140 … 16410000 … HNSW
SUMMARY: 0.996 2076.020 … 16410000 … HNSW
SPANN R=2 success, 10M
SUMMARY: 0.933 60.550 … 10000000 … SPANN
Current work: disk‑efficient SPANN build (partitionId+docId only) + centroid HNSW assignment to reduce scratch space and indexing time, aiming to make R=2 feasible at 16.4M.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris closed pull request #15613: Add SPANN (Disk Resident HNSW-IVF) Implementation
URL: https://github.com/apache/lucene/pull/15613
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733155980
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/Lucene99SpannVectorsWriter.java:
##########
@@ -0,0 +1,246 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.lucene.codecs.CodecUtil;
+import org.apache.lucene.codecs.KnnFieldVectorsWriter;
+import org.apache.lucene.codecs.KnnVectorsFormat;
+import org.apache.lucene.codecs.KnnVectorsWriter;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.IndexFileNames;
+import org.apache.lucene.index.SegmentWriteState;
+import org.apache.lucene.index.Sorter;
+import org.apache.lucene.index.VectorEncoding;
+import org.apache.lucene.index.VectorSimilarityFunction;
+import org.apache.lucene.store.IndexOutput;
+import org.apache.lucene.util.IOUtils;
+
+/**
+ * Writes vectors in the SPANN (HNSW-IVF) format.
+ *
+ * <p>Centroids are computed via K-Means and indexed into an HNSW-based coarse quantizer. Vector
+ * data is assigned to the nearest centroid and written sequentially in a clustered format.
+ */
+public class Lucene99SpannVectorsWriter extends KnnVectorsWriter {
+
+ private static final int KMEANS_MAX_ITERS = 20;
+
+ private final SegmentWriteState state;
+ private final KnnVectorsWriter centroidDelegate;
+ private final Map<String, SpannFieldVectorsWriter> fieldWriters = new HashMap<>();
+ private final int maxPartitions;
+ private final int clusteringSampleSize;
+
+ public Lucene99SpannVectorsWriter(
+ SegmentWriteState state,
+ KnnVectorsFormat centroidFormat,
+ int maxPartitions,
+ int clusteringSampleSize)
+ throws IOException {
+ this.state = state;
+ this.centroidDelegate = centroidFormat.fieldsWriter(state);
+ this.maxPartitions = maxPartitions;
+ this.clusteringSampleSize = clusteringSampleSize;
+ }
+
+ @Override
+ public KnnFieldVectorsWriter<?> addField(FieldInfo fieldInfo) throws IOException {
+ SpannFieldVectorsWriter writer = new SpannFieldVectorsWriter(fieldInfo);
+ fieldWriters.put(fieldInfo.name, writer);
+
+ if (fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ return new KnnFieldVectorsWriter<byte[]>() {
+ @Override
+ public void addValue(int docID, byte[] vectorValue) throws IOException {
+ float[] floats = new float[vectorValue.length];
+ for (int i = 0; i < vectorValue.length; i++) {
+ floats[i] = (float) vectorValue[i];
+ }
+ writer.addValue(docID, floats);
+ }
+
+ @Override
+ public byte[] copyValue(byte[] vectorValue) {
+ return vectorValue.clone();
+ }
+
+ @Override
+ public long ramBytesUsed() {
+ return 0; // The delegate writer tracks usage
+ }
+ };
+ }
+
+ return writer;
+ }
+
+ @Override
+ public void flush(int maxDoc, Sorter.DocMap sortMap) throws IOException {
+
+ for (Map.Entry<String, SpannFieldVectorsWriter> entry : fieldWriters.entrySet()) {
+ String fieldName = entry.getKey();
+ SpannFieldVectorsWriter writer = entry.getValue();
+ FieldInfo fieldInfo = writer.getFieldInfo();
+
+ if (writer.getVectors().isEmpty()) {
+ continue;
+ }
+
+ float[][] vectorArray = writer.getVectors().toArray(new float[0][]);
+
+ // Cap the number of partitions at configured limit (default 100)
+ int numPartitions = Math.min(vectorArray.length, maxPartitions);
+
+ // Downsample to keep flush time constant
+ // Cap at max(clusteringSampleSize, 256 * numPartitions)
+ float[][] trainingVectors = vectorArray;
+ if (vectorArray.length > clusteringSampleSize) {
+ int step = vectorArray.length / clusteringSampleSize;
+ trainingVectors = new float[clusteringSampleSize][];
+ for (int i = 0; i < clusteringSampleSize; i++) {
+ trainingVectors[i] = vectorArray[i * step];
+ }
+ }
+
+ float[][] centroids =
+ SpannKMeans.cluster(
+ trainingVectors,
+ numPartitions,
+ fieldInfo.getVectorSimilarityFunction(),
+ KMEANS_MAX_ITERS);
+
+ if (fieldInfo.getVectorEncoding() == VectorEncoding.BYTE) {
+ @SuppressWarnings("unchecked")
+ KnnFieldVectorsWriter<byte[]> byteCentroidWriter =
+ (KnnFieldVectorsWriter<byte[]>) centroidDelegate.addField(fieldInfo);
+ for (int partitionId = 0; partitionId < centroids.length; partitionId++) {
+ byte[] byteCentroid = new byte[centroids[partitionId].length];
+ for (int k = 0; k < centroids[partitionId].length; k++) {
+ byteCentroid[k] = (byte) centroids[partitionId][k];
+ }
+ byteCentroidWriter.addValue(partitionId, byteCentroid);
+ }
+ } else {
+ @SuppressWarnings("unchecked")
+ KnnFieldVectorsWriter<float[]> floatCentroidWriter =
+ (KnnFieldVectorsWriter<float[]>) centroidDelegate.addField(fieldInfo);
+ for (int partitionId = 0; partitionId < centroids.length; partitionId++) {
+ floatCentroidWriter.addValue(partitionId, centroids[partitionId]);
+ }
+ }
+
+ List<List<Integer>> partitionDocIds = new ArrayList<>(centroids.length);
+ List<List<float[]>> partitions = new ArrayList<>(centroids.length);
+ for (int i = 0; i < centroids.length; i++) {
+ partitions.add(new ArrayList<>());
+ partitionDocIds.add(new ArrayList<>());
+ }
+
+ VectorSimilarityFunction simFunc = fieldInfo.getVectorSimilarityFunction();
+ List<float[]> vectors = writer.getVectors();
+ List<Integer> docIds = writer.getDocIds();
+ for (int i = 0; i < vectors.size(); i++) {
+ float[] vector = vectors.get(i);
+ int docId = docIds.get(i);
+ int bestCentroid = 0;
+ float bestScore = Float.NEGATIVE_INFINITY;
+
+ for (int c = 0; c < centroids.length; c++) {
+ float score = simFunc.compare(vector, centroids[c]);
Review Comment:
Since we only train on a sample of the flush (e.g. 16k vectors), treating the final assignment as a fresh pass over all vectors is simpler and safer than tracking partial pre-assignments. I prefer this simplicity for the initial version but will defer to your advise.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris commented on code in PR #15613:
URL: https://github.com/apache/lucene/pull/15613#discussion_r2733164137
##########
lucene/core/src/java/org/apache/lucene/codecs/spann/SpannFieldVectorsWriter.java:
##########
@@ -0,0 +1,73 @@
+/*
+ * 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.codecs.spann;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.lucene.codecs.KnnFieldVectorsWriter;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.util.RamUsageEstimator;
+
+/**
+ * Buffers vectors in memory until flush.
+ *
+ * <p>Future improvements could include off-heap or disk-backed buffering (e.g. ByteBlockPool) to
Review Comment:
No strong preference here. Will remove them.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "atris (via GitHub)" <gi...@apache.org>.
atris closed pull request #15613: Add SPANN (Disk Resident HNSW-IVF) Implementation
URL: https://github.com/apache/lucene/pull/15613
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org
Re: [PR] Add SPANN (Disk Resident HNSW-IVF) Implementation [lucene]
Posted by "mccullocht (via GitHub)" <gi...@apache.org>.
mccullocht commented on PR #15613:
URL: https://github.com/apache/lucene/pull/15613#issuecomment-3842924253
IIUC this doesn't attempt to balance the size of the clusters at all, which is an important property of SPANN because it means that selecting N centroids results in a ~fixed amount of work.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@lucene.apache.org
For additional commands, e-mail: issues-help@lucene.apache.org