diff --git a/test/collection/test_config.py b/test/collection/test_config.py index 360a0d138..fd35a1a41 100644 --- a/test/collection/test_config.py +++ b/test/collection/test_config.py @@ -3529,3 +3529,98 @@ def test_reconfigure_inverted_index_without_stopword_presets_leaves_existing(sel } merged = rc.merge_with_existing(existing) assert merged["stopwordPresets"] == {"fr": ["le", "la"]} + + +class TestSharedVectorIndexConfig: + """A config object reused for several vectors must not be shared or mutated.""" + + def test_quantizer_does_not_leak_between_vectors(self) -> None: + shared = Configure.VectorIndex.hnsw(ef_construction=128) + config = _CollectionConfigCreate( + name="test", + vector_config=[ + Configure.Vectors.self_provided(name="plain", vector_index_config=shared), + Configure.Vectors.self_provided( + name="quantized", + vector_index_config=shared, + quantizer=Configure.VectorIndex.Quantizer.pq(), + ), + ], + ) + + vectors = config._to_dict()["vectorConfig"] + assert vectors["plain"]["vectorIndexConfig"] == {"efConstruction": 128} + assert vectors["quantized"]["vectorIndexConfig"] == { + "efConstruction": 128, + "pq": {"enabled": True, "encoder": {}}, + } + assert shared._to_dict() == {"efConstruction": 128}, "the caller's object was mutated" + + def test_quantizer_does_not_leak_between_vectors_with_dynamic_index(self) -> None: + shared = Configure.VectorIndex.dynamic() + config = _CollectionConfigCreate( + name="test", + vector_config=[ + Configure.Vectors.self_provided(name="plain", vector_index_config=shared), + Configure.Vectors.self_provided( + name="quantized", + vector_index_config=shared, + quantizer=Configure.VectorIndex.Quantizer.bq(), + ), + ], + ) + + vectors = config._to_dict()["vectorConfig"] + assert vectors["plain"]["vectorIndexConfig"] == {} + assert vectors["quantized"]["vectorIndexConfig"] == { + "hnsw": {"bq": {"enabled": True}}, + "flat": {"bq": {"enabled": True}}, + } + assert shared._to_dict() == {}, "the caller's object was mutated" + + def test_quantizer_does_not_leak_between_multi_vectors(self) -> None: + shared = Configure.VectorIndex.hnsw(ef_construction=128) + config = _CollectionConfigCreate( + name="test", + vector_config=[ + Configure.MultiVectors.self_provided(name="plain", vector_index_config=shared), + Configure.MultiVectors.self_provided( + name="quantized", + vector_index_config=shared, + quantizer=Configure.VectorIndex.Quantizer.pq(), + ), + ], + ) + + vectors = config._to_dict()["vectorConfig"] + assert vectors["plain"]["vectorIndexConfig"] == { + "efConstruction": 128, + "multivector": {"enabled": True}, + } + assert vectors["quantized"]["vectorIndexConfig"] == { + "efConstruction": 128, + "multivector": {"enabled": True}, + "pq": {"enabled": True, "encoder": {}}, + } + assert shared._to_dict() == {"efConstruction": 128}, "the caller's object was mutated" + + def test_encoding_does_not_leak_between_multi_vectors(self) -> None: + shared = Configure.VectorIndex.MultiVector.multi_vector() + config = _CollectionConfigCreate( + name="test", + vector_config=[ + Configure.MultiVectors.self_provided(name="plain", multi_vector_config=shared), + Configure.MultiVectors.self_provided( + name="muvera", + multi_vector_config=shared, + encoding=Configure.VectorIndex.MultiVector.Encoding.muvera(), + ), + ], + ) + + vectors = config._to_dict()["vectorConfig"] + assert vectors["plain"]["vectorIndexConfig"] == {"multivector": {"enabled": True}} + assert vectors["muvera"]["vectorIndexConfig"] == { + "multivector": {"enabled": True, "muvera": {"enabled": True}} + } + assert shared.encoding is None, "the caller's object was mutated" diff --git a/weaviate/collections/classes/config_vectors.py b/weaviate/collections/classes/config_vectors.py index edf1c3b89..477b36b44 100644 --- a/weaviate/collections/classes/config_vectors.py +++ b/weaviate/collections/classes/config_vectors.py @@ -158,6 +158,9 @@ def single( vector_index_config: Optional[VectorIndexConfigCreate], quantizer: Optional[_QuantizerConfigCreate], ) -> Optional[VectorIndexConfigCreate]: + # copy so that a config object reused for several vectors is never mutated in place + if vector_index_config is not None: + vector_index_config = vector_index_config.model_copy(deep=True) if quantizer is not None: if vector_index_config is None: vector_index_config = _IndexWrappers.__hnsw(quantizer=quantizer) @@ -182,8 +185,13 @@ def multi( multi_vector_config: Optional[_MultiVectorConfigCreate], encoding: Optional[_MultiVectorEncodingConfigCreate], ) -> Optional[VectorIndexConfigCreate]: + # copy so that config objects reused for several vectors are never mutated in place + if vector_index_config is not None: + vector_index_config = vector_index_config.model_copy(deep=True) if multi_vector_config is None: multi_vector_config = _MultiVectorConfigCreate(aggregation=None, encoding=None) + else: + multi_vector_config = multi_vector_config.model_copy(deep=True) if encoding is not None: multi_vector_config.encoding = encoding if vector_index_config is None: