Skip to content

Commit 7e5cd7f

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 99e958c commit 7e5cd7f

9 files changed

Lines changed: 317 additions & 42 deletions

File tree

ci/docker-compose.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ services:
3131
DISABLE_LAZY_LOAD_SHARDS: 'true'
3232
GRPC_MAX_MESSAGE_SIZE: 100000000 # 100mb
3333
OBJECTS_TTL_DELETE_SCHEDULE: "@every 12h" # for objectTTL tests to work
34+
# for config.delete_vector_index tests to work. Can be dropped once 1.39.0 is GA, the endpoint
35+
# is enabled by default from then on. Ignored by servers that do not know the flag.
36+
ENABLE_EXPERIMENTAL_ALTER_SCHEMA_DROP_VECTOR_INDEX_ENDPOINT: 'true'
3437

3538
contextionary:
3639
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,
@@ -2038,3 +2041,77 @@ def test_delete_property_index(
20382041
assert config.properties[0].index_range_filters is False
20392042
assert config.properties[0].index_searchable is _index_searchable
20402043
assert config.properties[0].index_filterable is _index_filterable
2044+
2045+
2046+
def _vector_config_without_index(
2047+
collection: Collection[Any, Any], vector_name: str, timeout: float = 30
2048+
) -> Dict[str, _NamedVectorConfig]:
2049+
"""Poll the collection config until `vector_name` no longer has an index.
2050+
2051+
The drop is applied asynchronously, a 200 from the endpoint only means that Weaviate accepted
2052+
the request. It then becomes visible in two steps: the vector first stays in the schema with a
2053+
`vector_index_config` of `None`, and once the index is gone from disk the entry is removed from
2054+
the schema altogether. Both shapes must parse, so accept either.
2055+
"""
2056+
start = time.time()
2057+
while True:
2058+
vector_config = collection.config.get().vector_config
2059+
assert vector_config is not None
2060+
if (
2061+
vector_name not in vector_config
2062+
or vector_config[vector_name].vector_index_config is None
2063+
):
2064+
return vector_config
2065+
if time.time() - start > timeout:
2066+
pytest.fail(f"vector index of {vector_name} was not dropped within {timeout}s")
2067+
time.sleep(0.2)
2068+
2069+
2070+
def test_delete_vector_index(collection_factory: CollectionFactory) -> None:
2071+
"""Test that dropping the index of a named vector leaves the rest of the collection usable."""
2072+
collection_dummy = collection_factory("dummy")
2073+
if collection_dummy._connection._weaviate_version.is_lower_than(1, 39, 0):
2074+
pytest.skip("delete vector index not supported before 1.39.0")
2075+
2076+
collection = collection_factory(
2077+
properties=[Property(name="name", data_type=DataType.TEXT)],
2078+
vector_config=[
2079+
Configure.Vectors.self_provided(name="dropped"),
2080+
Configure.Vectors.self_provided(name="kept"),
2081+
],
2082+
)
2083+
collection.data.insert(
2084+
properties={"name": "banana"},
2085+
vector={"dropped": [1, 2], "kept": [3, 4]},
2086+
)
2087+
2088+
config = collection.config.get()
2089+
assert config.vector_config is not None
2090+
assert config.vector_config["dropped"].vector_index_config is not None
2091+
2092+
with pytest.raises(weaviate.exceptions.UnexpectedStatusCodeError):
2093+
collection.config.delete_vector_index("does_not_exist")
2094+
2095+
assert collection.config.delete_vector_index("dropped") is True
2096+
2097+
vector_config = _vector_config_without_index(collection, "dropped")
2098+
# vectors that were not dropped keep their index
2099+
assert vector_config["kept"].vector_index_config is not None
2100+
2101+
# `list_all` parses the schema through the simple config parser, it must cope with the drop too
2102+
with weaviate.connect_to_local() as client:
2103+
simple = client.collections.list_all()[collection.name]
2104+
assert simple.vector_config is not None
2105+
assert simple.vector_config["kept"].vector_index_config is not None
2106+
2107+
# searching the vector that still has an index keeps working
2108+
assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1
2109+
2110+
2111+
def test_delete_vector_index_invalid_input(collection_factory: CollectionFactory) -> None:
2112+
"""Test that a non-string vector name is rejected before hitting the network."""
2113+
collection = collection_factory(
2114+
vector_config=[Configure.Vectors.self_provided(name="vec")],
2115+
)
2116+
with pytest.raises(WeaviateInvalidInputError):
2117+
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: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,92 @@
1-
from weaviate.collections.classes.config_methods import _collection_configs_simple_from_json
1+
from typing import Any, Dict
2+
3+
from weaviate.collections.classes.config import VectorIndexType
4+
from weaviate.collections.classes.config_methods import (
5+
_collection_config_from_json,
6+
_collection_config_simple_from_json,
7+
_collection_configs_simple_from_json,
8+
)
9+
10+
HNSW_CONFIG = {
11+
"skip": False,
12+
"cleanupIntervalSeconds": 300,
13+
"maxConnections": 64,
14+
"efConstruction": 128,
15+
"ef": -1,
16+
"dynamicEfMin": 100,
17+
"dynamicEfMax": 500,
18+
"dynamicEfFactor": 8,
19+
"vectorCacheMaxObjects": 1000000000000,
20+
"flatSearchCutoff": 40000,
21+
"distance": "cosine",
22+
}
23+
24+
25+
def _schema_with_vector_config(vector_config: Dict[str, Any]) -> Dict[str, Any]:
26+
"""Build a minimal collection schema, as returned by Weaviate, around the given vectorConfig."""
27+
return {
28+
"class": "TestCollection",
29+
"vectorConfig": vector_config,
30+
"properties": [],
31+
"invertedIndexConfig": {
32+
"bm25": {"b": 0.75, "k1": 1.2},
33+
"cleanupIntervalSeconds": 60,
34+
"stopwords": {"preset": "en", "additions": None, "removals": None},
35+
},
36+
"multiTenancyConfig": {"enabled": False},
37+
"replicationConfig": {"factor": 1, "deletionStrategy": "NoAutomatedResolution"},
38+
"shardingConfig": {
39+
"virtualPerPhysical": 128,
40+
"desiredCount": 1,
41+
"actualCount": 1,
42+
"desiredVirtualCount": 128,
43+
"actualVirtualCount": 128,
44+
"key": "_id",
45+
"strategy": "hash",
46+
"function": "murmur3",
47+
},
48+
}
49+
50+
51+
def test_collection_config_from_json_with_dropped_vector_index() -> None:
52+
"""A vector whose index was dropped is returned without a vectorIndexConfig."""
53+
# Shape returned by Weaviate after `collection.config.delete_vector_index("dropped")`:
54+
# the entry stays in the schema, `vectorIndexType` becomes "none" and `vectorIndexConfig`
55+
# is omitted entirely.
56+
schema = _schema_with_vector_config(
57+
{
58+
"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"},
59+
"kept": {
60+
"vectorizer": {"none": {}},
61+
"vectorIndexType": "hnsw",
62+
"vectorIndexConfig": HNSW_CONFIG,
63+
},
64+
}
65+
)
66+
67+
config = _collection_config_from_json(schema)
68+
69+
assert config.vector_config is not None
70+
assert config.vector_config["dropped"].vector_index_config is None
71+
assert config.vector_config["kept"].vector_index_config is not None
72+
73+
# The dropped vector must round-trip back to the "none" index type the server reported.
74+
as_dict = config.to_dict()
75+
assert as_dict["vectorConfig"]["dropped"]["vectorIndexType"] == VectorIndexType.NONE.value
76+
assert "vectorIndexConfig" not in as_dict["vectorConfig"]["dropped"]
77+
assert as_dict["vectorConfig"]["kept"]["vectorIndexType"] == VectorIndexType.HNSW.value
78+
79+
80+
def test_collection_config_simple_from_json_with_dropped_vector_index() -> None:
81+
"""`collections.list_all()` must not choke on a collection with a dropped vector index."""
82+
schema = _schema_with_vector_config(
83+
{"dropped": {"vectorizer": {"none": {}}, "vectorIndexType": "none"}}
84+
)
85+
86+
config = _collection_config_simple_from_json(schema)
87+
88+
assert config.vector_config is not None
89+
assert config.vector_config["dropped"].vector_index_config is None
290

391

492
def test_collection_config_simple_from_json_with_none_vectorizer_config() -> None:

test/collection/test_config_update.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,48 @@ def test_enabling_sq_multi_vector(schema: dict, should_error: bool) -> None:
102102
assert new_schema["vectorConfig"]["boi"]["vectorIndexConfig"]["sq"]["enabled"]
103103

104104
assert new_schema["vectorConfig"]["yeh"] == schema["vectorConfig"]["yeh"]
105+
106+
107+
@pytest.mark.parametrize("use_deprecated_syntax", [False, True])
108+
def test_updating_dropped_vector_index(use_deprecated_syntax: bool) -> None:
109+
"""A vector whose index was dropped has no index config to merge into."""
110+
schema = multi_vector_schema()
111+
# shape reported by Weaviate for a vector dropped via `config.delete_vector_index`
112+
schema["vectorConfig"]["boi"] = {"vectorizer": {"none": {}}, "vectorIndexType": "none"}
113+
114+
hnsw = Reconfigure.VectorIndex.hnsw(ef=128)
115+
update = (
116+
_CollectionConfigUpdate(
117+
vectorizer_config=[
118+
Reconfigure.NamedVectors.update(name="boi", vector_index_config=hnsw)
119+
]
120+
)
121+
if use_deprecated_syntax
122+
else _CollectionConfigUpdate(
123+
vector_config=[Reconfigure.Vectors.update(name="boi", vector_index_config=hnsw)]
124+
)
125+
)
126+
127+
with pytest.raises(WeaviateInvalidInputError, match="delete_vector_index"):
128+
update.merge_with_existing(schema)
129+
130+
131+
def test_updating_vector_next_to_dropped_vector_index() -> None:
132+
"""Vectors that still have an index remain updatable next to a dropped one."""
133+
schema = multi_vector_schema()
134+
schema["vectorConfig"]["boi"] = {"vectorizer": {"none": {}}, "vectorIndexType": "none"}
135+
136+
update = _CollectionConfigUpdate(
137+
vector_config=[
138+
Reconfigure.Vectors.update(
139+
name="yeh", vector_index_config=Reconfigure.VectorIndex.hnsw(ef=128)
140+
)
141+
]
142+
)
143+
new_schema = update.merge_with_existing(schema)
144+
145+
assert new_schema["vectorConfig"]["yeh"]["vectorIndexConfig"]["ef"] == 128
146+
assert new_schema["vectorConfig"]["boi"] == {
147+
"vectorizer": {"none": {}},
148+
"vectorIndexType": "none",
149+
}

weaviate/collections/classes/config.py

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,6 +1442,22 @@ def mutual_exclusivity(
14421442
)
14431443
return v
14441444

1445+
@staticmethod
1446+
def __existing_vector_index_config(schema: Dict[str, Any], name: str) -> Dict[str, Any]:
1447+
if name not in schema["vectorConfig"]:
1448+
raise WeaviateInvalidInputError(
1449+
f"Vector config with name {name} does not exist in the existing vector config"
1450+
)
1451+
existing = schema["vectorConfig"][name]
1452+
if "vectorIndexConfig" not in existing:
1453+
# the index was dropped with `collection.config.delete_vector_index`, Weaviate reports
1454+
# such a vector as `vectorIndexType: "none"` without any index config to merge into
1455+
raise WeaviateInvalidInputError(
1456+
f"Vector config with name {name} has no vector index, it was deleted with "
1457+
"collection.config.delete_vector_index() and cannot be re-created"
1458+
)
1459+
return cast(Dict[str, Any], existing["vectorIndexConfig"])
1460+
14451461
def __check_quantizers(
14461462
self,
14471463
quantizer: Optional[_QuantizerConfigUpdate],
@@ -1554,18 +1570,10 @@ def merge_with_existing(self, schema: Dict[str, Any]) -> Dict[str, Any]:
15541570
)
15551571
else:
15561572
for vc in self.vectorizerConfig:
1557-
if vc.name not in schema["vectorConfig"]:
1558-
raise WeaviateInvalidInputError(
1559-
f"Vector config with name {vc.name} does not exist in the existing vector config"
1560-
)
1561-
self.__check_quantizers(
1562-
vc.vectorIndexConfig.quantizer,
1563-
schema["vectorConfig"][vc.name]["vectorIndexConfig"],
1564-
)
1573+
existing = self.__existing_vector_index_config(schema, vc.name)
1574+
self.__check_quantizers(vc.vectorIndexConfig.quantizer, existing)
15651575
schema["vectorConfig"][vc.name]["vectorIndexConfig"] = (
1566-
vc.vectorIndexConfig.merge_with_existing(
1567-
schema["vectorConfig"][vc.name]["vectorIndexConfig"]
1568-
)
1576+
vc.vectorIndexConfig.merge_with_existing(existing)
15691577
)
15701578
schema["vectorConfig"][vc.name]["vectorIndexType"] = (
15711579
vc.vectorIndexConfig.vector_index_type()
@@ -1577,18 +1585,10 @@ def merge_with_existing(self, schema: Dict[str, Any]) -> Dict[str, Any]:
15771585
else self.vectorConfig
15781586
)
15791587
for vc in vcs:
1580-
if vc.name not in schema["vectorConfig"]:
1581-
raise WeaviateInvalidInputError(
1582-
f"Vector config with name {vc.name} does not exist in the existing vector config"
1583-
)
1584-
self.__check_quantizers(
1585-
vc.vectorIndexConfig.quantizer,
1586-
schema["vectorConfig"][vc.name]["vectorIndexConfig"],
1587-
)
1588+
existing = self.__existing_vector_index_config(schema, vc.name)
1589+
self.__check_quantizers(vc.vectorIndexConfig.quantizer, existing)
15881590
schema["vectorConfig"][vc.name]["vectorIndexConfig"] = (
1589-
vc.vectorIndexConfig.merge_with_existing(
1590-
schema["vectorConfig"][vc.name]["vectorIndexConfig"]
1591-
)
1591+
vc.vectorIndexConfig.merge_with_existing(existing)
15921592
)
15931593
schema["vectorConfig"][vc.name]["vectorIndexType"] = (
15941594
vc.vectorIndexConfig.vector_index_type()
@@ -1981,16 +1981,23 @@ def to_dict(self) -> Dict[str, Any]:
19811981
@dataclass
19821982
class _NamedVectorConfig(_ConfigBase):
19831983
vectorizer: _NamedVectorizerConfig
1984+
# `None` means the index of this vector was dropped with `collection.config.delete_vector_index`.
1985+
# The vector data is still stored, but there is no index to configure or search.
19841986
vector_index_config: Union[
19851987
VectorIndexConfigHNSW,
19861988
VectorIndexConfigFlat,
19871989
VectorIndexConfigDynamic,
19881990
VectorIndexConfigHFresh,
1991+
None,
19891992
]
19901993

19911994
def to_dict(self) -> Dict:
19921995
ret_dict = super().to_dict()
1993-
ret_dict["vectorIndexType"] = self.vector_index_config.vector_index_type()
1996+
ret_dict["vectorIndexType"] = (
1997+
VectorIndexType.NONE.value
1998+
if self.vector_index_config is None
1999+
else self.vector_index_config.vector_index_type()
2000+
)
19942001
return ret_dict
19952002

19962003

0 commit comments

Comments
 (0)