修改为东南天坐标系
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,282 @@
|
||||
from time import sleep
|
||||
import threading
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.api.types import IndexingStatus
|
||||
from chromadb.test.conftest import skip_if_not_cluster
|
||||
from chromadb.test.utils.wait_for_version_increase import (
|
||||
get_collection_version,
|
||||
wait_for_version_increase,
|
||||
)
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_empty_collection(client: ClientAPI) -> None:
|
||||
"""Test indexing status on empty collection"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="test_collection")
|
||||
status = collection.get_indexing_status()
|
||||
|
||||
assert isinstance(status, IndexingStatus)
|
||||
assert status.num_indexed_ops == 0
|
||||
assert status.num_unindexed_ops == 0
|
||||
assert status.total_ops == 0
|
||||
assert status.op_indexing_progress == 1.0
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_after_add(client: ClientAPI) -> None:
|
||||
"""Test indexing status after adding embeddings"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="test_collection")
|
||||
|
||||
ids = [f"id_{i}" for i in range(300)]
|
||||
embeddings = [[float(i), float(i + 1), float(i + 2)] for i in range(300)]
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
collection.add(ids=ids, embeddings=embeddings) # type: ignore
|
||||
|
||||
status = collection.get_indexing_status()
|
||||
assert status.total_ops == 300
|
||||
|
||||
if initial_version == get_collection_version(client, collection.name):
|
||||
assert isinstance(status, IndexingStatus)
|
||||
assert status.num_unindexed_ops == 300
|
||||
assert status.num_indexed_ops == 0
|
||||
assert status.op_indexing_progress == 0.0
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
# Give some time to invalidate the frontend query cache
|
||||
sleep(60)
|
||||
|
||||
# Check status after indexing completes
|
||||
final_status = collection.get_indexing_status()
|
||||
assert isinstance(final_status, IndexingStatus)
|
||||
assert final_status.num_indexed_ops == 300
|
||||
assert final_status.num_unindexed_ops == 0
|
||||
assert final_status.op_indexing_progress == 1.0
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_after_upsert(client: ClientAPI) -> None:
|
||||
"""Test indexing status after upsert operations"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="test_collection")
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
collection.upsert(ids=["id1", "id2"], embeddings=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) # type: ignore
|
||||
|
||||
status = collection.get_indexing_status()
|
||||
assert status.total_ops == 2
|
||||
|
||||
if initial_version == get_collection_version(client, collection.name):
|
||||
assert isinstance(status, IndexingStatus)
|
||||
assert status.num_unindexed_ops == 2
|
||||
assert status.num_indexed_ops == 0
|
||||
assert status.op_indexing_progress == 0.0
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
sleep(60)
|
||||
|
||||
collection.upsert(ids=["id1", "id3"], embeddings=[[1.1, 2.1, 3.1], [7.0, 8.0, 9.0]]) # type: ignore
|
||||
|
||||
status = collection.get_indexing_status()
|
||||
assert status.total_ops == 4
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_after_delete(client: ClientAPI) -> None:
|
||||
"""Test indexing status after delete operations"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="test_collection")
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
collection.add(
|
||||
ids=["id1", "id2", "id3"],
|
||||
embeddings=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], # type: ignore
|
||||
)
|
||||
|
||||
if initial_version == get_collection_version(client, collection.name):
|
||||
status = collection.get_indexing_status()
|
||||
assert isinstance(status, IndexingStatus)
|
||||
assert status.num_unindexed_ops == 3
|
||||
assert status.num_indexed_ops == 0
|
||||
assert status.op_indexing_progress == 0.0
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
sleep(60)
|
||||
|
||||
initial_status = collection.get_indexing_status()
|
||||
assert initial_status.total_ops == 3
|
||||
|
||||
collection.delete(ids=["id1", "id2"])
|
||||
|
||||
# Delete adds operations to the log, so total_ops increases
|
||||
status_after_delete = collection.get_indexing_status()
|
||||
assert status_after_delete.total_ops == 5
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_field_types(client: ClientAPI) -> None:
|
||||
"""Test that indexing status returns correct field types"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="field_types_collection")
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
collection.add(ids=["type_test_id"], embeddings=[[1.0, 2.0, 3.0]]) # type: ignore
|
||||
|
||||
status = collection.get_indexing_status()
|
||||
|
||||
if initial_version == get_collection_version(client, collection.name):
|
||||
assert isinstance(status, IndexingStatus)
|
||||
assert status.num_unindexed_ops == 1
|
||||
assert status.num_indexed_ops == 0
|
||||
assert status.op_indexing_progress == 0.0
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
sleep(60)
|
||||
|
||||
final_status = collection.get_indexing_status()
|
||||
|
||||
assert isinstance(final_status.num_indexed_ops, int)
|
||||
assert isinstance(final_status.num_unindexed_ops, int)
|
||||
assert isinstance(final_status.total_ops, int)
|
||||
assert isinstance(final_status.op_indexing_progress, float)
|
||||
|
||||
assert final_status.num_indexed_ops >= 0
|
||||
assert final_status.num_unindexed_ops >= 0
|
||||
assert final_status.total_ops >= 0
|
||||
assert 0.0 <= final_status.op_indexing_progress <= 1.0
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_batch_progression(client: ClientAPI) -> None:
|
||||
"""Test indexing status with 2000 records based on index version progression"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="batch_test_collection")
|
||||
get_collection_version(client, collection.name)
|
||||
|
||||
# Insert 2000 records in two batches of 1000 (max batch size)
|
||||
ids_1 = [f"batch_id_{i}" for i in range(1000)]
|
||||
embeddings_1 = [[float(i), float(i + 1), float(i + 2)] for i in range(1000)]
|
||||
collection.add(ids=ids_1, embeddings=embeddings_1) # type: ignore
|
||||
|
||||
ids_2 = [f"batch_id_{i}" for i in range(1000, 2000)]
|
||||
embeddings_2 = [[float(i), float(i + 1), float(i + 2)] for i in range(1000, 2000)]
|
||||
collection.add(ids=ids_2, embeddings=embeddings_2) # type: ignore
|
||||
|
||||
current_version = get_collection_version(client, collection.name)
|
||||
|
||||
allowed_statuses = [
|
||||
IndexingStatus(
|
||||
num_indexed_ops=0,
|
||||
num_unindexed_ops=2000,
|
||||
total_ops=2000,
|
||||
op_indexing_progress=0.0,
|
||||
),
|
||||
IndexingStatus(
|
||||
num_indexed_ops=1000,
|
||||
num_unindexed_ops=1000,
|
||||
total_ops=2000,
|
||||
op_indexing_progress=0.5,
|
||||
),
|
||||
IndexingStatus(
|
||||
num_indexed_ops=2000,
|
||||
num_unindexed_ops=0,
|
||||
total_ops=2000,
|
||||
op_indexing_progress=1.0,
|
||||
),
|
||||
]
|
||||
|
||||
ops_indexed = 0
|
||||
while ops_indexed < 2000:
|
||||
status = collection.get_indexing_status()
|
||||
assert status in allowed_statuses
|
||||
print("witnessed status: ", status)
|
||||
ops_indexed = status.num_indexed_ops
|
||||
wait_for_version_increase(client, collection.name, current_version)
|
||||
sleep(60)
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_not_found(client: ClientAPI) -> None:
|
||||
"""Test indexing status on non-existent collection"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="temp_collection")
|
||||
client.delete_collection("temp_collection")
|
||||
|
||||
try:
|
||||
collection.get_indexing_status()
|
||||
assert False, "Expected exception for non-existent collection"
|
||||
except Exception as e:
|
||||
assert (
|
||||
"not found" in str(e).lower()
|
||||
or "does not exist" in str(e).lower()
|
||||
or "soft deleted" in str(e).lower()
|
||||
or "collection not found" in str(e).lower()
|
||||
)
|
||||
|
||||
|
||||
@skip_if_not_cluster()
|
||||
def test_indexing_status_concurrent_progress_variation(client: ClientAPI) -> None:
|
||||
"""Test that progress values vary during concurrent operations"""
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="concurrent_test_collection")
|
||||
|
||||
progress_values = []
|
||||
stop_monitoring = threading.Event()
|
||||
|
||||
def progress_monitor() -> None:
|
||||
"""Thread that continuously monitors indexing progress"""
|
||||
while not stop_monitoring.is_set():
|
||||
try:
|
||||
status = collection.get_indexing_status()
|
||||
progress_values.append(status.op_indexing_progress)
|
||||
sleep(0.1) # Poll every 100ms
|
||||
except Exception:
|
||||
break
|
||||
|
||||
def record_adder() -> None:
|
||||
"""Thread that adds records one at a time"""
|
||||
for i in range(50): # Add 50 records one by one
|
||||
collection.add(
|
||||
ids=[f"concurrent_id_{i}"],
|
||||
embeddings=[[float(i), float(i + 1), float(i + 2)]], # type: ignore
|
||||
)
|
||||
sleep(0.05) # Small delay between additions
|
||||
|
||||
# Start both threads
|
||||
monitor_thread = threading.Thread(target=progress_monitor)
|
||||
adder_thread = threading.Thread(target=record_adder)
|
||||
|
||||
monitor_thread.start()
|
||||
adder_thread.start()
|
||||
|
||||
# Wait for record addition to complete
|
||||
adder_thread.join()
|
||||
|
||||
# Give a moment for final progress updates
|
||||
sleep(1.0)
|
||||
|
||||
# Stop monitoring and wait for thread to finish
|
||||
stop_monitoring.set()
|
||||
monitor_thread.join()
|
||||
|
||||
# Assert that we collected progress values
|
||||
assert len(progress_values) > 0, "No progress values were collected"
|
||||
|
||||
# Assert that not all progress values are 0
|
||||
non_zero_progress = [p for p in progress_values if p > 0.0]
|
||||
assert len(non_zero_progress) > 0, "All progress values remained at 0"
|
||||
|
||||
# Assert that there's variation in progress values
|
||||
unique_progress = set(progress_values)
|
||||
assert (
|
||||
len(unique_progress) > 1
|
||||
), f"Expected variation in progress values, but only got: {unique_progress}"
|
||||
|
||||
print(f"Collected {len(progress_values)} progress values")
|
||||
print(f"Unique progress values: {sorted(unique_progress)}")
|
||||
print(f"Progress range: {min(progress_values):.3f} - {max(progress_values):.3f}")
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Tests for the Search API endpoint."""
|
||||
|
||||
from typing import Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.api.models.Collection import Collection
|
||||
from chromadb.api.types import Embeddings, ReadLevel
|
||||
from chromadb.execution.expression import Knn, Search
|
||||
from chromadb.test.conftest import (
|
||||
ClientFactories,
|
||||
is_spann_disabled_mode,
|
||||
skip_reason_spann_disabled,
|
||||
)
|
||||
|
||||
|
||||
def _create_test_collection(
|
||||
client_factories: ClientFactories,
|
||||
) -> Tuple[Collection, ClientAPI]:
|
||||
"""Create a test collection with some data."""
|
||||
client = client_factories.create_client_from_system()
|
||||
client.reset()
|
||||
|
||||
collection_name = f"search_api_test_{uuid4().hex}"
|
||||
collection = client.get_or_create_collection(name=collection_name)
|
||||
|
||||
return collection, client
|
||||
|
||||
|
||||
@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
|
||||
def test_search_with_read_level_index_and_wal(
|
||||
client_factories: ClientFactories,
|
||||
) -> None:
|
||||
"""Test search with ReadLevel.INDEX_AND_WAL (default) returns results."""
|
||||
collection, _ = _create_test_collection(client_factories)
|
||||
|
||||
# Add some data
|
||||
collection.add(
|
||||
ids=["doc1", "doc2", "doc3"],
|
||||
documents=["apple fruit", "banana fruit", "car vehicle"],
|
||||
embeddings=[[0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.9, 0.8, 0.7, 0.6]],
|
||||
)
|
||||
|
||||
# Search with explicit INDEX_AND_WAL (default behavior)
|
||||
search = Search().rank(Knn(query=[0.1, 0.2, 0.3, 0.4], limit=10))
|
||||
results = collection.search(search, read_level=ReadLevel.INDEX_AND_WAL)
|
||||
|
||||
assert results["ids"] is not None
|
||||
assert len(results["ids"]) == 1
|
||||
assert len(results["ids"][0]) > 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
|
||||
def test_search_with_read_level_index_only(
|
||||
client_factories: ClientFactories,
|
||||
) -> None:
|
||||
"""Test search with ReadLevel.INDEX_ONLY returns results."""
|
||||
collection, _ = _create_test_collection(client_factories)
|
||||
|
||||
# Add some data
|
||||
collection.add(
|
||||
ids=["doc1", "doc2", "doc3"],
|
||||
documents=["apple fruit", "banana fruit", "car vehicle"],
|
||||
embeddings=[[0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.9, 0.8, 0.7, 0.6]],
|
||||
)
|
||||
|
||||
# Search with INDEX_ONLY - this skips the WAL
|
||||
# Note: Results may or may not include recent writes depending on compaction state
|
||||
search = Search().rank(Knn(query=[0.1, 0.2, 0.3, 0.4], limit=10))
|
||||
results = collection.search(search, read_level=ReadLevel.INDEX_ONLY)
|
||||
|
||||
# Just verify the API works and returns a valid response structure
|
||||
assert results["ids"] is not None
|
||||
assert len(results["ids"]) == 1
|
||||
# Results may be empty if data hasn't been compacted yet, which is expected behavior
|
||||
|
||||
|
||||
@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
|
||||
def test_search_default_read_level(
|
||||
client_factories: ClientFactories,
|
||||
) -> None:
|
||||
"""Test search without explicit read_level uses default (INDEX_AND_WAL)."""
|
||||
collection, _ = _create_test_collection(client_factories)
|
||||
|
||||
# Add some data
|
||||
collection.add(
|
||||
ids=["doc1", "doc2"],
|
||||
documents=["hello world", "goodbye world"],
|
||||
embeddings=[[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]],
|
||||
)
|
||||
|
||||
# Search without specifying read_level (should use default)
|
||||
search = Search().rank(Knn(query=[0.1, 0.2, 0.3, 0.4], limit=10))
|
||||
results = collection.search(search)
|
||||
|
||||
# Should return results since default is INDEX_AND_WAL (full consistency)
|
||||
assert results["ids"] is not None
|
||||
assert len(results["ids"]) == 1
|
||||
assert len(results["ids"][0]) > 0
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from chromadb.api.shared_system_client import SharedSystemClient
|
||||
from chromadb.api.base_http_client import BaseHTTPClient
|
||||
from chromadb.config import System
|
||||
from typing import Optional, Dict, Generator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache() -> Generator[None, None, None]:
|
||||
"""Automatically clear the system cache before and after each test."""
|
||||
SharedSystemClient.clear_system_cache()
|
||||
yield
|
||||
SharedSystemClient.clear_system_cache()
|
||||
|
||||
|
||||
def create_mock_http_client(
|
||||
api_url: Optional[str] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> MagicMock:
|
||||
"""Create a mock BaseHTTPClient instance with the specified configuration."""
|
||||
mock_server_api = MagicMock(spec=BaseHTTPClient)
|
||||
|
||||
mock_server_api.get_api_url.return_value = api_url or ""
|
||||
mock_server_api.get_request_headers.return_value = headers or {}
|
||||
|
||||
return mock_server_api
|
||||
|
||||
|
||||
def register_mock_system(system_id: str, mock_server_api: MagicMock) -> MagicMock:
|
||||
"""Register a mock system with the given ID and server API."""
|
||||
mock_system = MagicMock(spec=System)
|
||||
mock_system.instance.return_value = mock_server_api
|
||||
SharedSystemClient._identifier_to_system[system_id] = mock_system
|
||||
return mock_system
|
||||
|
||||
|
||||
def test_extracts_api_key_from_chroma_cloud_client() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"X-Chroma-Token": "test-api-key-123"},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key == "test-api-key-123"
|
||||
|
||||
|
||||
def test_extracts_api_key_with_lowercase_header() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"x-chroma-token": "test-api-key-456"},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key == "test-api-key-456"
|
||||
|
||||
|
||||
def test_extracts_api_key_from_gcp_chroma_cloud_client() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url="https://dummy.gcp.trychroma.com/api/v2",
|
||||
headers={"X-Chroma-Token": "gcp-test-api-key"},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key == "gcp-test-api-key"
|
||||
|
||||
|
||||
def test_skips_non_chroma_cloud_clients() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url="https://localhost:8000/api/v2",
|
||||
headers={"X-Chroma-Token": "local-api-key"},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key is None
|
||||
|
||||
|
||||
def test_skips_clients_without_api_url() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url=None,
|
||||
headers={"X-Chroma-Token": "test-api-key"},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key is None
|
||||
|
||||
|
||||
def test_returns_none_when_no_api_key_in_headers() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key is None
|
||||
|
||||
|
||||
def test_returns_first_api_key_found_from_multiple_clients() -> None:
|
||||
mock_server_api_1 = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"X-Chroma-Token": "first-key"},
|
||||
)
|
||||
mock_server_api_2 = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"X-Chroma-Token": "second-key"},
|
||||
)
|
||||
register_mock_system("test-id-1", mock_server_api_1)
|
||||
register_mock_system("test-id-2", mock_server_api_2)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key == "first-key"
|
||||
|
||||
|
||||
def test_handles_exception_gracefully() -> None:
|
||||
mock_system = MagicMock(spec=System)
|
||||
mock_system.instance.side_effect = Exception("Test exception")
|
||||
SharedSystemClient._identifier_to_system["test-id"] = mock_system
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key is None
|
||||
|
||||
|
||||
def test_returns_none_when_no_clients_exist() -> None:
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key is None
|
||||
|
||||
|
||||
def test_skips_non_http_clients() -> None:
|
||||
"""Test that non-BaseHTTPClient instances are skipped."""
|
||||
mock_server_api = MagicMock() # Not a BaseHTTPClient
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key is None
|
||||
|
||||
|
||||
def test_extracts_api_key_with_mixed_case_header() -> None:
|
||||
mock_server_api = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"X-CHROMA-TOKEN": "mixed-case-key"},
|
||||
)
|
||||
register_mock_system("test-id", mock_server_api)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key == "mixed-case-key"
|
||||
|
||||
|
||||
def test_multiple_clients_returns_one_key() -> None:
|
||||
"""Test that multiple clients return one of the available keys."""
|
||||
mock_api_1 = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"X-Chroma-Token": "key-1"},
|
||||
)
|
||||
mock_api_2 = create_mock_http_client(
|
||||
api_url="https://api.trychroma.com/api/v2",
|
||||
headers={"X-Chroma-Token": "key-2"},
|
||||
)
|
||||
register_mock_system("id-1", mock_api_1)
|
||||
register_mock_system("id-2", mock_api_2)
|
||||
|
||||
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
|
||||
|
||||
assert api_key in ["key-1", "key-2"]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from chromadb.api import ServerAPI
|
||||
from chromadb.config import DEFAULT_TENANT
|
||||
|
||||
|
||||
def test_delete_database_requires_rbac_permission(
|
||||
api_with_authn_rbac_authz: ServerAPI,
|
||||
) -> None:
|
||||
database_name = f"db_{uuid.uuid4().hex}"
|
||||
api_with_authn_rbac_authz.create_database(database_name, tenant=DEFAULT_TENANT)
|
||||
|
||||
with pytest.raises(Exception, match="Forbidden"):
|
||||
api_with_authn_rbac_authz.delete_database(database_name, tenant=DEFAULT_TENANT)
|
||||
|
||||
db = api_with_authn_rbac_authz.get_database(database_name, tenant=DEFAULT_TENANT)
|
||||
assert db["name"] == database_name
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,478 @@
|
||||
"""
|
||||
Integration test for the Collection statistics wrapper methods
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from chromadb.api.client import Client as ClientCreator
|
||||
from chromadb.base_types import SparseVector
|
||||
from chromadb.config import System
|
||||
from chromadb.test.utils.wait_for_version_increase import (
|
||||
get_collection_version,
|
||||
wait_for_version_increase,
|
||||
)
|
||||
from chromadb.utils.statistics import (
|
||||
attach_statistics_function,
|
||||
detach_statistics_function,
|
||||
get_statistics,
|
||||
get_statistics_fn_name,
|
||||
)
|
||||
|
||||
|
||||
def test_statistics_wrapper(basic_http_client: System) -> None:
|
||||
"""Test the statistics wrapper methods on Collection"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
# Create a collection
|
||||
collection = client.get_or_create_collection(
|
||||
name="test_collection",
|
||||
metadata={"description": "Test collection for statistics"},
|
||||
)
|
||||
|
||||
# Enable statistics
|
||||
attached_fn, created = attach_statistics_function(collection, "test_collection_statistics")
|
||||
assert attached_fn is not None
|
||||
assert created is True
|
||||
assert attached_fn.function_name == "statistics"
|
||||
assert attached_fn.output_collection == "test_collection_statistics"
|
||||
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Add some documents with metadata
|
||||
collection.add(
|
||||
ids=["doc1", "doc2", "doc3"],
|
||||
documents=["test document 1", "test document 2", "test document 3"],
|
||||
metadatas=[
|
||||
{"category": "A", "score": 10, "active": True},
|
||||
{"category": "B", "score": 10, "active": False},
|
||||
{"category": "A", "score": 20, "active": True},
|
||||
],
|
||||
)
|
||||
|
||||
# Wait for statistics to be computed
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
time.sleep(60)
|
||||
|
||||
# Get statistics
|
||||
stats = get_statistics(collection, "test_collection_statistics")
|
||||
print("\nStatistics output:")
|
||||
print(json.dumps(stats, indent=2))
|
||||
|
||||
# Verify the structure
|
||||
assert "statistics" in stats
|
||||
assert "summary" in stats
|
||||
|
||||
# Verify summary
|
||||
assert stats["summary"]["total_count"] == 3
|
||||
|
||||
# Verify category statistics
|
||||
assert "category" in stats["statistics"]
|
||||
assert "A" in stats["statistics"]["category"]
|
||||
assert "B" in stats["statistics"]["category"]
|
||||
assert stats["statistics"]["category"]["A"]["count"] == 2
|
||||
assert stats["statistics"]["category"]["B"]["count"] == 1
|
||||
|
||||
# Verify score statistics
|
||||
assert "score" in stats["statistics"]
|
||||
assert "10" in stats["statistics"]["score"]
|
||||
assert "20" in stats["statistics"]["score"]
|
||||
assert stats["statistics"]["score"]["10"]["count"] == 2
|
||||
assert stats["statistics"]["score"]["20"]["count"] == 1
|
||||
|
||||
# Verify active statistics
|
||||
assert "active" in stats["statistics"]
|
||||
assert "true" in stats["statistics"]["active"]
|
||||
assert "false" in stats["statistics"]["active"]
|
||||
assert stats["statistics"]["active"]["true"]["count"] == 2
|
||||
assert stats["statistics"]["active"]["false"]["count"] == 1
|
||||
|
||||
# Test get_attached_function
|
||||
stats_fn = collection.get_attached_function(get_statistics_fn_name(collection))
|
||||
assert stats_fn.function_name == "statistics"
|
||||
|
||||
# Disable statistics (keep the collection)
|
||||
success = detach_statistics_function(collection, delete_stats_collection=False)
|
||||
assert success is True
|
||||
|
||||
# Verify the statistics collection still exists
|
||||
stats_collection = client.get_collection("test_collection_statistics")
|
||||
assert stats_collection is not None
|
||||
|
||||
|
||||
def test_backfill_statistics(basic_http_client: System) -> None:
|
||||
"""Test backfill statistics"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="my_collection")
|
||||
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Add some documents with metadata
|
||||
collection.add(
|
||||
ids=["doc1", "doc2", "doc3"],
|
||||
documents=["test document 1", "test document 2", "test document 3"],
|
||||
metadatas=[
|
||||
{"category": "A", "score": 10, "active": True},
|
||||
{"category": "B", "score": 10, "active": False},
|
||||
{"category": "A", "score": 20, "active": True},
|
||||
],
|
||||
)
|
||||
|
||||
# Let this all be compacted
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Enable statistics
|
||||
attached_fn, created = attach_statistics_function(collection, "my_collection_statistics")
|
||||
assert created is True
|
||||
assert attached_fn.function_name == "statistics"
|
||||
assert attached_fn.output_collection == "my_collection_statistics"
|
||||
|
||||
# Wait for statistics to be computed
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
stats = get_statistics(collection, "my_collection_statistics")
|
||||
assert stats is not None
|
||||
assert "statistics" in stats
|
||||
assert "summary" in stats
|
||||
|
||||
# Verify summary
|
||||
assert stats["summary"]["total_count"] == 3
|
||||
|
||||
# Verify category statistics
|
||||
assert "category" in stats["statistics"]
|
||||
assert "A" in stats["statistics"]["category"]
|
||||
assert "B" in stats["statistics"]["category"]
|
||||
assert stats["statistics"]["category"]["A"]["count"] == 2
|
||||
assert stats["statistics"]["category"]["B"]["count"] == 1
|
||||
|
||||
# Verify score statistics
|
||||
assert "score" in stats["statistics"]
|
||||
assert "10" in stats["statistics"]["score"]
|
||||
assert "20" in stats["statistics"]["score"]
|
||||
assert stats["statistics"]["score"]["10"]["count"] == 2
|
||||
assert stats["statistics"]["score"]["20"]["count"] == 1
|
||||
|
||||
# Verify active statistics
|
||||
assert "active" in stats["statistics"]
|
||||
assert "true" in stats["statistics"]["active"]
|
||||
assert "false" in stats["statistics"]["active"]
|
||||
assert stats["statistics"]["active"]["true"]["count"] == 2
|
||||
assert stats["statistics"]["active"]["false"]["count"] == 1
|
||||
|
||||
# Disable statistics
|
||||
success = detach_statistics_function(collection, delete_stats_collection=True)
|
||||
assert success is True
|
||||
|
||||
|
||||
def test_statistics_wrapper_custom_output_collection(basic_http_client: System) -> None:
|
||||
"""Test statistics with custom output collection name"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="my_collection")
|
||||
|
||||
# Enable statistics with custom output collection name
|
||||
attached_fn, created = attach_statistics_function(
|
||||
collection, stats_collection_name="my_custom_stats"
|
||||
)
|
||||
assert created is True
|
||||
assert attached_fn.output_collection == "my_custom_stats"
|
||||
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Add data
|
||||
collection.add(
|
||||
ids=["id1"],
|
||||
documents=["doc1"],
|
||||
metadatas=[{"key": "value"}],
|
||||
)
|
||||
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
# Get statistics
|
||||
stats = get_statistics(collection, "my_custom_stats")
|
||||
assert "statistics" in stats
|
||||
assert "key" in stats["statistics"]
|
||||
|
||||
# Disable and delete the custom collection
|
||||
detach_statistics_function(collection, delete_stats_collection=True)
|
||||
|
||||
|
||||
def test_statistics_wrapper_key_filter(basic_http_client: System) -> None:
|
||||
"""Test get_statistics with key filter parameter"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="key_filter_test")
|
||||
|
||||
# Enable statistics
|
||||
_, created = attach_statistics_function(collection, "key_filter_test_statistics")
|
||||
assert created is True
|
||||
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Add documents with multiple metadata keys
|
||||
collection.add(
|
||||
ids=["doc1", "doc2", "doc3"],
|
||||
documents=["test document 1", "test document 2", "test document 3"],
|
||||
metadatas=[
|
||||
{"category": "A", "score": 10, "active": True},
|
||||
{"category": "B", "score": 10, "active": False},
|
||||
{"category": "A", "score": 20, "active": True},
|
||||
],
|
||||
)
|
||||
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
time.sleep(60)
|
||||
|
||||
# Get all statistics (no key filter)
|
||||
all_stats = get_statistics(collection, "key_filter_test_statistics")
|
||||
assert "category" in all_stats["statistics"]
|
||||
assert "score" in all_stats["statistics"]
|
||||
assert "active" in all_stats["statistics"]
|
||||
|
||||
# Get statistics filtered by "category" key only
|
||||
category_stats = get_statistics(
|
||||
collection, "key_filter_test_statistics", keys=["category"]
|
||||
)
|
||||
assert "category" in category_stats["statistics"]
|
||||
assert "score" not in category_stats["statistics"]
|
||||
assert "active" not in category_stats["statistics"]
|
||||
assert category_stats["statistics"]["category"]["A"]["count"] == 2
|
||||
assert category_stats["statistics"]["category"]["B"]["count"] == 1
|
||||
# Summary should still be present when filtering by key
|
||||
assert "summary" in category_stats
|
||||
assert category_stats["summary"]["total_count"] == 3
|
||||
|
||||
# Get statistics filtered by "score" key only
|
||||
score_stats = get_statistics(
|
||||
collection, "key_filter_test_statistics", keys=["score"]
|
||||
)
|
||||
assert "score" in score_stats["statistics"]
|
||||
assert "category" not in score_stats["statistics"]
|
||||
assert "active" not in score_stats["statistics"]
|
||||
assert score_stats["statistics"]["score"]["10"]["count"] == 2
|
||||
assert score_stats["statistics"]["score"]["20"]["count"] == 1
|
||||
# Summary should still be present when filtering by key
|
||||
assert "summary" in score_stats
|
||||
assert score_stats["summary"]["total_count"] == 3
|
||||
|
||||
# Cleanup
|
||||
detach_statistics_function(collection, delete_stats_collection=True)
|
||||
|
||||
|
||||
def test_statistics_wrapper_key_filter_too_many_keys(basic_http_client: System) -> None:
|
||||
"""Test that get_statistics raises ValueError when more than 30 keys are provided"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="too_many_keys_test")
|
||||
|
||||
# Enable statistics
|
||||
attach_statistics_function(collection, "too_many_keys_test_statistics")
|
||||
|
||||
# Generate more than 30 keys
|
||||
too_many_keys = [f"key_{i}" for i in range(31)]
|
||||
|
||||
# Should raise ValueError when more than 30 keys are provided
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_statistics(collection, "too_many_keys_test_statistics", keys=too_many_keys)
|
||||
|
||||
assert "Too many keys provided: 31" in str(exc_info.value)
|
||||
assert "Maximum allowed is 30" in str(exc_info.value)
|
||||
|
||||
# Cleanup
|
||||
detach_statistics_function(collection, delete_stats_collection=True)
|
||||
|
||||
|
||||
# commenting out for now as waiting for query cache invalidateion slows down the test suite
|
||||
def test_statistics_wrapper_incremental_updates(basic_http_client: System) -> None:
|
||||
"""Test that statistics are updated incrementally"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="incremental_test")
|
||||
_, created = attach_statistics_function(collection, "incremental_test_statistics")
|
||||
assert created is True
|
||||
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Add initial batch
|
||||
collection.add(
|
||||
ids=["id1", "id2"],
|
||||
documents=["doc1", "doc2"],
|
||||
metadatas=[{"category": "A"}, {"category": "A"}],
|
||||
)
|
||||
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
next_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Check initial statistics
|
||||
stats = get_statistics(collection, "incremental_test_statistics")
|
||||
assert stats["statistics"]["category"]["A"]["count"] == 2
|
||||
assert stats["summary"]["total_count"] == 2
|
||||
|
||||
# Add more data
|
||||
collection.add(
|
||||
ids=["id3", "id4"],
|
||||
documents=["doc3", "doc4"],
|
||||
metadatas=[{"category": "B"}, {"category": "A"}],
|
||||
)
|
||||
|
||||
wait_for_version_increase(client, collection.name, next_version)
|
||||
# TODO(tanujnay112): Remove this sleep once query cache invalidation is solidified
|
||||
# or figure out a different testing harness where we don't have to wait for query cache invalidation
|
||||
time.sleep(70)
|
||||
|
||||
# Check updated statistics
|
||||
stats = get_statistics(collection, "incremental_test_statistics")
|
||||
assert stats["statistics"]["category"]["A"]["count"] == 3
|
||||
assert stats["statistics"]["category"]["B"]["count"] == 1
|
||||
assert stats["summary"]["total_count"] == 4
|
||||
|
||||
detach_statistics_function(collection, delete_stats_collection=True)
|
||||
|
||||
|
||||
def test_sparse_vector_statistics(basic_http_client: System) -> None:
|
||||
"""Test statistics with sparse vector that includes labels"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="sparse_vector_test1")
|
||||
|
||||
# Create sparse vectors with labels
|
||||
sparse_vec1 = SparseVector(
|
||||
indices=[100, 200, 300],
|
||||
values=[1.0, 2.0, 3.0],
|
||||
labels=["apple", "banana", "cherry"],
|
||||
)
|
||||
sparse_vec2 = SparseVector(
|
||||
indices=[100, 400], values=[1.5, 2.5], labels=["apple", "date"]
|
||||
)
|
||||
sparse_vec3 = SparseVector(
|
||||
indices=[200, 300], values=[2.0, 3.0], labels=["banana", "cherry"]
|
||||
)
|
||||
|
||||
# Add data with sparse vectors
|
||||
collection.add(
|
||||
ids=["id1", "id2", "id3"],
|
||||
documents=["doc1", "doc2", "doc3"],
|
||||
metadatas=[
|
||||
{"category": "A", "vec": sparse_vec1},
|
||||
{"category": "B", "vec": sparse_vec2},
|
||||
{"category": "A", "vec": sparse_vec3},
|
||||
],
|
||||
)
|
||||
_, created = attach_statistics_function(collection, "sparse_vector_test1_statistics")
|
||||
assert created is True
|
||||
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
# Get statistics
|
||||
stats = get_statistics(collection, "sparse_vector_test1_statistics")
|
||||
print("\nSparse vector statistics output:")
|
||||
print(json.dumps(stats, indent=2))
|
||||
|
||||
assert "statistics" in stats
|
||||
assert "summary" in stats
|
||||
assert stats["summary"]["total_count"] == 3
|
||||
|
||||
# Verify category statistics
|
||||
assert "category" in stats["statistics"]
|
||||
assert "A" in stats["statistics"]["category"]
|
||||
assert "B" in stats["statistics"]["category"]
|
||||
assert stats["statistics"]["category"]["A"]["count"] == 2
|
||||
assert stats["statistics"]["category"]["B"]["count"] == 1
|
||||
|
||||
# Verify sparse vector statistics use labels instead of hash IDs
|
||||
assert "vec" in stats["statistics"]
|
||||
assert "apple" in stats["statistics"]["vec"], "Should use label 'apple' not hash ID"
|
||||
assert (
|
||||
"banana" in stats["statistics"]["vec"]
|
||||
), "Should use label 'banana' not hash ID"
|
||||
assert (
|
||||
"cherry" in stats["statistics"]["vec"]
|
||||
), "Should use label 'cherry' not hash ID"
|
||||
assert "date" in stats["statistics"]["vec"], "Should use label 'date' not hash ID"
|
||||
|
||||
# Verify counts
|
||||
assert stats["statistics"]["vec"]["apple"]["count"] == 2 # in id1 and id2
|
||||
assert stats["statistics"]["vec"]["banana"]["count"] == 2 # in id1 and id3
|
||||
assert stats["statistics"]["vec"]["cherry"]["count"] == 2 # in id1 and id3
|
||||
assert stats["statistics"]["vec"]["date"]["count"] == 1 # in id2 only
|
||||
|
||||
|
||||
def test_statistics_high_cardinality(basic_http_client: System) -> None:
|
||||
"""Test statistics with high cardinality metadata"""
|
||||
client = ClientCreator.from_system(basic_http_client)
|
||||
client.reset()
|
||||
|
||||
collection = client.create_collection(name="high_cardinality_test")
|
||||
|
||||
# Generate 500 documents with 10 metadata fields each
|
||||
num_docs = 500
|
||||
num_fields = 10
|
||||
ids = [f"id{i}" for i in range(num_docs)]
|
||||
documents = [f"doc{i}" for i in range(num_docs)]
|
||||
|
||||
metadatas: list[dict[str, Any]] = []
|
||||
for i in range(num_docs):
|
||||
meta: dict[str, Any] = {}
|
||||
for j in range(num_fields):
|
||||
meta[f"field_{j}"] = f"value_{j}_{i}"
|
||||
metadatas.append(meta)
|
||||
|
||||
# Add in batches to avoid hitting request size limits
|
||||
batch_size = 100
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
for i in range(0, num_docs, batch_size):
|
||||
collection.add(
|
||||
ids=ids[i : i + batch_size],
|
||||
documents=documents[i : i + batch_size],
|
||||
metadatas=metadatas[i : i + batch_size], # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# Let all data be compacted
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
initial_version = get_collection_version(client, collection.name)
|
||||
|
||||
# Enable statistics
|
||||
_, created = attach_statistics_function(collection, "high_cardinality_test_statistics")
|
||||
assert created is True
|
||||
|
||||
# Wait for statistics to be computed
|
||||
wait_for_version_increase(client, collection.name, initial_version)
|
||||
|
||||
# Get statistics
|
||||
stats = get_statistics(collection, "high_cardinality_test_statistics")
|
||||
|
||||
assert "statistics" in stats
|
||||
|
||||
# Verify we have stats for all fields
|
||||
for j in range(num_fields):
|
||||
field_key = f"field_{j}"
|
||||
assert field_key in stats["statistics"]
|
||||
|
||||
field_stats = stats["statistics"][field_key]
|
||||
assert len(field_stats) == num_docs
|
||||
|
||||
# Verify each value has count 1
|
||||
for i in range(num_docs):
|
||||
value = f"value_{j}_{i}"
|
||||
assert value in field_stats
|
||||
assert field_stats[value]["count"] == 1
|
||||
|
||||
# Verify total count
|
||||
assert stats["summary"]["total_count"] == num_docs
|
||||
|
||||
detach_statistics_function(collection, delete_stats_collection=True)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,784 @@
|
||||
import math
|
||||
from typing import Any, Dict, Optional, Set, Tuple, cast
|
||||
|
||||
from hypothesis import given
|
||||
|
||||
from chromadb.api import ClientAPI
|
||||
from chromadb.api.collection_configuration import CreateCollectionConfiguration
|
||||
from chromadb.api.types import (
|
||||
CollectionMetadata,
|
||||
EMBEDDING_KEY,
|
||||
Schema,
|
||||
)
|
||||
from chromadb.test.property import strategies
|
||||
from chromadb.test.property.invariants import check_metadata
|
||||
from chromadb.test.conftest import (
|
||||
reset,
|
||||
is_spann_disabled_mode,
|
||||
)
|
||||
|
||||
|
||||
HNSW_METADATA_TO_CONFIG: Dict[str, str] = {
|
||||
"hnsw:space": "space",
|
||||
"hnsw:construction_ef": "ef_construction",
|
||||
"hnsw:search_ef": "ef_search",
|
||||
"hnsw:M": "max_neighbors",
|
||||
"hnsw:sync_threshold": "sync_threshold",
|
||||
"hnsw:resize_factor": "resize_factor",
|
||||
}
|
||||
|
||||
HNSW_FIELDS = [
|
||||
"space",
|
||||
"ef_construction",
|
||||
"ef_search",
|
||||
"max_neighbors",
|
||||
"sync_threshold",
|
||||
"resize_factor",
|
||||
]
|
||||
|
||||
HNSW_DEFAULTS: Dict[str, Any] = {
|
||||
"space": "l2",
|
||||
"ef_construction": 100,
|
||||
"ef_search": 100,
|
||||
"max_neighbors": 16,
|
||||
"sync_threshold": 1000,
|
||||
"resize_factor": 1.2,
|
||||
}
|
||||
|
||||
SPANN_FIELDS = [
|
||||
"space",
|
||||
"search_nprobe",
|
||||
"write_nprobe",
|
||||
"ef_construction",
|
||||
"ef_search",
|
||||
"max_neighbors",
|
||||
"reassign_neighbor_count",
|
||||
"split_threshold",
|
||||
"merge_threshold",
|
||||
]
|
||||
|
||||
SPANN_DEFAULTS: Dict[str, Any] = {
|
||||
"space": "l2",
|
||||
"search_nprobe": 64,
|
||||
"write_nprobe": 32,
|
||||
"ef_construction": 200,
|
||||
"ef_search": 200,
|
||||
"max_neighbors": 64,
|
||||
"reassign_neighbor_count": 64,
|
||||
"split_threshold": 50,
|
||||
"merge_threshold": 25,
|
||||
}
|
||||
|
||||
|
||||
def _extract_vector_configs_from_schema(
|
||||
schema: Schema,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
defaults_float = schema.defaults.float_list
|
||||
assert defaults_float is not None
|
||||
defaults_vi = defaults_float.vector_index
|
||||
assert defaults_vi is not None
|
||||
|
||||
embedding_float = schema.keys[EMBEDDING_KEY].float_list
|
||||
assert embedding_float is not None
|
||||
embedding_vi = embedding_float.vector_index
|
||||
assert embedding_vi is not None
|
||||
|
||||
return (
|
||||
strategies.vector_index_to_dict(defaults_vi.config),
|
||||
strategies.vector_index_to_dict(embedding_vi.config),
|
||||
)
|
||||
|
||||
|
||||
def _compute_expected_config_spann(
|
||||
metadata: Optional[CollectionMetadata],
|
||||
configuration: Optional[CreateCollectionConfiguration],
|
||||
schema_vector_index_config: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
# start off creating default spann config, we slowly modify it to much whatever prop test provides
|
||||
expected = SPANN_DEFAULTS.copy()
|
||||
space_set = False
|
||||
# theres some edge cases where space is set in hnsw config and in metadata
|
||||
# in this case, we check if the space set by config is not the default, and if so, we don't try to get use the one from metadata
|
||||
# essentially if either metadata or hnsw config provides a non-default space, we use that one, with config hnsw taking priority over metadata
|
||||
should_try_metadata = True
|
||||
|
||||
if configuration:
|
||||
spann_cfg = configuration.get("spann")
|
||||
if spann_cfg:
|
||||
spann_cfg_dict = cast(Dict[str, Any], spann_cfg)
|
||||
# update expected with whatever prop test provides
|
||||
expected.update(strategies.non_none_items(spann_cfg_dict))
|
||||
# if space is set in spann, this now takes priority over all else
|
||||
if spann_cfg_dict.get("space") is not None:
|
||||
expected["space"] = spann_cfg_dict["space"]
|
||||
space_set = True
|
||||
should_try_metadata = False
|
||||
hnsw_cfg = configuration.get("hnsw")
|
||||
if hnsw_cfg:
|
||||
hnsw_cfg_dict = cast(Dict[str, Any], hnsw_cfg)
|
||||
hnsw_non_none = strategies.non_none_items(hnsw_cfg_dict)
|
||||
for key, value in hnsw_non_none.items():
|
||||
if value is not None and value != HNSW_DEFAULTS[key]:
|
||||
# if any hnsw config is not the default, we do not use metadata at all, this is used
|
||||
# heres a sample case where this is needed: hnsw doesnt set space (so l2 by default), but sets ef_construction, metadata sets space to ip
|
||||
# in this case, they were aware of hnsw config, and chose not to set space in it. therefore the config takes priority over metadata
|
||||
should_try_metadata = False
|
||||
# when SPANN is active and HNSW config is provided, use space from hnsw config
|
||||
if hnsw_cfg_dict.get("space") is not None and not space_set:
|
||||
# if the space set by config is not the default, don't try to get use the one from metadata
|
||||
if hnsw_cfg_dict.get("space") != HNSW_DEFAULTS["space"]:
|
||||
should_try_metadata = False
|
||||
expected["space"] = hnsw_cfg_dict["space"]
|
||||
space_set = True
|
||||
|
||||
if schema_vector_index_config:
|
||||
if schema_vector_index_config.get("space") is not None:
|
||||
expected["space"] = schema_vector_index_config["space"]
|
||||
space_set = True
|
||||
if schema_vector_index_config.get("spann"):
|
||||
spann_schema = strategies.non_none_items(
|
||||
schema_vector_index_config["spann"]
|
||||
)
|
||||
expected.update(spann_schema)
|
||||
|
||||
if (
|
||||
metadata
|
||||
and metadata.get("hnsw:space") is not None
|
||||
and metadata.get("hnsw:space") != SPANN_DEFAULTS["space"]
|
||||
and should_try_metadata
|
||||
):
|
||||
expected["space"] = metadata["hnsw:space"]
|
||||
space_set = True
|
||||
|
||||
if (
|
||||
schema_vector_index_config
|
||||
and schema_vector_index_config.get("embedding_function_default_space")
|
||||
is not None
|
||||
and schema_vector_index_config.get("embedding_function_default_space")
|
||||
!= SPANN_DEFAULTS["space"]
|
||||
and not space_set
|
||||
):
|
||||
expected["space"] = schema_vector_index_config[
|
||||
"embedding_function_default_space"
|
||||
]
|
||||
space_set = True
|
||||
|
||||
if (
|
||||
not space_set
|
||||
and configuration
|
||||
and configuration.get("embedding_function") is not None
|
||||
):
|
||||
ef = configuration["embedding_function"]
|
||||
if hasattr(ef, "default_space"):
|
||||
expected["space"] = cast(Any, ef).default_space()
|
||||
|
||||
return expected
|
||||
|
||||
|
||||
def _compute_expected_config_hnsw(
|
||||
metadata: Optional[CollectionMetadata],
|
||||
configuration: Optional[CreateCollectionConfiguration],
|
||||
schema_vector_index_config: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
expected = HNSW_DEFAULTS.copy()
|
||||
space_set = False
|
||||
configured_hnsw_keys: Set[str] = set()
|
||||
should_try_metadata = True
|
||||
|
||||
if configuration:
|
||||
hnsw_cfg_raw = configuration.get("hnsw")
|
||||
if hnsw_cfg_raw is not None:
|
||||
hnsw_dict: Dict[str, Any] = cast(Dict[str, Any], hnsw_cfg_raw)
|
||||
hnsw_non_none = strategies.non_none_items(hnsw_dict)
|
||||
expected.update(hnsw_non_none)
|
||||
for key, value in hnsw_non_none.items():
|
||||
# if any hnsw config is not the default, we do not use metadata at all
|
||||
if value is not None and value != HNSW_DEFAULTS[key]:
|
||||
should_try_metadata = False
|
||||
configured_hnsw_keys.update(hnsw_non_none.keys())
|
||||
if hnsw_non_none.get("space") is not None and not space_set:
|
||||
if hnsw_non_none.get("space") != HNSW_DEFAULTS["space"]:
|
||||
should_try_metadata = False
|
||||
space_set = True
|
||||
spann_cfg_raw = configuration.get("spann")
|
||||
if spann_cfg_raw is not None:
|
||||
spann_dict: Dict[str, Any] = cast(Dict[str, Any], spann_cfg_raw)
|
||||
if spann_dict.get("space") is not None and not space_set:
|
||||
expected["space"] = spann_dict["space"]
|
||||
space_set = True
|
||||
should_try_metadata = False
|
||||
|
||||
if should_try_metadata and metadata:
|
||||
for key, cfg_key in HNSW_METADATA_TO_CONFIG.items():
|
||||
if metadata.get(key) is None:
|
||||
continue
|
||||
if cfg_key == "space":
|
||||
expected[cfg_key] = metadata[key]
|
||||
space_set = True
|
||||
configured_hnsw_keys.add(cfg_key)
|
||||
continue
|
||||
if cfg_key not in configured_hnsw_keys:
|
||||
expected[cfg_key] = metadata[key]
|
||||
configured_hnsw_keys.add(cfg_key)
|
||||
|
||||
if schema_vector_index_config:
|
||||
if schema_vector_index_config.get("space") is not None:
|
||||
expected["space"] = schema_vector_index_config["space"]
|
||||
space_set = True
|
||||
if schema_vector_index_config.get("hnsw"):
|
||||
expected.update(
|
||||
strategies.non_none_items(schema_vector_index_config["hnsw"])
|
||||
)
|
||||
elif schema_vector_index_config.get("spann"):
|
||||
# Schema provided SPANN configuration while HNSW is active; ignore.
|
||||
pass
|
||||
|
||||
if (
|
||||
schema_vector_index_config
|
||||
and schema_vector_index_config.get("embedding_function_default_space")
|
||||
is not None
|
||||
and not space_set
|
||||
):
|
||||
expected["space"] = schema_vector_index_config[
|
||||
"embedding_function_default_space"
|
||||
]
|
||||
space_set = True
|
||||
|
||||
if (
|
||||
not space_set
|
||||
and configuration
|
||||
and configuration.get("embedding_function") is not None
|
||||
):
|
||||
ef = configuration["embedding_function"]
|
||||
if hasattr(ef, "default_space"):
|
||||
expected["space"] = cast(Any, ef).default_space()
|
||||
|
||||
return expected
|
||||
|
||||
|
||||
def _compute_expected_config(
|
||||
spann_active: bool,
|
||||
metadata: Optional[CollectionMetadata],
|
||||
configuration: Optional[CreateCollectionConfiguration],
|
||||
schema_vector_index_config: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
some assumptions/assertions:
|
||||
1. we are not testing failure paths. any config built is/should be valid. invalid cases can be tested separately or in e2e tests
|
||||
ex: if configuration is set, schema is not set. if schema is set, configuration is not set. both hnsw and spann cannot be set at the same time in config or schema
|
||||
"""
|
||||
if spann_active:
|
||||
return _compute_expected_config_spann(
|
||||
metadata, configuration, schema_vector_index_config
|
||||
)
|
||||
else:
|
||||
return _compute_expected_config_hnsw(
|
||||
metadata, configuration, schema_vector_index_config
|
||||
)
|
||||
|
||||
|
||||
def _assert_config_values(
|
||||
actual: Dict[str, Any],
|
||||
expected: Dict[str, Any],
|
||||
spann_active: bool,
|
||||
) -> None:
|
||||
fields = SPANN_FIELDS if spann_active else HNSW_FIELDS
|
||||
for field in fields:
|
||||
actual_value = actual.get(field)
|
||||
expected_value = expected[field]
|
||||
# Use approximate equality for floating-point values
|
||||
if isinstance(actual_value, float) and isinstance(expected_value, float):
|
||||
assert math.isclose(
|
||||
actual_value, expected_value, rel_tol=1e-9, abs_tol=1e-9
|
||||
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
|
||||
else:
|
||||
assert (
|
||||
actual_value == expected_value
|
||||
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
|
||||
|
||||
|
||||
def _assert_schema_values(
|
||||
vector_info: Dict[str, Any],
|
||||
expected: Dict[str, Any],
|
||||
spann_active: bool,
|
||||
) -> None:
|
||||
assert vector_info["space"] == expected["space"]
|
||||
if spann_active:
|
||||
spann_cfg = cast(Optional[Dict[str, Any]], vector_info["spann"])
|
||||
assert spann_cfg is not None
|
||||
for field in SPANN_FIELDS:
|
||||
if field == "space":
|
||||
continue
|
||||
actual_value = spann_cfg.get(field)
|
||||
expected_value = expected[field]
|
||||
# Use approximate equality for floating-point values
|
||||
if isinstance(actual_value, float) and isinstance(expected_value, float):
|
||||
assert math.isclose(
|
||||
actual_value, expected_value, rel_tol=1e-9, abs_tol=1e-9
|
||||
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
|
||||
else:
|
||||
assert (
|
||||
actual_value == expected_value
|
||||
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
|
||||
else:
|
||||
hnsw_cfg = cast(Optional[Dict[str, Any]], vector_info["hnsw"])
|
||||
assert hnsw_cfg is not None
|
||||
for field in HNSW_FIELDS:
|
||||
if field == "space":
|
||||
continue
|
||||
actual_value = hnsw_cfg.get(field)
|
||||
expected_value = expected[field]
|
||||
# Use approximate equality for floating-point values
|
||||
if isinstance(actual_value, float) and isinstance(expected_value, float):
|
||||
assert math.isclose(
|
||||
actual_value, expected_value, rel_tol=1e-9, abs_tol=1e-9
|
||||
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
|
||||
else:
|
||||
assert (
|
||||
actual_value == expected_value
|
||||
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
|
||||
|
||||
|
||||
def _get_default_schema_indexes() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get expected index states for default schema (when schema=None).
|
||||
Based on Schema._initialize_defaults() and _initialize_keys().
|
||||
"""
|
||||
return {
|
||||
"defaults": {
|
||||
"string_inverted": {"enabled": True},
|
||||
"int_inverted": {"enabled": True},
|
||||
"float_inverted": {"enabled": True},
|
||||
"bool_inverted": {"enabled": True},
|
||||
"sparse_vector": {"enabled": False},
|
||||
"fts_index": {"enabled": False},
|
||||
"vector_index": {"enabled": False},
|
||||
},
|
||||
"#document": {
|
||||
"string_inverted": {"enabled": False},
|
||||
"fts_index": {"enabled": True},
|
||||
},
|
||||
"#embedding": {
|
||||
"vector_index": {"enabled": True},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _extract_expected_schema_indexes(
|
||||
schema: Schema,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Extract expected index states from input schema.
|
||||
Returns a dict mapping key -> index_type -> enabled/config info.
|
||||
"""
|
||||
expected: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Check defaults
|
||||
if schema.defaults.string and schema.defaults.string.string_inverted_index:
|
||||
if "defaults" not in expected:
|
||||
expected["defaults"] = {}
|
||||
expected["defaults"]["string_inverted"] = {
|
||||
"enabled": schema.defaults.string.string_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if schema.defaults.int_value and schema.defaults.int_value.int_inverted_index:
|
||||
if "defaults" not in expected:
|
||||
expected["defaults"] = {}
|
||||
expected["defaults"]["int_inverted"] = {
|
||||
"enabled": schema.defaults.int_value.int_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if schema.defaults.float_value and schema.defaults.float_value.float_inverted_index:
|
||||
if "defaults" not in expected:
|
||||
expected["defaults"] = {}
|
||||
expected["defaults"]["float_inverted"] = {
|
||||
"enabled": schema.defaults.float_value.float_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if schema.defaults.boolean and schema.defaults.boolean.bool_inverted_index:
|
||||
if "defaults" not in expected:
|
||||
expected["defaults"] = {}
|
||||
expected["defaults"]["bool_inverted"] = {
|
||||
"enabled": schema.defaults.boolean.bool_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if (
|
||||
schema.defaults.sparse_vector
|
||||
and schema.defaults.sparse_vector.sparse_vector_index
|
||||
):
|
||||
if "defaults" not in expected:
|
||||
expected["defaults"] = {}
|
||||
expected["defaults"]["sparse_vector"] = {
|
||||
"enabled": schema.defaults.sparse_vector.sparse_vector_index.enabled,
|
||||
"config": schema.defaults.sparse_vector.sparse_vector_index.config,
|
||||
}
|
||||
|
||||
# Check per-key indexes
|
||||
for key, value_types in schema.keys.items():
|
||||
if key in (EMBEDDING_KEY, "#document"):
|
||||
# Skip special keys - they're handled by vector index test
|
||||
continue
|
||||
|
||||
key_expected: Dict[str, Any] = {}
|
||||
|
||||
if value_types.string and value_types.string.string_inverted_index:
|
||||
key_expected["string_inverted"] = {
|
||||
"enabled": value_types.string.string_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if value_types.int_value and value_types.int_value.int_inverted_index:
|
||||
key_expected["int_inverted"] = {
|
||||
"enabled": value_types.int_value.int_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if value_types.float_value and value_types.float_value.float_inverted_index:
|
||||
key_expected["float_inverted"] = {
|
||||
"enabled": value_types.float_value.float_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if value_types.boolean and value_types.boolean.bool_inverted_index:
|
||||
key_expected["bool_inverted"] = {
|
||||
"enabled": value_types.boolean.bool_inverted_index.enabled,
|
||||
}
|
||||
|
||||
if value_types.sparse_vector and value_types.sparse_vector.sparse_vector_index:
|
||||
key_expected["sparse_vector"] = {
|
||||
"enabled": value_types.sparse_vector.sparse_vector_index.enabled,
|
||||
"config": value_types.sparse_vector.sparse_vector_index.config,
|
||||
}
|
||||
|
||||
if key_expected:
|
||||
expected[key] = key_expected
|
||||
|
||||
return expected
|
||||
|
||||
|
||||
def _assert_schema_indexes(
|
||||
actual_schema: Schema,
|
||||
expected_indexes: Dict[str, Dict[str, Any]],
|
||||
) -> None:
|
||||
"""Assert that the actual schema matches expected index states."""
|
||||
|
||||
# Check defaults
|
||||
if "defaults" in expected_indexes:
|
||||
defaults_expected = expected_indexes["defaults"]
|
||||
defaults_actual = actual_schema.defaults
|
||||
|
||||
if "string_inverted" in defaults_expected:
|
||||
expected_enabled = defaults_expected["string_inverted"]["enabled"]
|
||||
actual_string = defaults_actual.string
|
||||
if actual_string and actual_string.string_inverted_index:
|
||||
assert (
|
||||
actual_string.string_inverted_index.enabled == expected_enabled
|
||||
), f"defaults string_inverted enabled mismatch: expected {expected_enabled}, got {actual_string.string_inverted_index.enabled}"
|
||||
else:
|
||||
# If not explicitly set, defaults should be enabled
|
||||
assert expected_enabled, "defaults string_inverted should be enabled"
|
||||
|
||||
if "int_inverted" in defaults_expected:
|
||||
expected_enabled = defaults_expected["int_inverted"]["enabled"]
|
||||
actual_int = defaults_actual.int_value
|
||||
if actual_int and actual_int.int_inverted_index:
|
||||
assert (
|
||||
actual_int.int_inverted_index.enabled == expected_enabled
|
||||
), f"defaults int_inverted enabled mismatch: expected {expected_enabled}, got {actual_int.int_inverted_index.enabled}"
|
||||
else:
|
||||
assert expected_enabled, "defaults int_inverted should be enabled"
|
||||
|
||||
if "float_inverted" in defaults_expected:
|
||||
expected_enabled = defaults_expected["float_inverted"]["enabled"]
|
||||
actual_float = defaults_actual.float_value
|
||||
if actual_float and actual_float.float_inverted_index:
|
||||
assert (
|
||||
actual_float.float_inverted_index.enabled == expected_enabled
|
||||
), f"defaults float_inverted enabled mismatch: expected {expected_enabled}, got {actual_float.float_inverted_index.enabled}"
|
||||
else:
|
||||
assert expected_enabled, "defaults float_inverted should be enabled"
|
||||
|
||||
if "bool_inverted" in defaults_expected:
|
||||
expected_enabled = defaults_expected["bool_inverted"]["enabled"]
|
||||
actual_bool = defaults_actual.boolean
|
||||
if actual_bool and actual_bool.bool_inverted_index:
|
||||
assert (
|
||||
actual_bool.bool_inverted_index.enabled == expected_enabled
|
||||
), f"defaults bool_inverted enabled mismatch: expected {expected_enabled}, got {actual_bool.bool_inverted_index.enabled}"
|
||||
else:
|
||||
assert expected_enabled, "defaults bool_inverted should be enabled"
|
||||
|
||||
if "sparse_vector" in defaults_expected:
|
||||
expected_enabled = defaults_expected["sparse_vector"]["enabled"]
|
||||
actual_sparse = defaults_actual.sparse_vector
|
||||
assert actual_sparse is not None, "defaults sparse_vector should exist"
|
||||
assert (
|
||||
actual_sparse.sparse_vector_index is not None
|
||||
), "defaults sparse_vector_index should exist"
|
||||
assert (
|
||||
actual_sparse.sparse_vector_index.enabled == expected_enabled
|
||||
), f"defaults sparse_vector enabled mismatch: expected {expected_enabled}, got {actual_sparse.sparse_vector_index.enabled}"
|
||||
# Validate config fields if config is provided in expected
|
||||
if "config" in defaults_expected["sparse_vector"]:
|
||||
expected_config = defaults_expected["sparse_vector"]["config"]
|
||||
actual_config = actual_sparse.sparse_vector_index.config
|
||||
if expected_config.bm25 is not None:
|
||||
assert (
|
||||
actual_config.bm25 == expected_config.bm25
|
||||
), f"defaults sparse_vector bm25 mismatch: expected {expected_config.bm25}, got {actual_config.bm25}"
|
||||
if expected_config.source_key is not None:
|
||||
assert (
|
||||
actual_config.source_key == expected_config.source_key
|
||||
), f"defaults sparse_vector source_key mismatch: expected {expected_config.source_key}, got {actual_config.source_key}"
|
||||
|
||||
if "fts_index" in defaults_expected:
|
||||
expected_enabled = defaults_expected["fts_index"]["enabled"]
|
||||
actual_string = defaults_actual.string
|
||||
assert actual_string is not None, "defaults string should exist"
|
||||
assert (
|
||||
actual_string.fts_index is not None
|
||||
), "defaults fts_index should exist"
|
||||
assert (
|
||||
actual_string.fts_index.enabled == expected_enabled
|
||||
), f"defaults fts_index enabled mismatch: expected {expected_enabled}, got {actual_string.fts_index.enabled}"
|
||||
|
||||
if "vector_index" in defaults_expected:
|
||||
expected_enabled = defaults_expected["vector_index"]["enabled"]
|
||||
actual_float_list = defaults_actual.float_list
|
||||
assert actual_float_list is not None, "defaults float_list should exist"
|
||||
assert (
|
||||
actual_float_list.vector_index is not None
|
||||
), "defaults vector_index should exist"
|
||||
assert (
|
||||
actual_float_list.vector_index.enabled == expected_enabled
|
||||
), f"defaults vector_index enabled mismatch: expected {expected_enabled}, got {actual_float_list.vector_index.enabled}"
|
||||
|
||||
# Check per-key indexes
|
||||
for key, key_expected in expected_indexes.items():
|
||||
if key == "defaults":
|
||||
continue
|
||||
|
||||
assert key in actual_schema.keys, f"Expected key '{key}' not found in schema"
|
||||
actual_value_types = actual_schema.keys[key]
|
||||
|
||||
if "string_inverted" in key_expected:
|
||||
expected_enabled = key_expected["string_inverted"]["enabled"]
|
||||
actual_string = actual_value_types.string
|
||||
assert actual_string is not None, f"Key '{key}' string should exist"
|
||||
assert (
|
||||
actual_string.string_inverted_index is not None
|
||||
), f"Key '{key}' string_inverted_index should exist"
|
||||
assert (
|
||||
actual_string.string_inverted_index.enabled == expected_enabled
|
||||
), f"Key '{key}' string_inverted enabled mismatch: expected {expected_enabled}, got {actual_string.string_inverted_index.enabled}"
|
||||
|
||||
if "int_inverted" in key_expected:
|
||||
expected_enabled = key_expected["int_inverted"]["enabled"]
|
||||
actual_int = actual_value_types.int_value
|
||||
assert actual_int is not None, f"Key '{key}' int_value should exist"
|
||||
assert (
|
||||
actual_int.int_inverted_index is not None
|
||||
), f"Key '{key}' int_inverted_index should exist"
|
||||
assert (
|
||||
actual_int.int_inverted_index.enabled == expected_enabled
|
||||
), f"Key '{key}' int_inverted enabled mismatch: expected {expected_enabled}, got {actual_int.int_inverted_index.enabled}"
|
||||
|
||||
if "float_inverted" in key_expected:
|
||||
expected_enabled = key_expected["float_inverted"]["enabled"]
|
||||
actual_float = actual_value_types.float_value
|
||||
assert actual_float is not None, f"Key '{key}' float_value should exist"
|
||||
assert (
|
||||
actual_float.float_inverted_index is not None
|
||||
), f"Key '{key}' float_inverted_index should exist"
|
||||
assert (
|
||||
actual_float.float_inverted_index.enabled == expected_enabled
|
||||
), f"Key '{key}' float_inverted enabled mismatch: expected {expected_enabled}, got {actual_float.float_inverted_index.enabled}"
|
||||
|
||||
if "bool_inverted" in key_expected:
|
||||
expected_enabled = key_expected["bool_inverted"]["enabled"]
|
||||
actual_bool = actual_value_types.boolean
|
||||
assert actual_bool is not None, f"Key '{key}' boolean should exist"
|
||||
assert (
|
||||
actual_bool.bool_inverted_index is not None
|
||||
), f"Key '{key}' bool_inverted_index should exist"
|
||||
assert (
|
||||
actual_bool.bool_inverted_index.enabled == expected_enabled
|
||||
), f"Key '{key}' bool_inverted enabled mismatch: expected {expected_enabled}, got {actual_bool.bool_inverted_index.enabled}"
|
||||
|
||||
if "sparse_vector" in key_expected:
|
||||
expected_enabled = key_expected["sparse_vector"]["enabled"]
|
||||
expected_config = key_expected["sparse_vector"]["config"]
|
||||
actual_sparse = actual_value_types.sparse_vector
|
||||
assert actual_sparse is not None, f"Key '{key}' sparse_vector should exist"
|
||||
assert (
|
||||
actual_sparse.sparse_vector_index is not None
|
||||
), f"Key '{key}' sparse_vector_index should exist"
|
||||
assert (
|
||||
actual_sparse.sparse_vector_index.enabled == expected_enabled
|
||||
), f"Key '{key}' sparse_vector enabled mismatch: expected {expected_enabled}, got {actual_sparse.sparse_vector_index.enabled}"
|
||||
# Validate config fields match
|
||||
actual_config = actual_sparse.sparse_vector_index.config
|
||||
if expected_config.bm25 is not None:
|
||||
assert (
|
||||
actual_config.bm25 == expected_config.bm25
|
||||
), f"Key '{key}' sparse_vector bm25 mismatch: expected {expected_config.bm25}, got {actual_config.bm25}"
|
||||
if expected_config.source_key is not None:
|
||||
assert (
|
||||
actual_config.source_key == expected_config.source_key
|
||||
), f"Key '{key}' sparse_vector source_key mismatch: expected {expected_config.source_key}, got {actual_config.source_key}"
|
||||
|
||||
if "fts_index" in key_expected:
|
||||
expected_enabled = key_expected["fts_index"]["enabled"]
|
||||
actual_string = actual_value_types.string
|
||||
assert actual_string is not None, f"Key '{key}' string should exist"
|
||||
assert (
|
||||
actual_string.fts_index is not None
|
||||
), f"Key '{key}' fts_index should exist"
|
||||
assert (
|
||||
actual_string.fts_index.enabled == expected_enabled
|
||||
), f"Key '{key}' fts_index enabled mismatch: expected {expected_enabled}, got {actual_string.fts_index.enabled}"
|
||||
|
||||
if "vector_index" in key_expected:
|
||||
expected_enabled = key_expected["vector_index"]["enabled"]
|
||||
actual_float_list = actual_value_types.float_list
|
||||
assert actual_float_list is not None, f"Key '{key}' float_list should exist"
|
||||
assert (
|
||||
actual_float_list.vector_index is not None
|
||||
), f"Key '{key}' vector_index should exist"
|
||||
assert (
|
||||
actual_float_list.vector_index.enabled == expected_enabled
|
||||
), f"Key '{key}' vector_index enabled mismatch: expected {expected_enabled}, got {actual_float_list.vector_index.enabled}"
|
||||
|
||||
|
||||
@given(
|
||||
name=strategies.collection_name(),
|
||||
optional_fields=strategies.metadata_configuration_schema_strategy(),
|
||||
)
|
||||
def test_vector_index_configuration_create_collection(
|
||||
client: ClientAPI,
|
||||
name: str,
|
||||
optional_fields: strategies.CollectionInputCombination,
|
||||
) -> None:
|
||||
metadata = optional_fields.metadata
|
||||
configuration = optional_fields.configuration
|
||||
schema = optional_fields.schema
|
||||
|
||||
reset(client)
|
||||
collection = client.create_collection(
|
||||
name=name,
|
||||
metadata=metadata,
|
||||
configuration=configuration,
|
||||
schema=schema,
|
||||
)
|
||||
|
||||
if metadata is None:
|
||||
assert collection.metadata in (None, {})
|
||||
else:
|
||||
check_metadata(metadata, collection.metadata)
|
||||
|
||||
coll_config = collection.configuration
|
||||
spann_active = not is_spann_disabled_mode
|
||||
active_key = "spann" if spann_active else "hnsw"
|
||||
inactive_key = "hnsw" if spann_active else "spann"
|
||||
|
||||
active_block = coll_config.get(active_key)
|
||||
inactive_block = coll_config.get(inactive_key)
|
||||
|
||||
assert active_block is not None, f"{active_key} configuration missing"
|
||||
assert inactive_block in (
|
||||
None,
|
||||
{},
|
||||
), f"{inactive_key} configuration should be absent"
|
||||
|
||||
expected = _compute_expected_config(
|
||||
spann_active=spann_active,
|
||||
metadata=metadata,
|
||||
configuration=configuration,
|
||||
schema_vector_index_config=optional_fields.schema_vector_info,
|
||||
)
|
||||
|
||||
_assert_config_values(cast(Dict[str, Any], active_block), expected, spann_active)
|
||||
|
||||
# Check embedding function name if one was provided
|
||||
if configuration and configuration.get("embedding_function") is not None:
|
||||
ef = configuration["embedding_function"]
|
||||
if ef is not None:
|
||||
coll_ef = coll_config.get("embedding_function")
|
||||
if coll_ef is not None:
|
||||
ef_config = coll_ef.get_config()
|
||||
if ef_config and ef_config.get("type") == "known":
|
||||
assert hasattr(
|
||||
ef, "name"
|
||||
), "embedding function should have name method"
|
||||
assert ef_config.get("name") == ef.name(), (
|
||||
f"embedding function name mismatch: "
|
||||
f"expected {ef.name()}, got {ef_config.get('name')}"
|
||||
)
|
||||
|
||||
schema_result = collection.schema
|
||||
assert schema_result is not None
|
||||
defaults_cfg, embedding_cfg = _extract_vector_configs_from_schema(schema_result)
|
||||
|
||||
if spann_active:
|
||||
assert defaults_cfg["hnsw"] is None
|
||||
assert embedding_cfg["hnsw"] is None
|
||||
assert defaults_cfg["spann"] is not None
|
||||
assert embedding_cfg["spann"] is not None
|
||||
else:
|
||||
assert defaults_cfg["spann"] is None
|
||||
assert embedding_cfg["spann"] is None
|
||||
assert defaults_cfg["hnsw"] is not None
|
||||
assert embedding_cfg["hnsw"] is not None
|
||||
|
||||
_assert_schema_values(defaults_cfg, expected, spann_active)
|
||||
_assert_schema_values(embedding_cfg, expected, spann_active)
|
||||
|
||||
# Check embedding function name in schema if one was provided
|
||||
if configuration and configuration.get("embedding_function") is not None:
|
||||
ef = configuration["embedding_function"]
|
||||
if ef is not None:
|
||||
# Check defaults vector index
|
||||
defaults_ef = schema_result.defaults.float_list.vector_index.config.embedding_function # type: ignore[union-attr]
|
||||
if defaults_ef is not None and hasattr(defaults_ef, "name"):
|
||||
assert defaults_ef.name() == ef.name(), (
|
||||
f"defaults embedding function name mismatch: "
|
||||
f"expected {ef.name()}, got {defaults_ef.name()}"
|
||||
)
|
||||
# Check embedding key vector index
|
||||
embedding_ef = schema_result.keys[EMBEDDING_KEY].float_list.vector_index.config.embedding_function # type: ignore[union-attr]
|
||||
if embedding_ef is not None and hasattr(embedding_ef, "name"):
|
||||
assert embedding_ef.name() == ef.name(), (
|
||||
f"embedding key embedding function name mismatch: "
|
||||
f"expected {ef.name()}, got {embedding_ef.name()}"
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
name=strategies.collection_name(),
|
||||
schema=strategies.schema_strategy(),
|
||||
)
|
||||
def test_schema_create_and_get_collection(
|
||||
client: ClientAPI,
|
||||
name: str,
|
||||
schema: Optional[Schema],
|
||||
) -> None:
|
||||
"""
|
||||
Test that schema-only components (inverted indexes, sparse vector indexes)
|
||||
are correctly created and persisted when creating a collection.
|
||||
"""
|
||||
reset(client)
|
||||
|
||||
if schema is None:
|
||||
expected_indexes = _get_default_schema_indexes()
|
||||
else:
|
||||
expected_indexes = _extract_expected_schema_indexes(schema)
|
||||
|
||||
collection = client.create_collection(name=name, schema=schema)
|
||||
|
||||
# Get the returned schema
|
||||
schema_result = collection.schema
|
||||
assert schema_result is not None, "Schema should not be None"
|
||||
|
||||
_assert_schema_indexes(schema_result, expected_indexes)
|
||||
|
||||
collection = client.get_collection(name)
|
||||
schema_result = collection.schema
|
||||
assert schema_result is not None, "Schema should not be None"
|
||||
_assert_schema_indexes(schema_result, expected_indexes)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user