Add a scalar-quantized version of the de-duplicating vector format - #16506
Add a scalar-quantized version of the de-duplicating vector format#16506kaivalnp wants to merge 5 commits into
Conversation
BenchmarksCohere v3 vectors, 100K docs, 10K queries, maxConn=64, beamWidth=250, topK=100, fanout=100, force merged to 1 segment.
This PR Some observations:
|
|
So I understand why recall might drop a bit due to less-optimal quantization (no centering -- is that the only change?), but I'm less clear why we expect an increase in latency when de-duplicating, especially as the increase correlating with reduced quantized bit size. I think I would expect a small increase, perhaps, due to the need for an indirection when looking up ordinals relative to a dense index, but this wouldn't vary with quantization. |
I think this is a combination of two things: the fixed cost per-node for the indirection (off-heap |
msokolov
left a comment
There was a problem hiding this comment.
phew, a lot to cover. I had a few small things
| private static final FlatVectorsScorer FLAT_SCORER = | ||
| FlatVectorScorerUtil.getLucene99FlatVectorsScorer(); | ||
|
|
||
| private final FlatVectorsScorer scorer; |
There was a problem hiding this comment.
since this is a scorer that delegates can we name this the conventional way as either delegate or in?
There was a problem hiding this comment.
Makes sense, changed to delegate!
| this.scorer = scorer; | ||
| } | ||
|
|
||
| /** Resolves the values to score; subclasses may unwrap composite values. */ |
There was a problem hiding this comment.
Can we call it unwrap? I was confused about what this was doing when I saw it used below
There was a problem hiding this comment.
Makes sense, changed!
| * DedupMergeContext} accordingly. | ||
| * | ||
| * <p>Also used by {@link DedupScalarQuantizedVectorsFormat} (with a non-null {@link | ||
| * DedupQuantizer}) to additionally write a quantized copy of each FLOAT32 group, into a separate |
There was a problem hiding this comment.
maybe in future they could be FLOAT16? Should we say each "full-precision" or "unqunatized" group?
There was a problem hiding this comment.
Makes sense, changed each FLOAT32 group -> each applicable group (considering that we may be able to quantize FLOAT16 / BYTE vectors in the future)
| } | ||
|
|
||
| /** Supplies the distinct (raw) float vector at a group ordinal. */ | ||
| interface FloatVectorSupplier { |
There was a problem hiding this comment.
what's the visibility of this? If we later want to quantize Float16 (ie short[]) or even byte[] will we need a breaking change?
There was a problem hiding this comment.
This is package-private. When we enable quantization on float16[], it will likely need new methods inside this class and the de-duplicating vector writer -- but will not be breaking for end-users who can only consume the top-level format (i.e. DedupHnswScalarQuantizedVectorsFormat), which will be unchanged.
| if (flavorOrd < 0 || flavorOrd >= Flavor.values().length) { | ||
| throw new CorruptIndexException("Invalid flavor ordinal: " + flavorOrd, meta); | ||
| } | ||
| Flavor flavor = Flavor.values()[flavorOrd]; |
There was a problem hiding this comment.
Lucene doesn't generally rely on Enum ordinals assigned by the JVM and rather will explicitly define codes to store in the index, that can later be mapped to enums as needed. This enables, for example, later removing support for an ordinal. EG see the way VectorSimlarityFunction is handled in Lucene94FieldInfosFormat, or indeed the way ScalarEncoding is being deserialized here using a custom function (fromWireNumber)
There was a problem hiding this comment.
Makes sense, switched to explicit ordinals!
| OptimizedScalarQuantizer.QuantizationResult corrections) | ||
| throws IOException { | ||
| quantizedVectorData.writeBytes(packed, packed.length); | ||
| quantizedVectorData.writeInt(Float.floatToIntBits(corrections.lowerInterval())); |
There was a problem hiding this comment.
for flavors where the corrections are uniformly zero, can we skip writing them?
There was a problem hiding this comment.
I think the additionalCorrection term is always zero for some flavors (DOT_PRODUCT and NORMALIZED, but not EUCLIDEAN).
Writing the (no-op) correction factor has some benefits: like parity with the non de-duplicating format (allowing re-use of the scorer and vector values classes), and aligning the quantized bytes to multiples of 4 for optimal scoring performance.
There was a problem hiding this comment.
ok, the savings should be pretty small anyway
| void finish(IndexOutput meta, IndexOutput vectorData) throws IOException { | ||
| /** | ||
| * Merges each group's distinct vectors, followed by per-field metadata. When {@code quantizer} is | ||
| * non-null, a quantized copy of each FLOAT32 group is also written and its block location |
There was a problem hiding this comment.
each "full precision" group?
There was a problem hiding this comment.
Makes sense, changed each FLOAT32 group -> each applicable group
| * quantizing. | ||
| * | ||
| * <p>Only {@link org.apache.lucene.index.VectorEncoding#FLOAT32} vectors are quantized; BYTE and | ||
| * FLOAT16 vectors are stored raw only, identical to {@link DedupFlatVectorsFormat}. |
There was a problem hiding this comment.
ah, okay, it's by design. But I think we would eventually want to be able to quantize FLOAT16 too?
There was a problem hiding this comment.
Right, Lucene doesn't support quantizing FLOAT16 vectors today, we'll need to copy whatever the flat format does eventually.
| @Override | ||
| public FieldOrdToGroupOrd copy() { | ||
| return new FieldOrdToGroupOrdArrayList(fieldOrdToGroupOrd); | ||
| throw new UnsupportedOperationException("not meant for copying"); |
There was a problem hiding this comment.
I guess we just never needed this?
There was a problem hiding this comment.
Yes, this is used during flush, and was never copied -- so I threw an exception instead.
| * both raw and quantized form. General de-duplication behavior is covered by {@link | ||
| * TestDedupFlatVectorsFormat}; this test focuses on the quantized side. | ||
| */ | ||
| public class TestDedupScalarQuantizedVectorsFormat extends LuceneTestCase { |
There was a problem hiding this comment.
since we explicitly forbade copy() on some the vector values, let's add a test ensuring that those methods throw Unsupported exception
There was a problem hiding this comment.
I'm not sure about how to reach the #copy method for those classes from public APIs, any suggestions to have a meaningful test? (or do you mean a simple one that creates a new FieldOrdToGroupOrdArrayList and expects an error on calling #copy)
There was a problem hiding this comment.
yeah, I was thinking of a simple unit test
Also add CHANGES.txt entry
| float[] vector = vectors.get(ord); | ||
| if (flavor.normalized()) { | ||
| // normalize a copy: the source buffer is shared / owned by the group | ||
| System.arraycopy(vector, 0, normalized, 0, dimension); |
Add a test to validate that on-heap field ord -> group ord mappings are not supposed to be copied.
|
Thanks for the review @msokolov! I'll merge this in a couple of days unless there are further comments. |
Description
Add a scalar-quantized version of the de-duplicating vector format
DedupHnswScalarQuantizedVectorsFormat(follow-up to #15979), which stores each distinct vector once in both raw and scalar quantized form: searches score against the quantized vectors, while raw vectors remain available for exact reads and re-scoring (same as Lucene 10.4 OSQ).De-duplication works like the raw format: vectors of the same dimension and encoding form a group, and each field maps its document ordinals onto shared group ordinals through a
fieldOrdToGroupOrdtranslation.Files:
.vdd: Raw vectors and per-field data (same as the flat de-duplicating format)..vdqm(metadata): groups with their per-flavor block locations (each block records its ownScalarEncoding), followed by per-field entries. See below for "flavor"..vdqd(quantized records): one block perFLOAT32group and quantization flavor in use, one record per distinct vector (quantized bytes + corrective terms in-line with the Lucene 10.4 OSQ format).Design choices:
EUCLIDEANneeds the squared norm as its corrective term;COSINEneeds write-time normalization).EUCLIDEAN,DOT_PRODUCT+MAXIMUM_INNER_PRODUCT, andCOSINE, so fields whose similarity functions map to the same flavor share records, and every flavor scores through the stockLucene104ScalarQuantizedVectorScorerwith no custom score math.groupOrd → quantizedOrd) off-heap, de-duplicating quantized bytes (hash + equality checks on collision), splitting corrective terms into a separate dense block, and writing temp files + read-back during merge for proper de-duplication.fieldOrdToGroupOrdmap between raw and quantized data and keeps merges streaming, at the cost of occasionally storing byte-identical quantized records for distinct raw vectors.DISCLAIMER: Much of this code was written by an AI, but I have reviewed and refined most of it!