Repository navigation
fix: do not mutate a reused vector_index_config when adding a quantizer - #2173
joaquinhuigomez wants to merge 1 commit into
Conversation
`_IndexWrappers.single()` and `.multi()` set the quantizer, the multivector config and the encoding on the caller's object. Two vectors built from one `Configure.VectorIndex.hnsw(...)` therefore ended up holding the same pydantic instance, so a quantizer requested for one vector was also sent for the other, and the caller's own object was mutated as a side effect. That is hard to undo: `_CollectionConfigUpdate` refuses to change a quantizer after the collection has been created, so the collection has to be dropped and recreated. Copy the config objects before touching them, as weaviate#2143 did for `_FilterBase._target_path`. `multi()` copies `multi_vector_config` too, since it assigns the encoding onto it in the same way.
There was a problem hiding this comment.
Orca Security Scan Summary
| Status | Check | Issues by priority | |
|---|---|---|---|
| Infrastructure as Code | View in Orca | ||
| SAST | View in Orca | ||
| Secrets | View in Orca | ||
| Vulnerabilities | View in Orca |
|
To avoid any confusion in the future about your contribution to Weaviate, we work with a Contributor License Agreement. If you agree, you can simply add a comment to this PR that you agree with the CLA so that we can merge. |
chrikrah
left a comment
There was a problem hiding this comment.
@joaquinhuigomez I ran this at 3a24246 and falsified it: revert all three model_copy(deep=True) calls with
your tests kept, and all 4 of TestSharedVectorIndexConfig fail, against 211 passed on your branch. I would
merge it.
The collection that gets created is the sharper half of the case. Reusing one
Configure.VectorIndex.hnsw(ef_construction=128) across two vectors, with a quantizer on the second, sends
pq on both before this diff, so the vector the caller left unquantized is created quantized. The mutated
caller object is the visible symptom; the wrong schema is the cost.
Reconfigure.Vectors.update needs nothing: it passes vector_index_config straight into
VectorConfigUpdate with no quantizer merging, so there is no in-place edit to guard.
non-blocking: the three copies close the create path, and one caller-supplied object is still rewritten
elsewhere. _recompute_target_vector_to_grpc at grpc/shared.py:118 assigns to target_vector.target_vectors
and target_vector.weights, so a TargetVectors.manual_weights({...}) object comes back reordered after a
query. I could not make that one produce a wrong payload, because __check_vector_keys forces the incoming
list to be a permutation of the declared targets, so it is the same class of aliasing with no consequence I
can measure.
Evidence, Python 3.13, editable install plus pytest, at 3a24246:
$ python -m pytest test/collection/test_config.py -q
211 passed, 37 warnings in 0.20s
# all three model_copy calls reverted, your tests kept
4 failed, 207 passed
$ python probe_shared.py # one shared hnsw config, two vectors, quantizer on the second
with the fix: plain {'efConstruction': 128}
reverted: plain {'efConstruction': 128, 'pq': {'enabled': True, 'encoder': {}}}
$ python probe_targets.py # the two assignments from _recompute_target_vector_to_grpc, verbatim
caller object before: ['title', 'body', 'tags'] {'title': 0.7, 'body': 0.3, 'tags': 0.1}
caller object after : ['body', 'tags', 'title'] {'body': 0.3, 'tags': 0.1, 'title': 0.7}
Every claim above came from those runs. I did not run the rest of the suite, and nothing here talks to a
Weaviate instance, so the schema claim is the client's payload and not a server round trip.
@dirkkul, .github/CODEOWNERS matches nothing under weaviate/collections/classes/, and you merged 7 of the
changes touching these files: is the aliasing in grpc/shared.py worth the same treatment, or is the reorder
there deliberate?
|
Thanks for running the reversal — that's the exact check I'd want on it, and the created-collection schema is the better way to state the cost. On |
chrikrah
left a comment
There was a problem hiding this comment.
@joaquinhuigomez non-blocking: the approve stands at 3a24246. The sentence telling you the _recompute_target_vector_to_grpc aliasing had no measurable consequence was mine, and it carried no version caveat. Below 1.29 the rewrite is not a permutation.
add_list_of_vectors falls back to add_1d_vector once per row at shared.py:269-275, and line 118 writes the repeated list back onto your object. The window is 1.27 and 1.28, which test-package still runs the integration suite against at main.yaml:376.
# wv2173_p11.py, against main 38f30c11. q() is _query() from test/collection/test_target_vectors.py:13.
for ver in ("1.28.16", "1.39.0"):
tv = TargetVectors.sum(["a", "b"]) # one object, two searches
r1 = q(ver).near_vector(near_vector={"a": NearVector.list_of_vectors([1.0, 0.0], [0.0, 1.0]),
"b": [0.5, 0.5]}, target_vector=tv).near_vector
r2 = q(ver).near_vector(near_vector=[0.1, 0.2], target_vector=tv).near_vector
mw = TargetVectors.manual_weights({"a": 1.0}) # scalar weight, no reuse at all
r3 = q(ver).near_vector(near_vector={"a": NearVector.list_of_vectors([1.0, 0.0], [0.0, 1.0])},
target_vector=mw).near_vector$ PYTHONPATH=<main 38f30c11> python wv2173_p11.py # Python 3.12.3, weaviate-client 4.23.1 deps
shared.py: .../wv_main/weaviate/collections/grpc/shared.py
1.28.16 call 1 ['a', 'a', 'b'] | tv after ['a', 'a', 'b'] | call 2 ['a', 'a', 'b']
1.28.16 manual_weights first call: targets ['a'] against 2 VectorForTarget
1.39.0 call 1 ['a', 'b'] | tv after ['a', 'b'] | call 2 ['a', 'b']
1.39.0 manual_weights first call: targets ['a'] against 1 VectorForTarget
# not run: a live server. This reads the built NearVector message only.
A plain 1-D vector takes the fast path at shared.py:402 with no recompute, which is how the second search asks a 1.28 server to sum a twice.
The manual_weights line is a separate defect and the .copy() does not reach it: shared.py:121 dedupes the repeat out of the weights dict, classes/grpc.py:853-861 rebuilds the target list from that dict, and one target leaves against two VectorForTarget on the first call with no reuse anywhere. test_target_vectors.py:84 asserts the opposite for the list-weight neighbour.
@g-despot the approve holds either way: does the .copy() joaquinhuigomez offered belong in this diff, or in its own against the sub-1.29 path? dirkkul was the name suggested here, though __check_vector_keys is yours.
Reusing one
Configure.VectorIndex.hnsw(...)object across severalConfigure.Vectors.*calls leaks the quantizer requested for one vector onto all of them:renders
pqon bothcompressedandraw, andtuneditself is mutated._IndexWrappers.singleand.multiassign the quantizer (and, for multi-vectors, the encoding) directly onto the caller's object, so every vector built from the same variable shares one pydantic instance. It is silent, and because a quantizer cannot be changed after creation (__check_quantizersrefuses), the collection has to be recreated. Same class of aliasing that #2143 fixed in_FilterBase._target_path.Both wrappers now deep-copy
vector_index_configbefore touching it;multi()also copiesmulti_vector_config, which had the identical problem withencoding. Four tests cover HNSW, dynamic (the quantizer fans out to bothhnswandflat), multi-vector, and a shared multi-vector config — each asserts the unquantized vector stays clean and the caller's object is unchanged. All fail onmain.test/collection/test_config.py211 passed; ruff, flake8 and pyright clean.