Skip to content

fix: do not mutate a reused vector_index_config when adding a quantizer - #2173

Open
joaquinhuigomez wants to merge 1 commit into
weaviate:mainfrom
joaquinhuigomez:fix/vector-index-config-not-shared
Open

joaquinhuigomez wants to merge 1 commit into
weaviate:mainfrom
joaquinhuigomez:fix/vector-index-config-not-shared

Conversation

@joaquinhuigomez

Copy link
Copy Markdown

Reusing one Configure.VectorIndex.hnsw(...) object across several Configure.Vectors.* calls leaks the quantizer requested for one vector onto all of them:

tuned = Configure.VectorIndex.hnsw(ef_construction=256, max_connections=64)
Configure.Vectors.self_provided(name="compressed", vector_index_config=tuned,
                                quantizer=Configure.VectorIndex.Quantizer.pq(segments=96))
Configure.Vectors.self_provided(name="raw", vector_index_config=tuned)

renders pq on both compressed and raw, and tuned itself is mutated. _IndexWrappers.single and .multi assign 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_quantizers refuses), the collection has to be recreated. Same class of aliasing that #2143 fixed in _FilterBase._target_path.

Both wrappers now deep-copy vector_index_config before touching it; multi() also copies multi_vector_config, which had the identical problem with encoding. Four tests cover HNSW, dynamic (the quantizer fans out to both hnsw and flat), multi-vector, and a shared multi-vector config — each asserts the unquantized vector stays clean and the caller's object is unchanged. All fail on main. test/collection/test_config.py 211 passed; ruff, flake8 and pyright clean.

`_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.

@orca-security-eu orca-security-eu Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orca Security Scan Summary

Status Check Issues by priority
Passed Passed Infrastructure as Code high 0   medium 0   low 0   info 0 View in Orca
Passed Passed SAST high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Secrets high 0   medium 0   low 0   info 0 View in Orca
Passed Passed Vulnerabilities high 0   medium 0   low 0   info 0 View in Orca

@weaviate-git-bot

Copy link
Copy Markdown

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.

beep boop - the Weaviate bot 👋🤖

PS:
Are you already a member of the Weaviate Forum?

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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?

@joaquinhuigomez

Copy link
Copy Markdown
Author

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 _recompute_target_vector_to_grpc: I'd looked at that one too and came to the same place. The reorder is visible on the caller's TargetVectors object, but since __check_vector_keys forces the incoming list to be a permutation of the declared targets, the rebuilt order and weights stay semantically equivalent and I couldn't get it to emit a wrong payload either. It's the same aliasing shape with no consequence I can measure, so I left it out rather than widen this diff. If @dirkkul would rather the query path not rewrite the caller's object at all, a .copy() there is a trivial follow-up and I'm happy to send it.

@chrikrah chrikrah left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants