Skip to content

Commit 2dbfaed

Browse files
jfrancoaclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 4df5c74 commit 2dbfaed

9 files changed

Lines changed: 313 additions & 41 deletions

File tree

ci/docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ services:
3333
OBJECTS_TTL_DELETE_SCHEDULE: "@every 12h" # for objectTTL tests to work
3434
EXPORT_ENABLED: 'true'
3535
EXPORT_DEFAULT_PATH: "/var/lib/weaviate/exports"
36+
# for config.delete_vector_index tests to work. Can be dropped once 1.39.0 is GA, the endpoint
37+
# is enabled by default from then on. Ignored by servers that do not know the flag.
38+
ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT: 'true'
3639

3740
contextionary:
3841
environment:

integration/test_collection_config.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
2-
from typing import Generator, List, Optional, Union
2+
import time
3+
from typing import Any, Dict, Generator, List, Optional, Union
34

45
import pytest as pytest
56
from _pytest.fixtures import SubRequest
@@ -11,6 +12,7 @@
1112
OpenAICollection,
1213
_sanitize_collection_name,
1314
)
15+
from weaviate.collections import Collection
1416
from weaviate.collections.classes.config import (
1517
_BQConfig,
1618
_CollectionConfig,
@@ -37,6 +39,7 @@
3739
Rerankers,
3840
_RerankerProvider,
3941
Tokenization,
42+
_NamedVectorConfig,
4043
_NamedVectorConfigCreate,
4144
_VectorizerConfigCreate,
4245
IndexName,
@@ -2694,3 +2697,77 @@ def test_text_analyzer_roundtrip_from_dict(
26942697
assert config == new
26952698
assert config.to_dict() == new.to_dict()
26962699
client.collections.delete(name)
2700+
2701+
2702+
def _vector_config_without_index(
2703+
collection: Collection[Any, Any], vector_name: str, timeout: float = 30
2704+
) -> Dict[str, _NamedVectorConfig]:
2705+
"""Poll the collection config until `vector_name` no longer has an index.
2706+
2707+
The drop is applied asynchronously, a 200 from the endpoint only means that Weaviate accepted
2708+
the request. It then becomes visible in two steps: the vector first stays in the schema with a
2709+
`vector_index_config` of `None`, and once the index is gone from disk the entry is removed from
2710+
the schema altogether. Both shapes must parse, so accept either.
2711+
"""
2712+
start = time.time()
2713+
while True:
2714+
vector_config = collection.config.get().vector_config
2715+
assert vector_config is not None
2716+
if (
2717+
vector_name not in vector_config
2718+
or vector_config[vector_name].vector_index_config is None
2719+
):
2720+
return vector_config
2721+
if time.time() - start > timeout:
2722+
pytest.fail(f"vector index of {vector_name} was not dropped within {timeout}s")
2723+
time.sleep(0.2)
2724+
2725+
2726+
def test_delete_vector_index(collection_factory: CollectionFactory) -> None:
2727+
"""Test that dropping the index of a named vector leaves the rest of the collection usable."""
2728+
collection_dummy = collection_factory("dummy")
2729+
if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0):
2730+
pytest.skip("delete vector index not supported before 1.39.0")
2731+
2732+
collection = collection_factory(
2733+
properties=[Property(name="name", data_type=DataType.TEXT)],
2734+
vector_config=[
2735+
Configure.Vectors.self_provided(name="dropped"),
2736+
Configure.Vectors.self_provided(name="kept"),
2737+
],
2738+
)
2739+
collection.data.insert(
2740+
properties={"name": "banana"},
2741+
vector={"dropped": [1, 2], "kept": [3, 4]},
2742+
)
2743+
2744+
config = collection.config.get()
2745+
assert config.vector_config is not None
2746+
assert config.vector_config["dropped"].vector_index_config is not None
2747+
2748+
with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError):
2749+
collection.config.delete_vector_index("does_not_exist")
2750+
2751+
assert collection.config.delete_vector_index("dropped") is True
2752+
2753+
vector_config = _vector_config_without_index(collection, "dropped")
2754+
# vectors that were not dropped keep their index
2755+
assert vector_config["kept"].vector_index_config is not None
2756+
2757+
# `list_all` parses the schema through the simple config parser, it must cope with the drop too
2758+
with weaviate.connect_to_local() as client:
2759+
simple = client.collections.list_all()[collection.name]
2760+
assert simple.vector_config is not None
2761+
assert simple.vector_config["kept"].vector_index_config is not None
2762+
2763+
# searching the vector that still has an index keeps working
2764+
assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1
2765+
2766+
2767+
def test_delete_vector_index_invalid_input(collection_factory: CollectionFactory) -> None:
2768+
"""Test that a non-string vector name is rejected before hitting the network."""
2769+
collection = collection_factory(
2770+
vector_config=[Configure.Vectors.self_provided(name="vec")],
2771+
)
2772+
with pytest.raises(WeaviateInvalidInputError):
2773+
collection.config.delete_vector_index(42) # type: ignore[arg-type]

mock_tests/test_collection.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,46 @@ def test_collection_exists(weaviate_mock: HTTPServer) -> None:
484484
assert e.value.status_code == 500
485485

486486

487+
def test_delete_vector_index(weaviate_mock: HTTPServer) -> None:
488+
# the collection name is capitalized by the client before it hits the path
489+
weaviate_mock.expect_request(
490+
"/v1/schema/Test/vectors/vec/index", method="DELETE"
491+
).respond_with_json(response_json={}, status=200)
492+
493+
with weaviate.connect_to_local(
494+
port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True
495+
) as client:
496+
assert client.collections.use("test").config.delete_vector_index("vec")
497+
498+
with pytest.raises(weaviate.exceptions.WeaviateInvalidInputError):
499+
client.collections.use("test").config.delete_vector_index(42) # type: ignore[arg-type]
500+
501+
502+
def test_delete_vector_index_endpoint_disabled(weaviate_mock: HTTPServer) -> None:
503+
# servers without ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT=true answer 500
504+
weaviate_mock.expect_request(
505+
"/v1/schema/Test/vectors/vec/index", method="DELETE"
506+
).respond_with_json(
507+
response_json={
508+
"error": [
509+
{
510+
"message": "alter schema drop vector index endpoint is experimental and disabled by default"
511+
}
512+
]
513+
},
514+
status=500,
515+
)
516+
517+
with weaviate.connect_to_local(
518+
port=MOCK_PORT, host=MOCK_IP, grpc_port=MOCK_PORT_GRPC, skip_init_checks=True
519+
) as client:
520+
with pytest.raises(UnexpectedStatusCodeError) as e:
521+
client.collections.use("Test").config.delete_vector_index("vec")
522+
assert e.value.status_code == 500
523+
# the error message must not claim the vector is missing, the endpoint is simply off
524+
assert "experimental and disabled by default" in e.value.message
525+
526+
487527
def test_grpc_client_version_header(
488528
metadata_capture_collection: tuple[
489529
weaviate.collections.Collection, MockMetadataCaptureWeaviateService

test/collection/test_config_methods.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,95 @@
1+
from typing import Any, Dict
2+
3+
from weaviate.collections.classes.config import VectorIndexType
14
from weaviate.collections.classes.config_methods import (
25
_collection_config_from_json,
6+
_collection_config_simple_from_json,
37
_collection_configs_simple_from_json,
48
_nested_properties_from_config,
59
_properties_from_config,
610
)
711

12+
HNSW_CONFIG = {
13+
"skip": False,
14+
"cleanupIntervalSeconds": 300,
15+
"maxConnections": 64,
16+
"efConstruction": 128,
17+
"ef": -1,
18+
"dynamicEfMin": 100,
19+
"dynamicEfMax": 500,
20+
"dynamicEfFactor": 8,
21+
"vectorCacheMaxObjects": 1000000000000,
22+
"flatSearchCutoff": 40000,
23+
"distance": "cosine",
24+
}
25+
26+
27+
def _schema_with_vector_config(vector_config: Dict[str, Any]) -> Dict[str, Any]:
28+
"""Build a minimal collection schema, as returned by Weaviate, around the given vectorConfig."""
29+
return {
30+
"class": "TestCollection",
31+
"vectorConfig": vector_config,
32+
"properties": [],
33+
"invertedIndexConfig": {
34+
"bm25": {"b": 0.75, "k1": 1.2},
35+
"cleanupIntervalSeconds": 60,
36+
"stopwords": {"preset": "en", "additions": None, "removals": None},
37+
},
38+
"multiTenancyConfig": {"enabled": False},
39+
"replicationConfig": {"factor": 1, "deletionStrategy": "NoAutomatedResolution"},
40+
"shardingConfig": {
41+
"virtualPerPhysical": 128,
42+
"desiredCount": 1,
43+
"actualCount": 1,
44+
"desiredVirtualCount": 128,
45+
"actualVirtualCount": 128,
46+
"key": "_id",
47+
"strategy": "hash",
48+
"function": "murmur3",
49+
},
50+
}
51+
52+
53+
def test_collection_config_from_json_with_dropped_vector_index() -> None:
54+
"""A vector whose index was dropped is returned without a vectorIndexConfig."""
55+
# Shape returned by Weaviate after `collection.config.delete_vector_index("dropped")`:
56+
# the entry stays in the schema, `vectorIndexType` becomes "none" and `vectorIndexConfig`
57+
# is omitted entirely.
58+
schema = _schema_with_vector_config(
59+
{
60+
"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"},
61+
"kept": {
62+
"vectorizer": {"none": {}},
63+
"vectorIndexType": "hnsw",
64+
"vectorIndexConfig": HNSW_CONFIG,
65+
},
66+
}
67+
)
68+
69+
config = _collection_config_from_json(schema)
70+
71+
assert config.vector_config is not None
72+
assert config.vector_config["dropped"].vector_index_config is None
73+
assert config.vector_config["kept"].vector_index_config is not None
74+
75+
# The dropped vector must round-trip back to the "none" index type the server reported.
76+
as_dict = config.to_dict()
77+
assert as_dict["vectorConfig"]["dropped"]["vectorIndexType"] == VectorIndexType.NONE.value
78+
assert "vectorIndexConfig" not in as_dict["vectorConfig"]["dropped"]
79+
assert as_dict["vectorConfig"]["kept"]["vectorIndexType"] == VectorIndexType.HNSW.value
80+
81+
82+
def test_collection_config_simple_from_json_with_dropped_vector_index() -> None:
83+
"""`collections.list_all()` must not choke on a collection with a dropped vector index."""
84+
schema = _schema_with_vector_config(
85+
{"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"}}
86+
)
87+
88+
config = _collection_config_simple_from_json(schema)
89+
90+
assert config.vector_config is not None
91+
assert config.vector_config["dropped"].vector_index_config is None
92+
893

994
def test_collection_config_simple_from_json_with_none_vectorizer_config() -> None:
1095
"""Test that _collection_configs_simple_from_json handles None vectorizer config."""

test/collection/test_config_update.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,48 @@ def test_replication_async_config_reset_all_fields() -> None:
160160
)
161161
result = update.merge_with_existing(schema)
162162
assert result["asyncConfig"] == {}
163+
164+
165+
@pytest.mark.parametrize("use_deprecated_syntax", [False, True])
166+
def test_updating_dropped_vector_index(use_deprecated_syntax: bool) -> None:
167+
"""A vector whose index was dropped has no index config to merge into."""
168+
schema = multi_vector_schema()
169+
# shape reported by Weaviate for a vector dropped via `config.delete_vector_index`
170+
schema["vectorConfig"]["boi"] = {"vectorizer": {"none": {}}, "vectorIndexType": "none"}
171+
172+
hnsw = Reconfigure.VectorIndex.hnsw(ef=128)
173+
update = (
174+
_CollectionConfigUpdate(
175+
vectorizer_config=[
176+
Reconfigure.NamedVectors.update(name="boi", vector_index_config=hnsw)
177+
]
178+
)
179+
if use_deprecated_syntax
180+
else _CollectionConfigUpdate(
181+
vector_config=[Reconfigure.Vectors.update(name="boi", vector_index_config=hnsw)]
182+
)
183+
)
184+
185+
with pytest.raises(WeaviateInvalidInputError, match="delete_vector_index"):
186+
update.merge_with_existing(schema)
187+
188+
189+
def test_updating_vector_next_to_dropped_vector_index() -> None:
190+
"""Vectors that still have an index remain updatable next to a dropped one."""
191+
schema = multi_vector_schema()
192+
schema["vectorConfig"]["boi"] = {"vectorizer": {"none": {}}, "vectorIndexType": "none"}
193+
194+
update = _CollectionConfigUpdate(
195+
vector_config=[
196+
Reconfigure.Vectors.update(
197+
name="yeh", vector_index_config=Reconfigure.VectorIndex.hnsw(ef=128)
198+
)
199+
]
200+
)
201+
new_schema = update.merge_with_existing(schema)
202+
203+
assert new_schema["vectorConfig"]["yeh"]["vectorIndexConfig"]["ef"] == 128
204+
assert new_schema["vectorConfig"]["boi"] == {
205+
"vectorizer": {"none": {}},
206+
"vectorIndexType": "none",
207+
}

weaviate/collections/classes/config.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,22 @@ def mutual_exclusivity(
14681468
)
14691469
return v
14701470

1471+
@staticmethod
1472+
def __existing_vector_index_config(schema: Dict[str, Any], name: str) -> Dict[str, Any]:
1473+
if name not in schema["vectorConfig"]:
1474+
raise WeaviateInvalidInputError(
1475+
f"Vector config with name {name} does not exist in the existing vector config"
1476+
)
1477+
existing = schema["vectorConfig"][name]
1478+
if "vectorIndexConfig" not in existing:
1479+
# the index was dropped with `collection.config.delete_vector_index`, Weaviate reports
1480+
# such a vector as `vectorIndexType: "none"` without any index config to merge into
1481+
raise WeaviateInvalidInputError(
1482+
f"Vector config with name {name} has no vector index, it was deleted with "
1483+
"collection.config.delete_vector_index() and cannot be re-created"
1484+
)
1485+
return cast(Dict[str, Any], existing["vectorIndexConfig"])
1486+
14711487
def __check_quantizers(
14721488
self,
14731489
quantizer: Optional[_QuantizerConfigUpdate],
@@ -1580,18 +1596,10 @@ def merge_with_existing(self, schema: Dict[str, Any]) -> Dict[str, Any]:
15801596
)
15811597
else:
15821598
for vc in self.vectorizerConfig:
1583-
if vc.name not in schema["vectorConfig"]:
1584-
raise WeaviateInvalidInputError(
1585-
f"Vector config with name {vc.name} does not exist in the existing vector config"
1586-
)
1587-
self.__check_quantizers(
1588-
vc.vectorIndexConfig.quantizer,
1589-
schema["vectorConfig"][vc.name]["vectorIndexConfig"],
1590-
)
1599+
existing = self.__existing_vector_index_config(schema, vc.name)
1600+
self.__check_quantizers(vc.vectorIndexConfig.quantizer, existing)
15911601
schema["vectorConfig"][vc.name]["vectorIndexConfig"] = (
1592-
vc.vectorIndexConfig.merge_with_existing(
1593-
schema["vectorConfig"][vc.name]["vectorIndexConfig"]
1594-
)
1602+
vc.vectorIndexConfig.merge_with_existing(existing)
15951603
)
15961604
schema["vectorConfig"][vc.name]["vectorIndexType"] = (
15971605
vc.vectorIndexConfig.vector_index_type()
@@ -1603,18 +1611,10 @@ def merge_with_existing(self, schema: Dict[str, Any]) -> Dict[str, Any]:
16031611
else self.vectorConfig
16041612
)
16051613
for vc in vcs:
1606-
if vc.name not in schema["vectorConfig"]:
1607-
raise WeaviateInvalidInputError(
1608-
f"Vector config with name {vc.name} does not exist in the existing vector config"
1609-
)
1610-
self.__check_quantizers(
1611-
vc.vectorIndexConfig.quantizer,
1612-
schema["vectorConfig"][vc.name]["vectorIndexConfig"],
1613-
)
1614+
existing = self.__existing_vector_index_config(schema, vc.name)
1615+
self.__check_quantizers(vc.vectorIndexConfig.quantizer, existing)
16141616
schema["vectorConfig"][vc.name]["vectorIndexConfig"] = (
1615-
vc.vectorIndexConfig.merge_with_existing(
1616-
schema["vectorConfig"][vc.name]["vectorIndexConfig"]
1617-
)
1617+
vc.vectorIndexConfig.merge_with_existing(existing)
16181618
)
16191619
schema["vectorConfig"][vc.name]["vectorIndexType"] = (
16201620
vc.vectorIndexConfig.vector_index_type()
@@ -2064,16 +2064,23 @@ def to_dict(self) -> Dict[str, Any]:
20642064
@dataclass
20652065
class _NamedVectorConfig(_ConfigBase):
20662066
vectorizer: _NamedVectorizerConfig
2067+
# `None` means the index of this vector was dropped with `collection.config.delete_vector_index`.
2068+
# The vector data is still stored, but there is no index to configure or search.
20672069
vector_index_config: Union[
20682070
VectorIndexConfigHNSW,
20692071
VectorIndexConfigFlat,
20702072
VectorIndexConfigDynamic,
20712073
VectorIndexConfigHFresh,
2074+
None,
20722075
]
20732076

20742077
def to_dict(self) -> Dict:
20752078
ret_dict = super().to_dict()
2076-
ret_dict["vectorIndexType"] = self.vector_index_config.vector_index_type()
2079+
ret_dict["vectorIndexType"] = (
2080+
VectorIndexType.NONE.value
2081+
if self.vector_index_config is None
2082+
else self.vector_index_config.vector_index_type()
2083+
)
20772084
return ret_dict
20782085

20792086

0 commit comments

Comments
 (0)