Add a de-duplicating flat vector format#15979
Conversation
|
Important:
This PR As we can imagine, if the same vector is indexed in a second field for 50% of docs, the index size increases by ~50% on Indexing is slightly slower (<5%) + merges are non-trivially slower (up to 33%, currently looking into speeding this up). |
|
It seems like we pay a small penalty for doing the indirected lookup. I wonder if we could save this in the case of the "main" field and only pay it for secondary fields if we change the API a bit so that users can specify a "primary" field that is the union of all the deduped fields? Oh actually I misread the table! It looks as if the deduplicated vectors PR is actually a bit faster?! |
|
How do you think this would combine with all the other vector formats, such as the quantizing ones? I guess we would want to avoid a lot of code copying ... I guess it should be possible to somehow wrap an existing format so we can independently innovate about quantization? Also .. do you see this as becoming the default flat vector format, or do you think users would select it in a custom codec? |
Yeah, this was surprising to me too -- it is ~10% faster, though I'm not sure why (there's one additional lookup of
This is kind of tricky -- the main challenge is that quantization factors can depend on data distribution? (i.e. the same A slightly smaller challenge is that quantization formats today store the quantized For de-duplication to be effective, I guess we'd need to decouple the quantized
IMO it could be the default if the overhead of de-duplication is "small" (personally: something like <10% slower indexing / merge sounds like it would be worth replacing the default). Since it is currently not: perhaps a separate HNSW format backed by the de-duplicating format in a non-core module? |
|
Iterated with the AI to iron out some performance bottlenecks. Disclaimer: There's generous use of AI involved in the code, so functions may be more verbose than required! I have reviewed most of it locally, and will do a refactor to try and make it cleaner / more human friendly soon. Current ApproachSee
Indexing Time Complexity
Query Time Cost
All-in-all, it does not seem too expensive compared to the flat format? |
|
I observed a benchmark issue in the
The To work around this, I simply moved this line to after Can we look at the sum of cc @mikemccand BenchmarksI ran a To benchmark this PR, we need to replace the format here with Note: Before the work-around listed above, The numbers below do include the work-around that considers initial indexing + wait for running merges in
This PR Summary
As a next step, I'll increase the duplication factor: up to TK fields, each containing a (different) proportion |
|
This PR has not had activity in the past 2 weeks, labeling it as stale. If the PR is waiting for review, notify the dev@lucene.apache.org list. Thank you for your contribution! |
078e915 to
6b6b12d
Compare
|
Re-did the changes on top of Common:
This PR: |
There was a problem hiding this comment.
I started to look at this and left some comments, but it's an overwhelming amount of code to review and I have only been able to look at a small amount of it.
One overarching question I have is: how would we use this to support index-time filtering? For example, if I have ten filters I want to encode in the index: say "category (one of a,b,c,d,e)", "popularity (a numeric range filter with a few predetermined ranges)", and maybe some collection of boolean conditions. Then when I index documents, for each one I determine which filters it matches (possibly some filters may be boolean combinations of the above field-value combinations), and for each matching-condition, I maintain a mapping from that condition to a Lucene field, and then I index the document by adding fields with vectors in them for each of the matching conditions? And then I separately use the mapping I maintain (probably modeled as Lucene Query objects?) to field names so that at search time I can do some Query rewriting to replace a set of Query constraints + HNSW matching clause with an HNSW matching clause over a different field (one that incorporates the other constraints)? It just feels weird to explicitly index the same vector multiple times when what I really want is multiple filtered indexes over the same vector. At the same time, if we maintain an API like addVectorField(float[] vector, List filterLabels) then we are free to try out different ways of encoding the multiple filtered HNSW graphs. Do we want to represent as separate graphs? As labels on a single graph?
I think what I'm asking for here is some kind of syntactic sugar/API on top of all this to hide from the user the idea that we are generating multiple fields over the same data? I guess Lucene could maintain a map from "HNSW field f + label a -> HNSW field f_a"?
|
|
||
| /** | ||
| * Flat vector format that stores each unique vector exactly once per segment. Multiple documents | ||
| * (within the same field, and across fields with matching {@code (dimension, encoding)}) may share |
There was a problem hiding this comment.
what if in future we add other distinguishing factors? EG centered and rotated vs not centered and rotated?
There was a problem hiding this comment.
Doesn't using this require that all fields that want to be dedup'd must share the same KnnVectorsWriter instance? In which case if there are variations like that (rotated, centered), all fields will do that the same way?
Edit: also, I realized, encoding here is the input type (byte[] or float[]), not how the format is encoding vectors on disk. Maybe clarify that this is input type (byte/float)? It might clear up this confusion.
There was a problem hiding this comment.
all fields that want to be dedup'd must share the same
KnnVectorsWriter
This is correct -- and is currently left up to the user unfortunately :(
Any ideas to enforce this?
Maybe clarify that this is
input type (byte/float)
Makes sense, I'll try to add this
| * the same physical vector, dramatically reducing on-disk size when many docs/fields point at the | ||
| * same vector data. | ||
| * | ||
| * <p>The format groups fields into "pools" keyed by {@code (dimension, encoding)}. All fields in |
There was a problem hiding this comment.
For me, pool has the connotation of a limited resource that can be reserved and returned. What do you think of "variety"?
There was a problem hiding this comment.
How about organizes fields into groups with the same dimension and input type (byte/float)?
Edit: actually I think pools is OK here? It's used throughout ... and it grows on you (well, me at least) as I see it situ. It reminds me of what ZFS calls "pools" too.
There was a problem hiding this comment.
Makes sense, I'm thinking of "groups"
| * merge ends). | ||
| * | ||
| * <p>When a source reader is itself a {@link DedupFlatVectorsReader}, the merge takes a fast path | ||
| * (Level A): each source vec-ord is interned <em>once</em> per (mergedPool, sourceReader) pair and |
There was a problem hiding this comment.
I think this explanation can be clearer. Are we saying we can avoid vector comparisons for multiple occurrences of the same vector within the same source segment, which we would otherwise (when the source segment is not deduped) have to perform, because we already did the deduping in the source segment, so with this opto, we only need to compare each unique vector with all the other vectors in other segments?
There was a problem hiding this comment.
I think its optimizing the common case (merging N segments, all of which use this same format)?
In which case I would call it "Step 1" instead of "Level A", is to merge the pools, i.e. sourceVecOrd -> mergedVecOrd. Then "Step 2" is nicely just looking up the int -> int remap for the vector ordinals for each doc. Similar to how docid is rewritten during postings merge to map around deletions. Very clean/fast! And the laziness is clever to: we don't copy over a vector from the pool if it doesn't appear in the merged segment (none of the remaining live docs have it).
There was a problem hiding this comment.
Right! I'll try to make comments clearer..
| int p = 0; | ||
| for (Pool pool : orderedPools) { | ||
| meta.writeVInt(pool.key.dim); | ||
| meta.writeByte((byte) pool.key.encoding.ordinal()); |
There was a problem hiding this comment.
if the pools are ordered (and dense, right?) why do we need to write the ordinals?
There was a problem hiding this comment.
The ordinal here is the VectorEncoding and not the pool ordinal.
| // Level-A fast path setup: when a source reader is itself a dedup format reader, attach the | ||
| // source's docOrd→vecOrd table and a lazily-filled srcVecOrd→mergedVecOrd map (shared per | ||
| // (mergedPool, sourceReader)). The doc loop below interns each source vec-ord on first | ||
| // encounter only — uniques whose docs are all deleted are never interned (correctness), |
There was a problem hiding this comment.
what is meant by "(correctness)" here?
There was a problem hiding this comment.
I think it's CC stating the purpose of each statement? Optimization vs functionally necessary? I asked my CC instance to review this PR and it said something hilarious:
AI-authored code is fluent but tends to over-document, over-defend, and sometimes encode invariants in comments rather than asserts. The "Level A" naming, the parenthetical "(perf win)" / "(correctness)" asides, the long discursive Javadocs about why caches exist — these are AI tells. Tighten before committing per Lucene convention (Mike: matches your "no dwelling on implementation details in javadocs" rule).
There was a problem hiding this comment.
Yeah, I mean I think at the very least we should be authoring the comments in human English, or some kind of human language anyway
There was a problem hiding this comment.
This is an artifact of iterating on the AI's original solution, which eagerly merged "pools" across segments, even if some vector was no longer "referenced" by a document -- leading to ever-growing indices.
I asked it to merge lazily, only copying bytes that are referenced -- and some of those inner workings leaked into the comments.
I'll update to avoid this^
|
I'm working through my slow human review process still, but I asked my local CC Opus 4.7 to review the PR. I haven't reviewed its review! Even THAT is time consuming! Though I see it leaks some of my "steering doc" preferences, heh. Not sure I agree with all of it, e.g. the code dup across all Codec impls ... we need to be cautious there (changing the format of Codec B should not accidentally then change the format of Codec A). Humans reviewing AI stuff has become the bottleneck now!
|
I agree the current scope isn't perfect, but it's super hard even just to do this first step (the low level sharing), which it looks like is quite clean here (but I'm not done reviewing!). I'd love to see such sugar, and somehow support for Does the dedup'ing optimize the |
Oh no, I missed this first time around. Yeah this is difficult -- I'd rather keep "indexing time" as it is (after the final commit) because one could rollback at that point (presumably quickly) and have an accurate/intact index. Can we add 2nd time measure, which is time to wait for the in-flight merges? And, I would rather not pay much attention to either merge time in our comparisons here -- merge timing is exceptionally noisy, and has discrete step changes (a force-merge that needs two rounds vs three rounds for example). The only way to compare merge time would be single thread running force merge on same geometry index. Maybe open upstream ( |
mikemccand
left a comment
There was a problem hiding this comment.
Publishing what I have so far! Still working on it! PnP, even on one review of a PR!
Thanks @kaivalnp -- this is a big change, and will be very needly moving (speed and recall) for Lucene users needing accurate filtered vector search when that filter is known (independent of Query) at indexing time! And it looks like you did at least some polish over the raw AI hot take, thanks.
| * same vector data. | ||
| * | ||
| * <p>The format groups fields into "pools" keyed by {@code (dimension, encoding)}. All fields in | ||
| * the same pool share unique-vector storage; per-field metadata records how each doc-ord maps to a |
There was a problem hiding this comment.
Hmm, is encoding the String SPI identifier, or, a specific instance of one?
There was a problem hiding this comment.
Oh! This is VectorEncoding -- byte[] vs float[] input vectors, right? Cool, so this works for both!
| * the same physical vector, dramatically reducing on-disk size when many docs/fields point at the | ||
| * same vector data. | ||
| * | ||
| * <p>The format groups fields into "pools" keyed by {@code (dimension, encoding)}. All fields in |
There was a problem hiding this comment.
How about organizes fields into groups with the same dimension and input type (byte/float)?
Edit: actually I think pools is OK here? It's used throughout ... and it grows on you (well, me at least) as I see it situ. It reminds me of what ZFS calls "pools" too.
|
|
||
| public DedupFlatVectorsReader(SegmentReadState state, FlatVectorsScorer scorer) | ||
| throws IOException { | ||
| this(state, scorer, DataAccessHint.RANDOM); |
There was a problem hiding this comment.
Does this format implement prefetch? Maybe add // TODO and/or open followon once we merge? Since this format could "amplify" the randomness (the deref to original ordinal), prefetch is maybe even more important than the non-dup-detecting formats.
There was a problem hiding this comment.
Yes -- this PR does implement prefetch for vectors; the API asks for vector ordinals which need to be translated to ordinals on disk.
There was a problem hiding this comment.
Although I didn't get why randomness is amplified here?
| private final List<FlushFieldWriter<?>> flushFields = new ArrayList<>(); | ||
|
|
||
| // Pools used during merge. | ||
| private final Map<PoolKey, MergePool> mergePools = new LinkedHashMap<>(); |
There was a problem hiding this comment.
Hmm we also allocate these in the flush case? They just remain empty? Maybe we need to / should factor out into a dedicated merging utility class? Not sure.
There was a problem hiding this comment.
Yeah, maybe we can defer the initialization to the first call? (both merge + flush)
I'll try to factor into a separate class for code cleanliness too.
| int maxVecOrd = Math.max(0, pool.uniqueCount() - 1); | ||
| bitsPerVecOrd = DirectWriter.bitsRequired(Math.max(1, maxVecOrd)); | ||
| DirectWriter writer = DirectWriter.getInstance(vectorData, cardinality, bitsPerVecOrd); | ||
| for (int v : docOrdToVecOrd) { |
There was a problem hiding this comment.
I'm confused why we need two layers here (docOrd)? Some docs might not have a vector, so first layer (today, before this change) is docid -> vectorOrd, right? But with this change, it seems like it should still be that? Or is docOrd maybe a synonym for docid? I'm confused :)
There was a problem hiding this comment.
Inside the vector classes, vectors are identified using packed ordinals (i.e. docOrd) -- which point to monotonically increasing docids (mapped using ordToDoc, which is a OrdToDocDISIReaderConfiguration)
In the plain format, the mapping b/w ordinals -> vectors on disk is 1:1, so we don't need any additional mapping (i.e. a vector on disk was addressable as docOrd * vectorByteSize).
In the dedup format, multiple vectors can point to the same region on disk, so we need an additional docOrd -> vectorOrd mapping. Also, this is not monotonic, so I didn't use the same OrdToDocDISIReaderConfiguration (instead going with DirectWriter)
| // Level-A fast path setup: when a source reader is itself a dedup format reader, attach the | ||
| // source's docOrd→vecOrd table and a lazily-filled srcVecOrd→mergedVecOrd map (shared per | ||
| // (mergedPool, sourceReader)). The doc loop below interns each source vec-ord on first | ||
| // encounter only — uniques whose docs are all deleted are never interned (correctness), |
There was a problem hiding this comment.
I think it's CC stating the purpose of each statement? Optimization vs functionally necessary? I asked my CC instance to review this PR and it said something hilarious:
AI-authored code is fluent but tends to over-document, over-defend, and sometimes encode invariants in comments rather than asserts. The "Level A" naming, the parenthetical "(perf win)" / "(correctness)" asides, the long discursive Javadocs about why caches exist — these are AI tells. Tighten before committing per Lucene convention (Mike: matches your "no dwelling on implementation details in javadocs" rule).
| // source's docOrd→vecOrd table and a lazily-filled srcVecOrd→mergedVecOrd map (shared per | ||
| // (mergedPool, sourceReader)). The doc loop below interns each source vec-ord on first | ||
| // encounter only — uniques whose docs are all deleted are never interned (correctness), | ||
| // and uniques shared by many surviving docs are interned exactly once (perf win). |
There was a problem hiding this comment.
Actually I think this should be considered a correctness issue too, i.e., all dups should be detected and shared? We should never see two different vecOrd pointing to what are actually identical vectors (false negative)? Hopefully we can assert to that effect, and let's e.g. fix tests to exercise this: intentionally create many dup vectors across segments, and then confirm on force-merge we have exactly the correct number.
There was a problem hiding this comment.
And, mix in some deletes too!
There was a problem hiding this comment.
Makes sense, I'll try to add this^
|
This PR has not had activity in the past 2 weeks, labeling it as stale. If the PR is waiting for review, notify the dev@lucene.apache.org list. Thank you for your contribution! |
|
Apologies for the delay, getting back on this PR now! Thanks @msokolov
Sorry, I think the AI was too verbose in the code + comments, I re-wrote this PR from scratch as a human, only relying on AI for tests + some documentation + finding errors -- I hope the next iteration is clearer :)
This makes sense to me -- it is ultimately what we're hoping to do with this change! For this first pass I wanted to add the actual de-duplicating format + make no API changes + test performance of the format more widely -- can we open an issue to add this sugar when the change has baked for a while? (this change also needs a corresponding format for quantized vectors) Thanks @mikemccand (and AI)
It does not -- the current entry point for incoming vectors is here, which is per-field, and there is no explicit guarantee that all fields for a doc will be indexed in sequence, before moving on to the next doc (perhaps the recently added columnar indexing API exercises this). Moreover, there is an expectation that the same array can be re-used to pass vectors (since the writer has to own its copy). For now, I've relied on full equality checks for de-duping.
This was added in mikemccand/luceneutil#587
Makes sense, I'll also report single-threaded indexing numbers from now! The merge path is important to measure for this format because it can involve random IO for de-duplication against a previously written vector, which won't be captured in the indexing path alone.
Yes, there were a few context leaks about my iterations with the AI :) |
6b6b12d to
23ed23a
Compare
BenchmarksSingle-threaded indexingCommon
This PR Multi-threaded indexing with force-merge(same Common values)
This PR Notes
|
Description
Closes #14758
Add a new de-duplicating vector format that only stores unique vectors on disk.
De-duplication is done for vectors across all docs and fields indexed by the format.
Disclaimer: This was mostly written by an AI, with me refining the implementation through prompts -- although I think it did a pretty good job on its own!
Details about the format itself (layout of vectors on disk, de-duplication strategy during flush and merge, performance tradeoffs, etc) are included in a markdown doc in the PR.