From 532b4e658793cb0bf6a2b1460b741aa74294bfeb Mon Sep 17 00:00:00 2001 From: Marcin Antas Date: Mon, 23 Mar 2026 14:47:43 +0100 Subject: [PATCH 1/8] feat: add support for alter schema drop vector index --- weaviate/collections/config/async_.pyi | 1 + weaviate/collections/config/executor.py | 38 +++++++++++++++++++++++++ weaviate/collections/config/sync.pyi | 1 + 3 files changed, 40 insertions(+) diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 015b70dab..91139dd83 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -90,3 +90,4 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... async def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... + async def delete_vector_index(self, vector_name: str) -> bool: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 103ab70ac..ebfcf7ad0 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -666,3 +666,41 @@ def resp(res: Response) -> bool: error_msg="Property may not exist", status_codes=_ExpectedStatusCodes(ok_in=[200], error="property exists"), ) + + def delete_vector_index( + self, + vector_name: str, + ) -> executor.Result[bool]: + """Delete a vector index from the collection in Weaviate. + + This is a destructive operation. The index will + need to be regenerated if you wish to use it again. + + Args: + vector_name: The name of the vector whose index to delete. + + Raises: + weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. + weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. + weaviate.exceptions.WeaviateInvalidInputError: If the vector does not exist. + """ + _validate_input( + [_ValidateArgument(expected=[str], name="vector_name", value=vector_name)] + ) + + path = ( + f"/schema/{_capitalize_first_letter(self._name)}" + + f"/vectors/{vector_name}" + + "/index" + ) + + def resp(res: Response) -> bool: + return res.status_code == 200 + + return executor.execute( + response_callback=resp, + method=self._connection.delete, + path=path, + error_msg="Vector may not exist", + status_codes=_ExpectedStatusCodes(ok_in=[200], error="vector exists"), + ) diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index e54d8c8fc..7bd450819 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -88,3 +88,4 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... + def delete_vector_index(self, vector_name: str) -> bool: ... From 8cea0c94951c7ba3ae0e64ab6a63731a36c94fcc Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Wed, 22 Jul 2026 13:23:16 +0200 Subject: [PATCH 2/8] fix: make dropped vector indices representable in the client Follow-up to the `delete_vector_index` support, addressing review findings. After a successful drop, Weaviate keeps the vector in the schema as `vectorIndexType: "none"` with no `vectorIndexConfig`. The client asserted that every named vector has an index config, so `collection.config.get()` and `client.collections.list_all()` raised `AssertionError` for every collection in the cluster once any vector index had been dropped. `_NamedVectorConfig. vector_index_config` is now optional, `VectorIndexType` gained a server-reported `NONE` member and `to_dict()` round-trips it. `collection.config.update()` on a dropped vector raised a bare `KeyError: 'vectorIndexConfig'` from the schema merge. Both the current and the deprecated merge paths now go through one helper that raises a `WeaviateInvalidInputError` explaining that a dropped index cannot be re-created. The docstring claimed the index could be regenerated and that a missing vector raises `WeaviateInvalidInputError`. Neither is true: Weaviate rejects re-creating a dropped index, and an unknown vector name comes back as a 422. It now also documents that the endpoint is experimental and needs `ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true`, that only named vectors can be dropped, and that the drop is applied asynchronously. The error message no longer blames a missing vector for what is usually a disabled endpoint. Tests: unit coverage for parsing, exporting and updating a dropped vector, mock coverage for the request path and the disabled-endpoint response, and integration coverage gated at 1.39.0. The CI compose file enables the experimental endpoint; that flag can be dropped once 1.39.0 is GA. Co-Authored-By: Claude Opus 4.8 (1M context) --- ci/docker-compose.yml | 3 + integration/test_collection_config.py | 79 ++++++++++++++++- mock_tests/test_collection.py | 40 +++++++++ test/collection/test_config_methods.py | 85 +++++++++++++++++++ test/collection/test_config_update.py | 45 ++++++++++ weaviate/collections/classes/config.py | 53 +++++++----- .../collections/classes/config_methods.py | 7 +- .../classes/config_vector_index.py | 4 + weaviate/collections/config/executor.py | 38 +++++---- 9 files changed, 313 insertions(+), 41 deletions(-) diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index ddc92d0ed..9c5a86c88 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -33,6 +33,9 @@ services: OBJECTS_TTL_DELETE_SCHEDULE: "@every 12h" # for objectTTL tests to work EXPORT_ENABLED: 'true' EXPORT_DEFAULT_PATH: "/var/lib/weaviate/exports" + # for config.delete_vector_index tests to work. Can be dropped once 1.39.0 is GA, the endpoint + # is enabled by default from then on. Ignored by servers that do not know the flag. + ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT: 'true' contextionary: environment: diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index b1bd30e12..d961651c9 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -1,5 +1,6 @@ import datetime -from typing import Generator, List, Optional, Union +import time +from typing import Any, Dict, Generator, List, Optional, Union import pytest as pytest from _pytest.fixtures import SubRequest @@ -11,6 +12,7 @@ OpenAICollection, _sanitize_collection_name, ) +from weaviate.collections import Collection from weaviate.collections.classes.config import ( _BQConfig, _CollectionConfig, @@ -37,6 +39,7 @@ Rerankers, _RerankerProvider, Tokenization, + _NamedVectorConfig, _NamedVectorConfigCreate, _VectorizerConfigCreate, IndexName, @@ -2694,3 +2697,77 @@ def test_text_analyzer_roundtrip_from_dict( assert config == new assert config.to_dict() == new.to_dict() client.collections.delete(name) + + +def _vector_config_without_index( + collection: Collection[Any, Any], vector_name: str, timeout: float = 30 +) -> Dict[str, _NamedVectorConfig]: + """Poll the collection config until `vector_name` no longer has an index. + + The drop is applied asynchronously, a 200 from the endpoint only means that Weaviate accepted + the request. It then becomes visible in two steps: the vector first stays in the schema with a + `vector_index_config` of `None`, and once the index is gone from disk the entry is removed from + the schema altogether. Both shapes must parse, so accept either. + """ + start = time.time() + while True: + vector_config = collection.config.get().vector_config + assert vector_config is not None + if ( + vector_name not in vector_config + or vector_config[vector_name].vector_index_config is None + ): + return vector_config + if time.time() - start > timeout: + pytest.fail(f"vector index of {vector_name} was not dropped within {timeout}s") + time.sleep(0.2) + + +def test_delete_vector_index(collection_factory: CollectionFactory) -> None: + """Test that dropping the index of a named vector leaves the rest of the collection usable.""" + collection_dummy = collection_factory("dummy") + if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0): + pytest.skip("delete vector index not supported before 1.39.0") + + collection = collection_factory( + properties=[Property(name="name", data_type=DataType.TEXT)], + vector_config=[ + Configure.Vectors.self_provided(name="dropped"), + Configure.Vectors.self_provided(name="kept"), + ], + ) + collection.data.insert( + properties={"name": "banana"}, + vector={"dropped": [1, 2], "kept": [3, 4]}, + ) + + config = collection.config.get() + assert config.vector_config is not None + assert config.vector_config["dropped"].vector_index_config is not None + + with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError): + collection.config.delete_vector_index("does_not_exist") + + assert collection.config.delete_vector_index("dropped") is True + + vector_config = _vector_config_without_index(collection, "dropped") + # vectors that were not dropped keep their index + assert vector_config["kept"].vector_index_config is not None + + # `list_all` parses the schema through the simple config parser, it must cope with the drop too + with weaviate.connect_to_local() as client: + simple = client.collections.list_all()[collection.name] + assert simple.vector_config is not None + assert simple.vector_config["kept"].vector_index_config is not None + + # searching the vector that still has an index keeps working + assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1 + + +def test_delete_vector_index_invalid_input(collection_factory: CollectionFactory) -> None: + """Test that a non-string vector name is rejected before hitting the network.""" + collection = collection_factory( + vector_config=[Configure.Vectors.self_provided(name="vec")], + ) + with pytest.raises(WeaviateInvalidInputError): + collection.config.delete_vector_index(42) # type: ignore[arg-type] diff --git a/mock_tests/test_collection.py b/mock_tests/test_collection.py index 7769e23e0..2740b0cf4 100644 --- a/mock_tests/test_collection.py +++ b/mock_tests/test_collection.py @@ -504,6 +504,46 @@ async def test_async_collection_exists(weaviate_mock: HTTPServer) -> None: assert e.value.status_code == 500 +def test_delete_vector_index(weaviate_mock: HTTPServer) -> None: + # the collection name is capitalized by the client before it hits the path + weaviate_mock.expect_request( + "/v1/schema/Test/vectors/vec/index", method="DELETE" + ).respond_with_json(response_json={}, status=200) + + with weaviate.connect_to_local( + port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True + ) as client: + assert client.collections.use("test").config.delete_vector_index("vec") + + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type] + + +def test_delete_vector_index_endpoint_disabled(weaviate_mock: HTTPServer) -> None: + # servers without ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true answer 500 + weaviate_mock.expect_request( + "/v1/schema/Test/vectors/vec/index", method="DELETE" + ).respond_with_json( + response_json={ + "error": [ + { + "message": "alter schema drop vector index endpoint is experimental and disabled by default" + } + ] + }, + status=500, + ) + + with weaviate.connect_to_local( + port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True + ) as client: + with pytest.raises(UnexpectedStatusCodeError) as e: + client.collections.use("Test").config.delete_vector_index("vec") + assert e.value.status_code == 500 + # the error message must not claim the vector is missing, the endpoint is simply off + assert "experimental and disabled by default" in e.value.message + + def test_grpc_client_version_header( metadata_capture_collection: tuple[ weaviate.collections.Collection, MockMetadataCaptureWeaviateService diff --git a/test/collection/test_config_methods.py b/test/collection/test_config_methods.py index 2e40acacc..ad5543daf 100644 --- a/test/collection/test_config_methods.py +++ b/test/collection/test_config_methods.py @@ -1,10 +1,95 @@ +from typing import Any, Dict + +from weaviate.collections.classes.config import VectorIndexType from weaviate.collections.classes.config_methods import ( _collection_config_from_json, + _collection_config_simple_from_json, _collection_configs_simple_from_json, _nested_properties_from_config, _properties_from_config, ) +HNSW_CONFIG = { + "skip": False, + "cleanupIntervalSeconds": 300, + "maxConnections": 64, + "efConstruction": 128, + "ef": -1, + "dynamicEfMin": 100, + "dynamicEfMax": 500, + "dynamicEfFactor": 8, + "vectorCacheMaxObjects": 1000000000000, + "flatSearchCutoff": 40000, + "distance": "cosine", +} + + +def _schema_with_vector_config(vector_config: Dict[str, Any]) -> Dict[str, Any]: + """Build a minimal collection schema, as returned by Weaviate, around the given vectorConfig.""" + return { + "class": "TestCollection", + "vectorConfig": vector_config, + "properties": [], + "invertedIndexConfig": { + "bm25": {"b": 0.75, "k1": 1.2}, + "cleanupIntervalSeconds": 60, + "stopwords": {"preset": "en", "additions": None, "removals": None}, + }, + "multiTenancyConfig": {"enabled": False}, + "replicationConfig": {"factor": 1, "deletionStrategy": "NoAutomatedResolution"}, + "shardingConfig": { + "virtualPerPhysical": 128, + "desiredCount": 1, + "actualCount": 1, + "desiredVirtualCount": 128, + "actualVirtualCount": 128, + "key": "_id", + "strategy": "hash", + "function": "murmur3", + }, + } + + +def test_collection_config_from_json_with_dropped_vector_index() -> None: + """A vector whose index was dropped is returned without a vectorIndexConfig.""" + # Shape returned by Weaviate after `collection.config.delete_vector_index("dropped")`: + # the entry stays in the schema, `vectorIndexType` becomes "none" and `vectorIndexConfig` + # is omitted entirely. + schema = _schema_with_vector_config( + { + "dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"}, + "kept": { + "vectorizer": {"none": {}}, + "vectorIndexType": "hnsw", + "vectorIndexConfig": HNSW_CONFIG, + }, + } + ) + + config = _collection_config_from_json(schema) + + assert config.vector_config is not None + assert config.vector_config["dropped"].vector_index_config is None + assert config.vector_config["kept"].vector_index_config is not None + + # The dropped vector must round-trip back to the "none" index type the server reported. + as_dict = config.to_dict() + assert as_dict["vectorConfig"]["dropped"]["vectorIndexType"] == VectorIndexType.NONE.value + assert "vectorIndexConfig" not in as_dict["vectorConfig"]["dropped"] + assert as_dict["vectorConfig"]["kept"]["vectorIndexType"] == VectorIndexType.HNSW.value + + +def test_collection_config_simple_from_json_with_dropped_vector_index() -> None: + """`collections.list_all()` must not choke on a collection with a dropped vector index.""" + schema = _schema_with_vector_config( + {"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"}} + ) + + config = _collection_config_simple_from_json(schema) + + assert config.vector_config is not None + assert config.vector_config["dropped"].vector_index_config is None + def test_collection_config_simple_from_json_with_none_vectorizer_config() -> None: """Test that _collection_configs_simple_from_json handles None vectorizer config.""" diff --git a/test/collection/test_config_update.py b/test/collection/test_config_update.py index 066d4ecb3..19b7aaedd 100644 --- a/test/collection/test_config_update.py +++ b/test/collection/test_config_update.py @@ -230,3 +230,48 @@ def test_switching_quantizer_still_rejected_when_pq_enabled() -> None: ) with pytest.raises(WeaviateInvalidInputError): update.merge_with_existing(schema) + + +@pytest.mark.parametrize("use_deprecated_syntax", [False, True]) +def test_updating_dropped_vector_index(use_deprecated_syntax: bool) -> None: + """A vector whose index was dropped has no index config to merge into.""" + schema = multi_vector_schema() + # shape reported by Weaviate for a vector dropped via `config.delete_vector_index` + schema["vectorConfig"]["boi"] = {"vectorizer": {"none": {}}, "vectorIndexType": "none"} + + hnsw = Reconfigure.VectorIndex.hnsw(ef=128) + update = ( + _CollectionConfigUpdate( + vectorizer_config=[ + Reconfigure.NamedVectors.update(name="boi", vector_index_config=hnsw) + ] + ) + if use_deprecated_syntax + else _CollectionConfigUpdate( + vector_config=[Reconfigure.Vectors.update(name="boi", vector_index_config=hnsw)] + ) + ) + + with pytest.raises(WeaviateInvalidInputError, match="delete_vector_index"): + update.merge_with_existing(schema) + + +def test_updating_vector_next_to_dropped_vector_index() -> None: + """Vectors that still have an index remain updatable next to a dropped one.""" + schema = multi_vector_schema() + schema["vectorConfig"]["boi"] = {"vectorizer": {"none": {}}, "vectorIndexType": "none"} + + update = _CollectionConfigUpdate( + vector_config=[ + Reconfigure.Vectors.update( + name="yeh", vector_index_config=Reconfigure.VectorIndex.hnsw(ef=128) + ) + ] + ) + new_schema = update.merge_with_existing(schema) + + assert new_schema["vectorConfig"]["yeh"]["vectorIndexConfig"]["ef"] == 128 + assert new_schema["vectorConfig"]["boi"] == { + "vectorizer": {"none": {}}, + "vectorIndexType": "none", + } diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index e9effaf15..282ad6d1b 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -1546,6 +1546,22 @@ def mutual_exclusivity( ) return v + @staticmethod + def __existing_vector_index_config(schema: Dict[str, Any], name: str) -> Dict[str, Any]: + if name not in schema["vectorConfig"]: + raise WeaviateInvalidInputError( + f"Vector config with name {name} does not exist in the existing vector config" + ) + existing = schema["vectorConfig"][name] + if "vectorIndexConfig" not in existing: + # the index was dropped with `collection.config.delete_vector_index`, Weaviate reports + # such a vector as `vectorIndexType: "none"` without any index config to merge into + raise WeaviateInvalidInputError( + f"Vector config with name {name} has no vector index, it was deleted with " + "collection.config.delete_vector_index() and cannot be re-created" + ) + return cast(Dict[str, Any], existing["vectorIndexConfig"]) + def __check_quantizers( self, quantizer: Optional[_QuantizerConfigUpdate], @@ -1658,18 +1674,10 @@ def merge_with_existing(self, schema: Dict[str, Any]) -> Dict[str, Any]: ) else: for vc in self.vectorizerConfig: - if vc.name not in schema["vectorConfig"]: - raise WeaviateInvalidInputError( - f"Vector config with name {vc.name} does not exist in the existing vector config" - ) - self.__check_quantizers( - vc.vectorIndexConfig.quantizer, - schema["vectorConfig"][vc.name]["vectorIndexConfig"], - ) + existing = self.__existing_vector_index_config(schema, vc.name) + self.__check_quantizers(vc.vectorIndexConfig.quantizer, existing) schema["vectorConfig"][vc.name]["vectorIndexConfig"] = ( - vc.vectorIndexConfig.merge_with_existing( - schema["vectorConfig"][vc.name]["vectorIndexConfig"] - ) + vc.vectorIndexConfig.merge_with_existing(existing) ) schema["vectorConfig"][vc.name]["vectorIndexType"] = ( vc.vectorIndexConfig.vector_index_type() @@ -1681,18 +1689,10 @@ def merge_with_existing(self, schema: Dict[str, Any]) -> Dict[str, Any]: else self.vectorConfig ) for vc in vcs: - if vc.name not in schema["vectorConfig"]: - raise WeaviateInvalidInputError( - f"Vector config with name {vc.name} does not exist in the existing vector config" - ) - self.__check_quantizers( - vc.vectorIndexConfig.quantizer, - schema["vectorConfig"][vc.name]["vectorIndexConfig"], - ) + existing = self.__existing_vector_index_config(schema, vc.name) + self.__check_quantizers(vc.vectorIndexConfig.quantizer, existing) schema["vectorConfig"][vc.name]["vectorIndexConfig"] = ( - vc.vectorIndexConfig.merge_with_existing( - schema["vectorConfig"][vc.name]["vectorIndexConfig"] - ) + vc.vectorIndexConfig.merge_with_existing(existing) ) schema["vectorConfig"][vc.name]["vectorIndexType"] = ( vc.vectorIndexConfig.vector_index_type() @@ -2142,16 +2142,23 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class _NamedVectorConfig(_ConfigBase): vectorizer: _NamedVectorizerConfig + # `None` means the index of this vector was dropped with `collection.config.delete_vector_index`. + # The vector data is still stored, but there is no index to configure or search. vector_index_config: Union[ VectorIndexConfigHNSW, VectorIndexConfigFlat, VectorIndexConfigDynamic, VectorIndexConfigHFresh, + None, ] def to_dict(self) -> Dict: ret_dict = super().to_dict() - ret_dict["vectorIndexType"] = self.vector_index_config.vector_index_type() + ret_dict["vectorIndexType"] = ( + VectorIndexType.NONE.value + if self.vector_index_config is None + else self.vector_index_config.vector_index_type() + ) return ret_dict diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index 691cf208d..a67b62956 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -283,7 +283,12 @@ def __get_vector_config( props = vec_config.pop("properties", None) vector_index_config = __get_vector_index_config(named_vector) - assert vector_index_config is not None + # A vector whose index was dropped with `collection.config.delete_vector_index` is + # returned as `vectorIndexType: "none"` without any `vectorIndexConfig`. + assert ( + vector_index_config is not None + or named_vector.get("vectorIndexType") == VectorIndexType.NONE.value + ) try: vec: Union[str, Vectorizers] = Vectorizers(vectorizer_str) except ValueError: diff --git a/weaviate/collections/classes/config_vector_index.py b/weaviate/collections/classes/config_vector_index.py index ff6a0ba40..fe0166558 100644 --- a/weaviate/collections/classes/config_vector_index.py +++ b/weaviate/collections/classes/config_vector_index.py @@ -36,12 +36,16 @@ class VectorIndexType(str, Enum): FLAT: Flat index. DYNAMIC: Dynamic index. HFRESH: HFRESH index. + NONE: The index of this vector has been dropped, see ``collection.config.delete_vector_index``. + The vector data is still stored, but it cannot be searched. This value is reported by the + server only, it cannot be used to configure a vector. """ HNSW = "hnsw" FLAT = "flat" DYNAMIC = "dynamic" HFRESH = "hfresh" + NONE = "none" class _MultiVectorConfigCreateBase(_ConfigCreateModel): diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index ebfcf7ad0..b1e7f57bc 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -671,28 +671,34 @@ def delete_vector_index( self, vector_name: str, ) -> executor.Result[bool]: - """Delete a vector index from the collection in Weaviate. + """Delete the index of a named vector of the collection in Weaviate. - This is a destructive operation. The index will - need to be regenerated if you wish to use it again. + This is a destructive and irreversible operation. The vectors themselves are kept, but + their index is removed from disk and cannot be re-created afterwards, neither through + this method nor through `collection.config.update()`. Searches and writes targeting the + vector are rejected once the index is gone. + + The drop is applied asynchronously. A successful call means that Weaviate accepted the + request, not that the index is already gone. `collection.config.get()` first reports the + vector with a `vector_index_config` of `None` and drops it from `vector_config` + altogether once the index has been removed from disk. + + Only named vectors can be dropped and the server must be started with + `ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true`, since the endpoint + is experimental and disabled by default. Without it, Weaviate answers with a 500. Args: - vector_name: The name of the vector whose index to delete. + vector_name: The name of the named vector whose index to delete. Raises: weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. - weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status. - weaviate.exceptions.WeaviateInvalidInputError: If the vector does not exist. + weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status, e.g. + if the vector does not exist or if the endpoint is not enabled on the server. + weaviate.exceptions.WeaviateInvalidInputError: If `vector_name` is not a string. """ - _validate_input( - [_ValidateArgument(expected=[str], name="vector_name", value=vector_name)] - ) + _validate_input([_ValidateArgument(expected=[str], name="vector_name", value=vector_name)]) - path = ( - f"/schema/{_capitalize_first_letter(self._name)}" - + f"/vectors/{vector_name}" - + "/index" - ) + path = f"/schema/{_capitalize_first_letter(self._name)}/vectors/{vector_name}/index" def resp(res: Response) -> bool: return res.status_code == 200 @@ -701,6 +707,6 @@ def resp(res: Response) -> bool: response_callback=resp, method=self._connection.delete, path=path, - error_msg="Vector may not exist", - status_codes=_ExpectedStatusCodes(ok_in=[200], error="vector exists"), + error_msg="Vector index may not have been deleted", + status_codes=_ExpectedStatusCodes(ok_in=[200], error="delete vector index"), ) From 4e68c45c6a290a77df2a343421358c7b0a3e427c Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 4 Aug 2026 10:31:01 +0200 Subject: [PATCH 3/8] Address PR review feedback (round 3) - Replace the assert on dropped-vector schema shape with an explicit SchemaValidationError so a named vector missing vectorIndexConfig fails fast even under python -O; pinned by a new parser unit test - Slim the integration test to the happy path: the unknown-name error contract moved to the mock suite (422 -> UnexpectedStatusCodeError) and the redundant list_all/invalid-input assertions are covered by the existing unit and mock tests Co-Authored-By: Claude Fable 5 --- integration/test_collection_config.py | 18 ------------------ mock_tests/test_collection.py | 10 ++++++++++ test/collection/test_config_methods.py | 13 +++++++++++++ weaviate/collections/classes/config_methods.py | 14 ++++++++++---- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index d961651c9..645362b20 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -2745,29 +2745,11 @@ def test_delete_vector_index(collection_factory: CollectionFactory) -> None: assert config.vector_config is not None assert config.vector_config["dropped"].vector_index_config is not None - with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError): - collection.config.delete_vector_index("does_not_exist") - assert collection.config.delete_vector_index("dropped") is True vector_config = _vector_config_without_index(collection, "dropped") # vectors that were not dropped keep their index assert vector_config["kept"].vector_index_config is not None - # `list_all` parses the schema through the simple config parser, it must cope with the drop too - with weaviate.connect_to_local() as client: - simple = client.collections.list_all()[collection.name] - assert simple.vector_config is not None - assert simple.vector_config["kept"].vector_index_config is not None - # searching the vector that still has an index keeps working assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1 - - -def test_delete_vector_index_invalid_input(collection_factory: CollectionFactory) -> None: - """Test that a non-string vector name is rejected before hitting the network.""" - collection = collection_factory( - vector_config=[Configure.Vectors.self_provided(name="vec")], - ) - with pytest.raises(WeaviateInvalidInputError): - collection.config.delete_vector_index(42) # type: ignore[arg-type] diff --git a/mock_tests/test_collection.py b/mock_tests/test_collection.py index 2740b0cf4..e94796648 100644 --- a/mock_tests/test_collection.py +++ b/mock_tests/test_collection.py @@ -509,12 +509,22 @@ def test_delete_vector_index(weaviate_mock: HTTPServer) -> None: weaviate_mock.expect_request( "/v1/schema/Test/vectors/vec/index", method="DELETE" ).respond_with_json(response_json={}, status=200) + weaviate_mock.expect_request( + "/v1/schema/Test/vectors/missing/index", method="DELETE" + ).respond_with_json( + response_json={"error": [{"message": "vector index missing not found"}]}, status=422 + ) with weaviate.connect_to_local( port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True ) as client: assert client.collections.use("test").config.delete_vector_index("vec") + # a non-OK answer (e.g. unknown vector name) surfaces as UnexpectedStatusCodeError + with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as e: + client.collections.use("test").config.delete_vector_index("missing") + assert e.value.status_code == 422 + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type] diff --git a/test/collection/test_config_methods.py b/test/collection/test_config_methods.py index ad5543daf..dcbe7508e 100644 --- a/test/collection/test_config_methods.py +++ b/test/collection/test_config_methods.py @@ -1,6 +1,9 @@ from typing import Any, Dict +import pytest + from weaviate.collections.classes.config import VectorIndexType +from weaviate.exceptions import SchemaValidationError from weaviate.collections.classes.config_methods import ( _collection_config_from_json, _collection_config_simple_from_json, @@ -79,6 +82,16 @@ def test_collection_config_from_json_with_dropped_vector_index() -> None: assert as_dict["vectorConfig"]["kept"]["vectorIndexType"] == VectorIndexType.HNSW.value +def test_collection_config_from_json_missing_vector_index_config_raises() -> None: + """A non-dropped vector missing its vectorIndexConfig must fail fast, not parse as None.""" + schema = _schema_with_vector_config( + {"broken": {"vectorizer": {"none": {}}, "vectorIndexType": "hnsw"}} + ) + + with pytest.raises(SchemaValidationError, match="broken"): + _collection_config_from_json(schema) + + def test_collection_config_simple_from_json_with_dropped_vector_index() -> None: """`collections.list_all()` must not choke on a collection with a dropped vector index.""" schema = _schema_with_vector_config( diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index a67b62956..c7d429c9c 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -46,6 +46,7 @@ _VectorIndexConfigHNSW, _VectorizerConfig, ) +from weaviate.exceptions import SchemaValidationError def _is_primitive(d_type: str) -> bool: @@ -285,10 +286,15 @@ def __get_vector_config( vector_index_config = __get_vector_index_config(named_vector) # A vector whose index was dropped with `collection.config.delete_vector_index` is # returned as `vectorIndexType: "none"` without any `vectorIndexConfig`. - assert ( - vector_index_config is not None - or named_vector.get("vectorIndexType") == VectorIndexType.NONE.value - ) + if ( + vector_index_config is None + and named_vector.get("vectorIndexType") != VectorIndexType.NONE.value + ): + raise SchemaValidationError( + f"Named vector {name!r} has vectorIndexType " + f"{named_vector.get('vectorIndexType')!r} but no vectorIndexConfig in the " + "schema returned by Weaviate" + ) try: vec: Union[str, Vectorizers] = Vectorizers(vectorizer_str) except ValueError: From 16ea348abc8a0f4ea18515ada14f869d750f8c9e Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Thu, 6 Aug 2026 10:41:01 +0200 Subject: [PATCH 4/8] fix: parse legacy collection whose vector index was dropped A single-vector (non-named) collection whose vector index is dropped with `collection.config.delete_vector_index` comes back from the server with no top-level `vectorizer` (and no `vectorConfig`, `vectorIndexType` or `vectorIndexConfig`). `__get_vectorizer` accessed `schema["vectorizer"]` unguarded and raised `KeyError: 'vectorizer'`, so both `config.get()` and `collections.list_all()` crashed on such a collection. Return `None` when the key is absent, matching how a dropped named vector yields `vector_index_config is None`. Add parser tests for both entry points. Co-Authored-By: Claude Opus 4.8 --- test/collection/test_config_methods.py | 46 +++++++++++++++++++ .../collections/classes/config_methods.py | 4 ++ 2 files changed, 50 insertions(+) diff --git a/test/collection/test_config_methods.py b/test/collection/test_config_methods.py index dcbe7508e..5948eb96a 100644 --- a/test/collection/test_config_methods.py +++ b/test/collection/test_config_methods.py @@ -104,6 +104,52 @@ def test_collection_config_simple_from_json_with_dropped_vector_index() -> None: assert config.vector_config["dropped"].vector_index_config is None +def _legacy_schema_without_vectorizer() -> Dict[str, Any]: + """Schema of a legacy (single-vector) collection whose vector index was dropped. + + After the drop finalizes the server omits the top-level `vectorizer`, `vectorIndexType` + and `vectorIndexConfig`, and there is no `vectorConfig` block. + """ + return { + "class": "TestCollection", + "properties": [], + "invertedIndexConfig": { + "bm25": {"b": 0.75, "k1": 1.2}, + "cleanupIntervalSeconds": 60, + "stopwords": {"preset": "en", "additions": None, "removals": None}, + }, + "multiTenancyConfig": {"enabled": False}, + "replicationConfig": {"factor": 1, "deletionStrategy": "NoAutomatedResolution"}, + "shardingConfig": { + "virtualPerPhysical": 128, + "desiredCount": 1, + "actualCount": 1, + "desiredVirtualCount": 128, + "actualVirtualCount": 128, + "key": "_id", + "strategy": "hash", + "function": "murmur3", + }, + } + + +def test_collection_config_from_json_legacy_dropped_vector_index() -> None: + """A legacy collection whose vector index was dropped has no top-level vectorizer.""" + config = _collection_config_from_json(_legacy_schema_without_vectorizer()) + + assert config.vectorizer is None + assert config.vector_index_type is None + assert config.vector_config is None + + +def test_collection_config_simple_from_json_legacy_dropped_vector_index() -> None: + """`collections.list_all()` must not choke on a legacy collection with a dropped index.""" + config = _collection_config_simple_from_json(_legacy_schema_without_vectorizer()) + + assert config.vectorizer is None + assert config.vector_config is None + + def test_collection_config_simple_from_json_with_none_vectorizer_config() -> None: """Test that _collection_configs_simple_from_json handles None vectorizer config.""" schema = { diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index c7d429c9c..a236079a0 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -317,6 +317,10 @@ def __get_vector_config( def __get_vectorizer(schema: Dict[str, Any]) -> Optional[Union[str, Vectorizers]]: if "vectorConfig" in schema: return None + # A legacy (non-named-vector) collection whose vector index was dropped with + # `collection.config.delete_vector_index` comes back with no top-level `vectorizer`. + if "vectorizer" not in schema: + return None vectorizer = str(schema["vectorizer"]) try: From 6a7ad4933b8c7fc4045274f3b6b4666d89811929 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Tue, 25 Aug 2026 13:42:50 +0200 Subject: [PATCH 5/8] docs: correct drop-vector-index comments to match server behavior Verified against the Weaviate server source that these comments were wrong: - The endpoint is still experimental and off by default in current main; it is not enabled by default at 1.39.0 GA. The server rejects the drop unless ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true (usecases/schema/property.go:255). Fix the misleading ci/docker-compose.yml comment. - A legacy single-vector collection cannot reach the "no vectorConfig, no vectorizer" shape: the server rejects dropping its index because len(class.VectorConfig) == 0 (property.go:288), and setClassDefaults always forces a non-empty top-level vectorizer for legacy classes (class.go:750). That shape can only come from a named-vector collection whose vectors were all dropped. Reword the __get_vectorizer guard comment and rename the parser tests accordingly. Behavior and assertions are unchanged. Co-Authored-By: Claude Opus 4.8 --- ci/docker-compose.yml | 5 +++-- test/collection/test_config_methods.py | 22 ++++++++++--------- .../collections/classes/config_methods.py | 5 +++-- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml index 9c5a86c88..e5af0b408 100644 --- a/ci/docker-compose.yml +++ b/ci/docker-compose.yml @@ -33,8 +33,9 @@ services: OBJECTS_TTL_DELETE_SCHEDULE: "@every 12h" # for objectTTL tests to work EXPORT_ENABLED: 'true' EXPORT_DEFAULT_PATH: "/var/lib/weaviate/exports" - # for config.delete_vector_index tests to work. Can be dropped once 1.39.0 is GA, the endpoint - # is enabled by default from then on. Ignored by servers that do not know the flag. + # for config.delete_vector_index tests to work. The endpoint is experimental and off by + # default; remove this once it is promoted to a supported release. Ignored by servers that + # do not know the flag. ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT: 'true' contextionary: diff --git a/test/collection/test_config_methods.py b/test/collection/test_config_methods.py index 5948eb96a..60b273c35 100644 --- a/test/collection/test_config_methods.py +++ b/test/collection/test_config_methods.py @@ -104,11 +104,13 @@ def test_collection_config_simple_from_json_with_dropped_vector_index() -> None: assert config.vector_config["dropped"].vector_index_config is None -def _legacy_schema_without_vectorizer() -> Dict[str, Any]: - """Schema of a legacy (single-vector) collection whose vector index was dropped. +def _schema_without_any_vector() -> Dict[str, Any]: + """Schema of a named-vector collection whose vectors were all dropped. - After the drop finalizes the server omits the top-level `vectorizer`, `vectorIndexType` - and `vectorIndexConfig`, and there is no `vectorConfig` block. + Once the drops finalize the server removes every `vectorConfig` entry, so the block is + omitted, and a named-vector collection never has a top-level `vectorizer`, `vectorIndexType` + or `vectorIndexConfig`. (A legacy single-vector collection cannot reach this shape: the server + rejects dropping its index, so it always keeps a top-level `vectorizer`.) """ return { "class": "TestCollection", @@ -133,18 +135,18 @@ def _legacy_schema_without_vectorizer() -> Dict[str, Any]: } -def test_collection_config_from_json_legacy_dropped_vector_index() -> None: - """A legacy collection whose vector index was dropped has no top-level vectorizer.""" - config = _collection_config_from_json(_legacy_schema_without_vectorizer()) +def test_collection_config_from_json_all_vectors_dropped() -> None: + """A collection whose vectors were all dropped has no top-level vectorizer.""" + config = _collection_config_from_json(_schema_without_any_vector()) assert config.vectorizer is None assert config.vector_index_type is None assert config.vector_config is None -def test_collection_config_simple_from_json_legacy_dropped_vector_index() -> None: - """`collections.list_all()` must not choke on a legacy collection with a dropped index.""" - config = _collection_config_simple_from_json(_legacy_schema_without_vectorizer()) +def test_collection_config_simple_from_json_all_vectors_dropped() -> None: + """`collections.list_all()` must not choke on a collection whose vectors were all dropped.""" + config = _collection_config_simple_from_json(_schema_without_any_vector()) assert config.vectorizer is None assert config.vector_config is None diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index a236079a0..82e90442c 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -317,8 +317,9 @@ def __get_vector_config( def __get_vectorizer(schema: Dict[str, Any]) -> Optional[Union[str, Vectorizers]]: if "vectorConfig" in schema: return None - # A legacy (non-named-vector) collection whose vector index was dropped with - # `collection.config.delete_vector_index` comes back with no top-level `vectorizer`. + # A named-vector collection whose vectors were all dropped with + # `collection.config.delete_vector_index` comes back with neither a `vectorConfig` block nor a + # top-level `vectorizer`. Return None instead of raising KeyError on the missing key. if "vectorizer" not in schema: return None From 944e66c62d200954fe64c85bde37c2937fb13542 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Thu, 27 Aug 2026 12:45:35 +0200 Subject: [PATCH 6/8] Address PR review feedback (round 4) - config.py: __existing_vector_index_config raised a raw KeyError when the collection had no vectors left (server omits vectorConfig once every named vector is dropped). Guard the key so the intended WeaviateInvalidInputError is raised instead. Add a regression test. - config_methods.py: __get_vector_config reported "no vectorIndexConfig" for a vectorIndexType the client does not know, even though the config was present (an older client against a newer server). Branch on "vectorIndexConfig" in the named vector and give the unknown-type case its own message. Add a regression test. - executor.py: delete_vector_index no longer names the env flag or the 500 status in its docstring (both go stale when the endpoint is promoted); it now returns None instead of a bool that could only ever be True. Regenerate stubs. - tests: fold the disabled-endpoint case into test_delete_vector_index (its only unique check is that the server message reaches the exception) and drop the redundant simple-parser test; the all-vectors-dropped simple test remains the list_all() guard. Co-Authored-By: Claude Opus 4.8 --- mock_tests/test_collection.py | 40 ++++++++----------- test/collection/test_config_methods.py | 19 +++++---- test/collection/test_config_update.py | 14 +++++++ weaviate/collections/classes/config.py | 3 +- .../collections/classes/config_methods.py | 7 ++++ weaviate/collections/config/async_.pyi | 2 +- weaviate/collections/config/executor.py | 13 +++--- weaviate/collections/config/sync.pyi | 2 +- 8 files changed, 60 insertions(+), 40 deletions(-) diff --git a/mock_tests/test_collection.py b/mock_tests/test_collection.py index e94796648..121f265d9 100644 --- a/mock_tests/test_collection.py +++ b/mock_tests/test_collection.py @@ -514,25 +514,8 @@ def test_delete_vector_index(weaviate_mock: HTTPServer) -> None: ).respond_with_json( response_json={"error": [{"message": "vector index missing not found"}]}, status=422 ) - - with weaviate.connect_to_local( - port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True - ) as client: - assert client.collections.use("test").config.delete_vector_index("vec") - - # a non-OK answer (e.g. unknown vector name) surfaces as UnexpectedStatusCodeError - with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as e: - client.collections.use("test").config.delete_vector_index("missing") - assert e.value.status_code == 422 - - with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): - client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type] - - -def test_delete_vector_index_endpoint_disabled(weaviate_mock: HTTPServer) -> None: - # servers without ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true answer 500 weaviate_mock.expect_request( - "/v1/schema/Test/vectors/vec/index", method="DELETE" + "/v1/schema/Test/vectors/disabled/index", method="DELETE" ).respond_with_json( response_json={ "error": [ @@ -547,11 +530,22 @@ def test_delete_vector_index_endpoint_disabled(weaviate_mock: HTTPServer) -> Non with weaviate.connect_to_local( port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True ) as client: - with pytest.raises(UnexpectedStatusCodeError) as e: - client.collections.use("Test").config.delete_vector_index("vec") - assert e.value.status_code == 500 - # the error message must not claim the vector is missing, the endpoint is simply off - assert "experimental and disabled by default" in e.value.message + assert client.collections.use("test").config.delete_vector_index("vec") is None + + # a non-OK answer (e.g. unknown vector name) surfaces as UnexpectedStatusCodeError + with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as e: + client.collections.use("test").config.delete_vector_index("missing") + assert e.value.status_code == 422 + + # a disabled experimental endpoint answers 500; the server message must reach the + # exception rather than being masked as a missing vector + with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError) as disabled: + client.collections.use("test").config.delete_vector_index("disabled") + assert disabled.value.status_code == 500 + assert "experimental and disabled by default" in disabled.value.message + + with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError): + client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type] def test_grpc_client_version_header( diff --git a/test/collection/test_config_methods.py b/test/collection/test_config_methods.py index 60b273c35..f1c1319a0 100644 --- a/test/collection/test_config_methods.py +++ b/test/collection/test_config_methods.py @@ -92,16 +92,21 @@ def test_collection_config_from_json_missing_vector_index_config_raises() -> Non _collection_config_from_json(schema) -def test_collection_config_simple_from_json_with_dropped_vector_index() -> None: - """`collections.list_all()` must not choke on a collection with a dropped vector index.""" +def test_collection_config_from_json_unknown_vector_index_type_raises() -> None: + """An index type the client does not know is reported as such, not as a missing config.""" + # `vectorIndexConfig` is present and populated; only the type is unknown to this client. schema = _schema_with_vector_config( - {"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"}} + { + "future": { + "vectorizer": {"none": {}}, + "vectorIndexType": "spann", + "vectorIndexConfig": {"distance": "cosine", "searchListSize": 100}, + } + } ) - config = _collection_config_simple_from_json(schema) - - assert config.vector_config is not None - assert config.vector_config["dropped"].vector_index_config is None + with pytest.raises(SchemaValidationError, match="unknown vectorIndexType"): + _collection_config_from_json(schema) def _schema_without_any_vector() -> Dict[str, Any]: diff --git a/test/collection/test_config_update.py b/test/collection/test_config_update.py index 19b7aaedd..d2b9fc11a 100644 --- a/test/collection/test_config_update.py +++ b/test/collection/test_config_update.py @@ -275,3 +275,17 @@ def test_updating_vector_next_to_dropped_vector_index() -> None: "vectorizer": {"none": {}}, "vectorIndexType": "none", } + + +def test_updating_vector_when_none_left() -> None: + """Once every vector is dropped the server omits vectorConfig; update must not raise KeyError.""" + update = _CollectionConfigUpdate( + vector_config=[ + Reconfigure.Vectors.update( + name="gone", vector_index_config=Reconfigure.VectorIndex.hnsw(ef=128) + ) + ] + ) + + with pytest.raises(WeaviateInvalidInputError, match="does not exist"): + update.merge_with_existing({"class": "Test", "properties": []}) diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 282ad6d1b..24ca5e10f 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -1548,7 +1548,8 @@ def mutual_exclusivity( @staticmethod def __existing_vector_index_config(schema: Dict[str, Any], name: str) -> Dict[str, Any]: - if name not in schema["vectorConfig"]: + # `vectorConfig` is omitted entirely once every named vector has been dropped. + if "vectorConfig" not in schema or name not in schema["vectorConfig"]: raise WeaviateInvalidInputError( f"Vector config with name {name} does not exist in the existing vector config" ) diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index 82e90442c..a50829c25 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -290,6 +290,13 @@ def __get_vector_config( vector_index_config is None and named_vector.get("vectorIndexType") != VectorIndexType.NONE.value ): + if "vectorIndexConfig" in named_vector: + # the config is present; this client version does not know the index type + raise SchemaValidationError( + f"Named vector {name!r} has an unknown vectorIndexType " + f"{named_vector.get('vectorIndexType')!r}; upgrade the client to a version " + "that supports it" + ) raise SchemaValidationError( f"Named vector {name!r} has vectorIndexType " f"{named_vector.get('vectorIndexType')!r} but no vectorIndexConfig in the " diff --git a/weaviate/collections/config/async_.pyi b/weaviate/collections/config/async_.pyi index 91139dd83..13891e3c5 100644 --- a/weaviate/collections/config/async_.pyi +++ b/weaviate/collections/config/async_.pyi @@ -90,4 +90,4 @@ class _ConfigCollectionAsync(_ConfigCollectionExecutor[ConnectionAsync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... async def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... - async def delete_vector_index(self, vector_name: str) -> bool: ... + async def delete_vector_index(self, vector_name: str) -> None: ... diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index b1e7f57bc..82bce08a4 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -670,7 +670,7 @@ def resp(res: Response) -> bool: def delete_vector_index( self, vector_name: str, - ) -> executor.Result[bool]: + ) -> executor.Result[None]: """Delete the index of a named vector of the collection in Weaviate. This is a destructive and irreversible operation. The vectors themselves are kept, but @@ -683,9 +683,8 @@ def delete_vector_index( vector with a `vector_index_config` of `None` and drops it from `vector_config` altogether once the index has been removed from disk. - Only named vectors can be dropped and the server must be started with - `ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true`, since the endpoint - is experimental and disabled by default. Without it, Weaviate answers with a 500. + Only named vectors can be dropped. The endpoint is experimental and may be disabled + server-side, in which case Weaviate rejects the request. Args: vector_name: The name of the named vector whose index to delete. @@ -693,15 +692,15 @@ def delete_vector_index( Raises: weaviate.exceptions.WeaviateConnectionError: If the network connection to Weaviate fails. weaviate.exceptions.UnexpectedStatusCodeError: If Weaviate reports a non-OK status, e.g. - if the vector does not exist or if the endpoint is not enabled on the server. + if the vector does not exist or if the endpoint is disabled on the server. weaviate.exceptions.WeaviateInvalidInputError: If `vector_name` is not a string. """ _validate_input([_ValidateArgument(expected=[str], name="vector_name", value=vector_name)]) path = f"/schema/{_capitalize_first_letter(self._name)}/vectors/{vector_name}/index" - def resp(res: Response) -> bool: - return res.status_code == 200 + def resp(res: Response) -> None: + return None return executor.execute( response_callback=resp, diff --git a/weaviate/collections/config/sync.pyi b/weaviate/collections/config/sync.pyi index 7bd450819..9cee186c5 100644 --- a/weaviate/collections/config/sync.pyi +++ b/weaviate/collections/config/sync.pyi @@ -88,4 +88,4 @@ class _ConfigCollection(_ConfigCollectionExecutor[ConnectionSync]): self, *, vector_config: Union[_VectorConfigCreate, List[_VectorConfigCreate]] ) -> None: ... def delete_property_index(self, property_name: str, index_name: IndexName) -> bool: ... - def delete_vector_index(self, vector_name: str) -> bool: ... + def delete_vector_index(self, vector_name: str) -> None: ... From 4abce144ea8910cdfa7d60f3d96f379be80c9ec0 Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Thu, 27 Aug 2026 13:04:28 +0200 Subject: [PATCH 7/8] fix: update integration test to the None return of delete_vector_index The round-4 commit changed delete_vector_index to return None but only updated the mock test and stubs; the integration assertion still expected True and would fail on every >=1.39 CI job. Co-Authored-By: Claude Fable 5 --- integration/test_collection_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index 645362b20..71775d780 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -2745,7 +2745,7 @@ def test_delete_vector_index(collection_factory: CollectionFactory) -> None: assert config.vector_config is not None assert config.vector_config["dropped"].vector_index_config is not None - assert collection.config.delete_vector_index("dropped") is True + assert collection.config.delete_vector_index("dropped") is None vector_config = _vector_config_without_index(collection, "dropped") # vectors that were not dropped keep their index From d2de1647ba38f10eab35a726e7e0b09c8eca874e Mon Sep 17 00:00:00 2001 From: Jose Luis Franco Arza Date: Mon, 31 Aug 2026 17:08:14 +0200 Subject: [PATCH 8/8] Address PR review feedback (round 5) - Represent a dropped vector index with _VectorIndexConfigNone instead of None: the union in _NamedVectorConfig stays concrete, to_dict() derives the type via vector_index_type() like every other index, and the index type of a dropped vector is visible without serializing. The dict output still omits vectorIndexConfig, matching what the server sends. Exported as VectorIndexConfigNone in weaviate.outputs.config. - Strip dropped-vector entries in collections __create (shared by create, create_from_dict and create_from_config): the server rejects vectorIndexType "none" on create, so importing an exported config that ever had a vector index dropped answered 422. The whole entry is skipped, not just the type, because keeping it would re-create an index that was deliberately dropped. A Col001 warning names the skipped vectors; an emptied vectorConfig block is omitted entirely. Mock test verifies the request body both ways, confirmed failing without the strip. - Document in create_from_dict/create_from_config that the round trip is lossy: there is no API to re-create a vector without an index, so the new collection does not contain the dropped vectors. Co-Authored-By: Claude Fable 5 --- integration/test_collection_config.py | 16 ++++---- mock_tests/test_collection.py | 38 ++++++++++++++++++- test/collection/test_config_methods.py | 7 ++-- weaviate/collections/classes/config.py | 28 ++++++++++---- .../collections/classes/config_methods.py | 34 ++++++++++------- weaviate/collections/collections/base.py | 8 ++++ weaviate/collections/collections/executor.py | 30 +++++++++++++++ weaviate/collections/config/executor.py | 4 +- weaviate/outputs/config.py | 2 + weaviate/warnings.py | 10 +++++ 10 files changed, 143 insertions(+), 34 deletions(-) diff --git a/integration/test_collection_config.py b/integration/test_collection_config.py index 71775d780..bba965f67 100644 --- a/integration/test_collection_config.py +++ b/integration/test_collection_config.py @@ -23,6 +23,7 @@ _VectorIndexConfigDynamic, _VectorIndexConfigFlat, _VectorIndexConfigHNSW, + _VectorIndexConfigNone, _VectorIndexConfigHNSWUpdate, Configure, Reconfigure, @@ -2706,16 +2707,15 @@ def _vector_config_without_index( The drop is applied asynchronously, a 200 from the endpoint only means that Weaviate accepted the request. It then becomes visible in two steps: the vector first stays in the schema with a - `vector_index_config` of `None`, and once the index is gone from disk the entry is removed from - the schema altogether. Both shapes must parse, so accept either. + `vector_index_config` of `_VectorIndexConfigNone`, and once the index is gone from disk the + entry is removed from the schema altogether. Both shapes must parse, so accept either. """ start = time.time() while True: vector_config = collection.config.get().vector_config assert vector_config is not None - if ( - vector_name not in vector_config - or vector_config[vector_name].vector_index_config is None + if vector_name not in vector_config or isinstance( + vector_config[vector_name].vector_index_config, _VectorIndexConfigNone ): return vector_config if time.time() - start > timeout: @@ -2743,13 +2743,15 @@ def test_delete_vector_index(collection_factory: CollectionFactory) -> None: config = collection.config.get() assert config.vector_config is not None - assert config.vector_config["dropped"].vector_index_config is not None + assert not isinstance( + config.vector_config["dropped"].vector_index_config, _VectorIndexConfigNone + ) assert collection.config.delete_vector_index("dropped") is None vector_config = _vector_config_without_index(collection, "dropped") # vectors that were not dropped keep their index - assert vector_config["kept"].vector_index_config is not None + assert not isinstance(vector_config["kept"].vector_index_config, _VectorIndexConfigNone) # searching the vector that still has an index keeps working assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1 diff --git a/mock_tests/test_collection.py b/mock_tests/test_collection.py index 121f265d9..14450bd86 100644 --- a/mock_tests/test_collection.py +++ b/mock_tests/test_collection.py @@ -1,9 +1,11 @@ import datetime -from typing import Any, Dict, Literal +import json +from typing import Any, Dict, List, Literal import grpc import pytest from pytest_httpserver import HTTPServer +from werkzeug import Request, Response import weaviate import weaviate.classes as wvc @@ -548,6 +550,40 @@ def test_delete_vector_index(weaviate_mock: HTTPServer) -> None: client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type] +def test_create_from_dict_skips_dropped_vectors(weaviate_mock: HTTPServer) -> None: + """Entries with vectorIndexType "none" cannot be re-created and are stripped before the POST.""" + bodies: List[Dict[str, Any]] = [] + + def handler(request: Request) -> Response: + body = request.get_json() + bodies.append(body) + return Response(json.dumps({"class": body["class"]}), content_type="application/json") + + weaviate_mock.expect_request("/v1/schema", method="POST").respond_with_handler(handler) + + hnsw_entry = {"vectorizer": {"none": {}}, "vectorIndexType": "hnsw", "vectorIndexConfig": {}} + dropped_entry = {"vectorizer": {"none": {}}, "vectorIndexType": "none"} + + with weaviate.connect_to_local( + port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True + ) as client: + with pytest.warns(UserWarning, match=r"Col001.*dropped"): + client.collections.create_from_dict( + { + "class": "TestDropped", + "vectorConfig": {"dropped": dropped_entry, "kept": hnsw_entry}, + } + ) + assert bodies[-1]["vectorConfig"] == {"kept": hnsw_entry} + + # once every vector is stripped the empty block is omitted, not sent as {} + with pytest.warns(UserWarning, match=r"Col001.*only"): + client.collections.create_from_dict( + {"class": "TestAllDropped", "vectorConfig": {"only": dropped_entry}} + ) + assert "vectorConfig" not in bodies[-1] + + def test_grpc_client_version_header( metadata_capture_collection: tuple[ weaviate.collections.Collection, MockMetadataCaptureWeaviateService diff --git a/test/collection/test_config_methods.py b/test/collection/test_config_methods.py index f1c1319a0..748fdf7e1 100644 --- a/test/collection/test_config_methods.py +++ b/test/collection/test_config_methods.py @@ -2,7 +2,7 @@ import pytest -from weaviate.collections.classes.config import VectorIndexType +from weaviate.collections.classes.config import VectorIndexType, _VectorIndexConfigNone from weaviate.exceptions import SchemaValidationError from weaviate.collections.classes.config_methods import ( _collection_config_from_json, @@ -72,8 +72,9 @@ def test_collection_config_from_json_with_dropped_vector_index() -> None: config = _collection_config_from_json(schema) assert config.vector_config is not None - assert config.vector_config["dropped"].vector_index_config is None - assert config.vector_config["kept"].vector_index_config is not None + assert isinstance(config.vector_config["dropped"].vector_index_config, _VectorIndexConfigNone) + assert config.vector_config["dropped"].vector_index_config.vector_index_type() == "none" + assert not isinstance(config.vector_config["kept"].vector_index_config, _VectorIndexConfigNone) # The dropped vector must round-trip back to the "none" index type the server reported. as_dict = config.to_dict() diff --git a/weaviate/collections/classes/config.py b/weaviate/collections/classes/config.py index 24ca5e10f..f2c188164 100644 --- a/weaviate/collections/classes/config.py +++ b/weaviate/collections/classes/config.py @@ -2099,6 +2099,21 @@ def vector_index_type() -> str: VectorIndexConfigDynamic = _VectorIndexConfigDynamic +@dataclass +class _VectorIndexConfigNone(_ConfigBase): + """The index of this vector was dropped with `collection.config.delete_vector_index`. + + The vector data is still stored, but there is no index left to configure or search. + """ + + @staticmethod + def vector_index_type() -> str: + return VectorIndexType.NONE.value + + +VectorIndexConfigNone = _VectorIndexConfigNone + + @dataclass class _GenerativeConfig(_ConfigBase): generative: Union[GenerativeSearches, str] @@ -2143,23 +2158,20 @@ def to_dict(self) -> Dict[str, Any]: @dataclass class _NamedVectorConfig(_ConfigBase): vectorizer: _NamedVectorizerConfig - # `None` means the index of this vector was dropped with `collection.config.delete_vector_index`. - # The vector data is still stored, but there is no index to configure or search. vector_index_config: Union[ VectorIndexConfigHNSW, VectorIndexConfigFlat, VectorIndexConfigDynamic, VectorIndexConfigHFresh, - None, + VectorIndexConfigNone, ] def to_dict(self) -> Dict: ret_dict = super().to_dict() - ret_dict["vectorIndexType"] = ( - VectorIndexType.NONE.value - if self.vector_index_config is None - else self.vector_index_config.vector_index_type() - ) + ret_dict["vectorIndexType"] = self.vector_index_config.vector_index_type() + if isinstance(self.vector_index_config, _VectorIndexConfigNone): + # match the server: a dropped index is reported without any `vectorIndexConfig` + ret_dict.pop("vectorIndexConfig", None) return ret_dict diff --git a/weaviate/collections/classes/config_methods.py b/weaviate/collections/classes/config_methods.py index a50829c25..7d7d75d2f 100644 --- a/weaviate/collections/classes/config_methods.py +++ b/weaviate/collections/classes/config_methods.py @@ -44,6 +44,7 @@ _VectorIndexConfigFlat, _VectorIndexConfigHFresh, _VectorIndexConfigHNSW, + _VectorIndexConfigNone, _VectorizerConfig, ) from weaviate.exceptions import SchemaValidationError @@ -283,25 +284,32 @@ def __get_vector_config( vec_config = {} props = vec_config.pop("properties", None) - vector_index_config = __get_vector_index_config(named_vector) - # A vector whose index was dropped with `collection.config.delete_vector_index` is - # returned as `vectorIndexType: "none"` without any `vectorIndexConfig`. - if ( - vector_index_config is None - and named_vector.get("vectorIndexType") != VectorIndexType.NONE.value - ): - if "vectorIndexConfig" in named_vector: + vector_index_config: Union[ + _VectorIndexConfigHNSW, + _VectorIndexConfigFlat, + _VectorIndexConfigDynamic, + _VectorIndexConfigHFresh, + _VectorIndexConfigNone, + None, + ] = __get_vector_index_config(named_vector) + if vector_index_config is None: + # A vector whose index was dropped with `collection.config.delete_vector_index` is + # returned as `vectorIndexType: "none"` without any `vectorIndexConfig`. + if named_vector.get("vectorIndexType") == VectorIndexType.NONE.value: + vector_index_config = _VectorIndexConfigNone() + elif "vectorIndexConfig" in named_vector: # the config is present; this client version does not know the index type raise SchemaValidationError( f"Named vector {name!r} has an unknown vectorIndexType " f"{named_vector.get('vectorIndexType')!r}; upgrade the client to a version " "that supports it" ) - raise SchemaValidationError( - f"Named vector {name!r} has vectorIndexType " - f"{named_vector.get('vectorIndexType')!r} but no vectorIndexConfig in the " - "schema returned by Weaviate" - ) + else: + raise SchemaValidationError( + f"Named vector {name!r} has vectorIndexType " + f"{named_vector.get('vectorIndexType')!r} but no vectorIndexConfig in the " + "schema returned by Weaviate" + ) try: vec: Union[str, Vectorizers] = Vectorizers(vectorizer_str) except ValueError: diff --git a/weaviate/collections/collections/base.py b/weaviate/collections/collections/base.py index 32296c137..c6af43c32 100644 --- a/weaviate/collections/collections/base.py +++ b/weaviate/collections/collections/base.py @@ -97,6 +97,10 @@ def create_from_dict( This method is helpful for those making the v3 -> v4 migration and for those interfacing with any experimental Weaviate features that are not yet fully supported by the Weaviate Python client. + Vector entries whose index was dropped with `collection.config.delete_vector_index` are + skipped with a warning: there is no API to re-create a vector without an index, so the new + collection simply does not contain them. + Args: config: The dictionary representation of the collection's configuration. @@ -115,6 +119,10 @@ def create_from_config( ]: """Use this method to create a collection in Weaviate and immediately return a collection object using a pre-defined Weaviate collection configuration object. + Vector entries whose index was dropped with `collection.config.delete_vector_index` are + skipped with a warning: there is no API to re-create a vector without an index, so the new + collection simply does not contain them. + Args: config: The collection's configuration. diff --git a/weaviate/collections/collections/executor.py b/weaviate/collections/collections/executor.py index 588737600..e22401378 100644 --- a/weaviate/collections/collections/executor.py +++ b/weaviate/collections/collections/executor.py @@ -37,6 +37,7 @@ _collection_configs_from_json, _collection_configs_simple_from_json, ) +from weaviate.collections.classes.config_vector_index import VectorIndexType from weaviate.collections.classes.internal import References from weaviate.collections.classes.types import ( Properties, @@ -103,6 +104,7 @@ def __create( Collection[Properties, References], Awaitable[CollectionAsync[Properties, References]], ]: + config = self.__without_dropped_vectors(config) result = self._connection.post( path="/schema", weaviate_object=config, @@ -137,6 +139,34 @@ async def execute_(): assert isinstance(collection, Collection) return collection + @staticmethod + def __without_dropped_vectors(config: dict) -> dict: + """Strip vector entries whose index was dropped (`vectorIndexType: "none"`). + + The server rejects the `"none"` sentinel on create and there is no API to re-create a + vector without an index. Keeping the entry with a real index type instead would silently + re-create an index that was deliberately dropped, so the whole entry is skipped — matching + where the server's own cleanup ends up once a drop finalizes. + """ + vector_config = config.get("vectorConfig") + if not isinstance(vector_config, dict): + return config + dropped = [ + name + for name, vc in vector_config.items() + if isinstance(vc, dict) and vc.get("vectorIndexType") == VectorIndexType.NONE.value + ] + if not dropped: + return config + _Warnings.create_skips_vectors_without_index(dropped) + config = { + **config, + "vectorConfig": {n: vc for n, vc in vector_config.items() if n not in dropped}, + } + if not config["vectorConfig"]: + del config["vectorConfig"] + return config + def __delete(self, *, name: str) -> executor.Result[None]: return executor.execute( response_callback=lambda res: None, diff --git a/weaviate/collections/config/executor.py b/weaviate/collections/config/executor.py index 82bce08a4..7302bcdbf 100644 --- a/weaviate/collections/config/executor.py +++ b/weaviate/collections/config/executor.py @@ -680,8 +680,8 @@ def delete_vector_index( The drop is applied asynchronously. A successful call means that Weaviate accepted the request, not that the index is already gone. `collection.config.get()` first reports the - vector with a `vector_index_config` of `None` and drops it from `vector_config` - altogether once the index has been removed from disk. + vector with a `vector_index_config` of `VectorIndexConfigNone` and drops it from + `vector_config` altogether once the index has been removed from disk. Only named vectors can be dropped. The endpoint is experimental and may be disabled server-side, in which case Weaviate rejects the request. diff --git a/weaviate/outputs/config.py b/weaviate/outputs/config.py index 17ebebf0e..8799ae724 100644 --- a/weaviate/outputs/config.py +++ b/weaviate/outputs/config.py @@ -25,6 +25,7 @@ VectorIndexConfigFlat, VectorIndexConfigHFresh, VectorIndexConfigHNSW, + VectorIndexConfigNone, VectorIndexType, VectorizerConfig, Vectorizers, @@ -57,6 +58,7 @@ "VectorIndexConfigHNSW", "VectorIndexConfigHFresh", "VectorIndexConfigFlat", + "VectorIndexConfigNone", "VectorIndexType", "Vectorizers", "VectorizerConfig", diff --git a/weaviate/warnings.py b/weaviate/warnings.py index 1c0a1ae0b..426f99175 100644 --- a/weaviate/warnings.py +++ b/weaviate/warnings.py @@ -282,6 +282,16 @@ def datetime_year_zero(date: str) -> None: stacklevel=1, ) + @staticmethod + def create_skips_vectors_without_index(vectors: list) -> None: + warnings.warn( + message=f"""Col001: The vector config(s) {vectors} have no vector index (it was dropped with + `collection.config.delete_vector_index`) and cannot be re-created. The collection is created + without these vectors; inserts and queries targeting them will fail.""", + category=UserWarning, + stacklevel=1, + ) + @staticmethod def batch_refresh_failed(err: str) -> None: warnings.warn(