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
2 changes: 1 addition & 1 deletion .github/workflows/_python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ jobs:
- test-glob: "chromadb/test/api"
- test-glob: "chromadb/test/property/test_collections_with_database_tenant.py"
- test-glob: "chromadb/test/property/test_collections_with_database_tenant_overwrite.py"
runs-on: blacksmith-8vcpu-ubuntu-2404
runs-on: blacksmith-16vcpu-ubuntu-2404
# OIDC token auth for AWS
permissions:
contents: read
Expand Down
149 changes: 113 additions & 36 deletions chromadb/test/api/test_schema_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,17 @@ def test_schema_vector_config_persistence_with_ef(
if not is_spann_disabled_mode:
assert vector_index.config.spann is not None
spann_config = vector_index.config.spann
assert spann_config.search_nprobe == 16
assert spann_config.write_nprobe == 32
assert spann_config.ef_construction == 120
assert spann_config.max_neighbors == 24
# Capture server-returned values. Quantization may override the requested
# values with its own tuned defaults, so we verify persistence (values
# survive a client reload) rather than asserting specific numbers.
initial_search_nprobe = spann_config.search_nprobe
initial_write_nprobe = spann_config.write_nprobe
initial_ef_construction = spann_config.ef_construction
Comment thread
Sicheng-Pan marked this conversation as resolved.
initial_max_neighbors = spann_config.max_neighbors
assert initial_search_nprobe is not None
assert initial_write_nprobe is not None
assert initial_ef_construction is not None
assert initial_max_neighbors is not None
else:
assert vector_index.config.spann is None
assert vector_index.config.hnsw is not None
Expand All @@ -226,8 +233,8 @@ def test_schema_vector_config_persistence_with_ef(
spann_json = persisted_json["keys"]["#embedding"]["float_list"]["vector_index"][
"config"
]["spann"]
assert spann_json["search_nprobe"] == 16
assert spann_json["write_nprobe"] == 32
assert spann_json["search_nprobe"] == initial_search_nprobe
assert spann_json["write_nprobe"] == initial_write_nprobe
else:
hnsw_json = persisted_json["keys"]["#embedding"]["float_list"]["vector_index"][
"config"
Expand All @@ -252,8 +259,8 @@ def test_schema_vector_config_persistence_with_ef(
assert reloaded_vector_index.config.space == "cosine"
if not is_spann_disabled_mode:
assert reloaded_vector_index.config.spann is not None
assert reloaded_vector_index.config.spann.search_nprobe == 16
assert reloaded_vector_index.config.spann.write_nprobe == 32
assert reloaded_vector_index.config.spann.search_nprobe == initial_search_nprobe
assert reloaded_vector_index.config.spann.write_nprobe == initial_write_nprobe
else:
assert reloaded_vector_index.config.hnsw is not None
assert reloaded_vector_index.config.hnsw.ef_construction == 100
Expand Down Expand Up @@ -291,7 +298,7 @@ def get_config(self) -> Dict[str, Any]:

@staticmethod
def build_from_config(
config: Dict[str, Any]
config: Dict[str, Any],
) -> "DeterministicSparseEmbeddingFunction":
return DeterministicSparseEmbeddingFunction(config.get("label", "det_sparse"))

Expand Down Expand Up @@ -2344,14 +2351,24 @@ def test_modify_collection_no_initial_config_creates_default_schema(
# Verify schema was updated
updated_schema = collection.schema
assert updated_schema is not None
assert updated_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 32 # type: ignore
assert updated_schema.keys["#embedding"].float_list.vector_index.config.spann.search_nprobe == 32 # type: ignore
assert (
updated_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 32
) # type: ignore
assert (
updated_schema.keys[
"#embedding"
].float_list.vector_index.config.spann.search_nprobe
== 32
) # type: ignore

# Re-fetch from server
collection_refreshed = client.get_collection(collection_name)
refreshed_schema = collection_refreshed.schema
assert refreshed_schema is not None
assert refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 32 # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe
== 32
) # type: ignore


@pytest.mark.skipif(not is_spann_disabled_mode, reason="SPANN is disabled")
Expand Down Expand Up @@ -2379,13 +2396,18 @@ def test_modify_collection_no_initial_config_creates_default_schema_local(
updated_schema = collection.schema
assert updated_schema is not None
assert updated_schema.defaults.float_list.vector_index.config.hnsw.ef_search == 100 # type: ignore
assert updated_schema.keys["#embedding"].float_list.vector_index.config.hnsw.ef_search == 100 # type: ignore
assert (
updated_schema.keys["#embedding"].float_list.vector_index.config.hnsw.ef_search
== 100
) # type: ignore

# Re-fetch from server
collection_refreshed = client.get_collection(collection_name)
refreshed_schema = collection_refreshed.schema
assert refreshed_schema is not None
assert refreshed_schema.defaults.float_list.vector_index.config.hnsw.ef_search == 100 # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.hnsw.ef_search == 100
) # type: ignore


@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
Expand All @@ -2404,33 +2426,60 @@ def test_modify_collection_with_initial_spann_schema(client: ClientAPI) -> None:
},
)

# Verify initial schema has the specified config
# Verify initial schema has a SPANN config. Quantization may override the
# requested values with tuned defaults, so capture the server-returned values
# from each location and verify they are preserved after a modify.
schema = collection.schema
assert schema is not None
assert schema.defaults.float_list.vector_index.config.spann is not None # type: ignore
assert schema.defaults.float_list.vector_index.config.spann.search_nprobe == 10 # type: ignore
assert schema.defaults.float_list.vector_index.config.spann.ef_search == 50 # type: ignore
initial_defaults_ef_search = (
schema.defaults.float_list.vector_index.config.spann.ef_search
) # type: ignore
initial_embedding_ef_search = schema.keys[
"#embedding"
].float_list.vector_index.config.spann.ef_search # type: ignore
assert initial_defaults_ef_search is not None
assert initial_embedding_ef_search is not None

# Modify to update search_nprobe to a different value within limits
collection.modify(configuration={"spann": {"search_nprobe": 20}})

# Verify update
updated_schema = collection.schema
assert updated_schema is not None
assert updated_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 20 # type: ignore
# ef_search should remain unchanged
assert updated_schema.defaults.float_list.vector_index.config.spann.ef_search == 50 # type: ignore
assert (
updated_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 20
) # type: ignore
# ef_search should remain unchanged from the initial server-returned value
assert (
updated_schema.defaults.float_list.vector_index.config.spann.ef_search
== initial_defaults_ef_search
) # type: ignore

# Verify both locations updated
assert updated_schema.keys["#embedding"].float_list.vector_index.config.spann.search_nprobe == 20 # type: ignore
assert updated_schema.keys["#embedding"].float_list.vector_index.config.spann.ef_search == 50 # type: ignore
assert (
updated_schema.keys[
"#embedding"
].float_list.vector_index.config.spann.search_nprobe
== 20
) # type: ignore
assert (
updated_schema.keys["#embedding"].float_list.vector_index.config.spann.ef_search
== initial_embedding_ef_search
) # type: ignore

# Re-fetch and verify
collection_refreshed = client.get_collection(collection_name)
refreshed_schema = collection_refreshed.schema
assert refreshed_schema is not None
assert refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 20 # type: ignore
assert refreshed_schema.defaults.float_list.vector_index.config.spann.ef_search == 50 # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe
== 20
) # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.ef_search
== initial_defaults_ef_search
) # type: ignore


@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
Expand All @@ -2446,6 +2495,14 @@ def test_modify_collection_updates_schema_spann_multiple_fields(
configuration={"spann": {"search_nprobe": 64, "ef_search": 100}},
)

# Capture the server-assigned default for write_nprobe before modifying.
# This value depends on whether quantization is enabled (32 without, 64 with).
initial_schema = collection.schema
assert initial_schema is not None
initial_write_nprobe = (
initial_schema.defaults.float_list.vector_index.config.spann.write_nprobe
) # type: ignore

# Modify multiple fields
collection.modify(
configuration={
Expand All @@ -2462,20 +2519,36 @@ def test_modify_collection_updates_schema_spann_multiple_fields(
assert schema.defaults.float_list.vector_index.config.spann.search_nprobe == 128 # type: ignore
assert schema.defaults.float_list.vector_index.config.spann.ef_search == 200 # type: ignore

# Verify other fields were preserved
assert schema.defaults.float_list.vector_index.config.spann.write_nprobe == 32 # type: ignore
# Verify other fields were preserved (not changed by the modify)
assert (
schema.defaults.float_list.vector_index.config.spann.write_nprobe
== initial_write_nprobe
) # type: ignore

# Verify in both locations
assert schema.keys["#embedding"].float_list.vector_index.config.spann.search_nprobe == 128 # type: ignore
assert schema.keys["#embedding"].float_list.vector_index.config.spann.ef_search == 200 # type: ignore
assert (
schema.keys["#embedding"].float_list.vector_index.config.spann.search_nprobe
== 128
) # type: ignore
assert (
schema.keys["#embedding"].float_list.vector_index.config.spann.ef_search == 200
) # type: ignore

# Re-fetch from server
collection_refreshed = client.get_collection(collection_name)
refreshed_schema = collection_refreshed.schema
assert refreshed_schema is not None
assert refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 128 # type: ignore
assert refreshed_schema.defaults.float_list.vector_index.config.spann.ef_search == 200 # type: ignore
assert refreshed_schema.defaults.float_list.vector_index.config.spann.write_nprobe == 32 # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe
== 128
) # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.ef_search == 200
) # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.write_nprobe
== initial_write_nprobe
) # type: ignore


@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
Expand Down Expand Up @@ -2530,15 +2603,21 @@ def test_modify_collection_preserves_other_schema_fields(client: ClientAPI) -> N
assert updated_schema.keys["#document"].string.fts_index.enabled is True

# Verify vector index WAS updated
assert updated_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 128 # type: ignore
assert (
updated_schema.defaults.float_list.vector_index.config.spann.search_nprobe
== 128
) # type: ignore

# Re-fetch from server to verify persistence
collection_refreshed = client.get_collection(collection_name)
refreshed_schema = collection_refreshed.schema
assert refreshed_schema is not None

# Verify vector index was updated on server
assert refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe == 128 # type: ignore
assert (
refreshed_schema.defaults.float_list.vector_index.config.spann.search_nprobe
== 128
) # type: ignore

# Verify other value types are still intact on server
assert refreshed_schema.defaults.string is not None
Expand Down Expand Up @@ -2837,7 +2916,5 @@ def test_fts_disabled_search_api_blocks_document_filter(
)

with pytest.raises(InvalidArgumentError) as exc_info:
collection.search(
Search(where=Key.DOCUMENT.contains("alpha"))
)
collection.search(Search(where=Key.DOCUMENT.contains("alpha")))
assert "fts" in str(exc_info.value).lower()
57 changes: 52 additions & 5 deletions chromadb/test/property/invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,25 @@ def ann_accuracy(
dim = len(embeddings[0])
accuracy_threshold = accuracy_threshold * math.pow(10, int(math.log10(dim)))

# Detect whether quantization is active on this collection so the
# per-result distance equality check below can use a wider tolerance
# derived from the RaBitQ paper's theoretical error bound. The
# quantize field lives in the schema (under #embedding key), not in
# configuration_json.
quantization_active = False
serialized_schema = collection._model.serialized_schema
if serialized_schema is not None:
embedding_spann_cfg = (
serialized_schema.get("keys", {})
.get("#embedding", {})
.get("float_list", {})
.get("vector_index", {})
.get("config", {})
.get("spann", {})
)
if embedding_spann_cfg.get("quantize") not in (None, "none"):
quantization_active = True

# Perform exact distance computation
if query_embeddings is None:
query_embeddings = (
Expand Down Expand Up @@ -398,11 +417,39 @@ def ann_accuracy(
unexpected_id = id not in expected_ids
index = id_to_index[id]

correct_distance = np.allclose(
distances_i[index],
query_results["distances"][i][j],
atol=accuracy_threshold,
)
# For quantized SPANN (4-bit RaBitQ) the paper's Theorem 3.2
# bounds the distance-estimator error by O(1/sqrt(D)) with a
# constant that scales with the norms of the data and query
# vectors. We observed on test_add_mcmr that 100% of failing
# quantized comparisons fall within 5 * ||q|| * ||d|| / sqrt(D)
# (P95 at ~9% of that bound, max at 16%). The corresponding
# *relative* error can be large when the true distance value
# happens to be near zero (notably for inner product, whose
# value has no non-zero floor), which is why a simple rtol
# cannot cover quantized IP.
#
# For quantized collections we therefore use an absolute
# tolerance derived from the RaBitQ bound in addition to the
# standard accuracy_threshold floor. For non-quantized
# collections we omit rtol so numpy's default rtol=1e-5
# applies, matching pre-branch behavior (e.g.
# test_cross_version_persist's local HNSW relies on it for
# small numerical drift).
if quantization_active:
q_norm = float(np.linalg.norm(query_embeddings[i]))
d_norm = float(np.linalg.norm(embeddings[index]))
rabitq_atol = 5.0 * q_norm * d_norm / math.sqrt(dim)
correct_distance = np.allclose(
distances_i[index],
query_results["distances"][i][j],
atol=max(accuracy_threshold, rabitq_atol),
)
else:
correct_distance = np.allclose(
distances_i[index],
query_results["distances"][i][j],
atol=accuracy_threshold,
)
if unexpected_id:
# If the ID is unexpcted, but the distance is correct, then we
# have a duplicate in the data. In this case, we should not reduce recall.
Expand Down
10 changes: 9 additions & 1 deletion chromadb/test/property/strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,15 @@ def collections(

name = draw(collection_name())
metadata = draw(collection_metadata)
dimension = draw(st.integers(min_value=2, max_value=2048))
# Dimension bounds tuned for 4-bit RaBitQ quantization (enabled broadly
# in the distributed test tenant configs):
# - min 128: below this, RaBitQ's concentration-of-measure bound
# (~1/sqrt(d)) produces relative error >10% on cosine/L2 distances,
# and hypothesis eagerly shrinks to pathological low-dim cases.
# - max 512: quantized compaction's per-record cost is dominated by
# a dense O(dim^2) random rotation matmul; capping dimension keeps
# per-compaction CPU bounded so the test fits in COMPACTION_SLEEP.
dimension = draw(st.integers(min_value=128, max_value=512))
dtype = draw(st.sampled_from(float_types))

use_persistent_hnsw_params = draw(with_persistent_hnsw_params)
Expand Down
12 changes: 6 additions & 6 deletions k8s/distributed-chroma/values.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ queryService:
jemallocConfig: "prof:true,prof_active:true,lg_prof_sample:19"
resources:
limits:
cpu: 100m
cpu: 300m
requests:
cpu: 100m
cpu: 300m

compactionService:
env:
Expand All @@ -46,17 +46,17 @@ compactionService:
jemallocConfig: "prof:true,prof_active:true,lg_prof_sample:19"
resources:
limits:
cpu: 200m
cpu: 1500m
requests:
cpu: 200m
cpu: 1500m

rustLogService:
replicaCount: 1
resources:
limits:
cpu: 200m
cpu: 500m
requests:
cpu: 200m
cpu: 500m

garbageCollector:
jemallocConfig: "prof:true,prof_active:true,lg_prof_sample:19"
Expand Down
Loading
Loading