Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ci/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ 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. 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:
environment:
Expand Down
61 changes: 60 additions & 1 deletion integration/test_collection_config.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +12,7 @@
OpenAICollection,
_sanitize_collection_name,
)
from weaviate.collections import Collection
from weaviate.collections.classes.config import (
_BQConfig,
_CollectionConfig,
Expand All @@ -37,6 +39,7 @@
Rerankers,
_RerankerProvider,
Tokenization,
_NamedVectorConfig,
_NamedVectorConfigCreate,
_VectorizerConfigCreate,
IndexName,
Expand Down Expand Up @@ -2694,3 +2697,59 @@ 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

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

# searching the vector that still has an index keeps working
assert len(collection.query.near_vector([3, 4], target_vector="kept").objects) == 1
44 changes: 44 additions & 0 deletions mock_tests/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,50 @@ 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)
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
)
weaviate_mock.expect_request(
"/v1/schema/Test/vectors/disabled/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:
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(
metadata_capture_collection: tuple[
weaviate.collections.Collection, MockMetadataCaptureWeaviateService
Expand Down
151 changes: 151 additions & 0 deletions test/collection/test_config_methods.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,161 @@
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,
_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_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_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(
{
"future": {
"vectorizer": {"none": {}},
"vectorIndexType": "spann",
"vectorIndexConfig": {"distance": "cosine", "searchListSize": 100},
}
}
)

with pytest.raises(SchemaValidationError, match="unknown vectorIndexType"):
_collection_config_from_json(schema)


def _schema_without_any_vector() -> Dict[str, Any]:
"""Schema of a named-vector collection whose vectors were all dropped.

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",
"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_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_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


def test_collection_config_simple_from_json_with_none_vectorizer_config() -> None:
"""Test that _collection_configs_simple_from_json handles None vectorizer config."""
Expand Down
59 changes: 59 additions & 0 deletions test/collection/test_config_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,62 @@ 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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems like a test that should be in Weaviate and not the python client

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But it's basically testing a python functionality, the merge_with_existing. If this test would go as a go acceptance test it wouldn't invoke the same logic imho. It's not even using a Weaviate instance.

"""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",
}


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": []})
Loading
Loading