Skip to content

Commit c4cf6ee

Browse files
authored
Merge branch 'main' into pr/2090
2 parents c73c2bc + b98d5c6 commit c4cf6ee

18 files changed

Lines changed: 345 additions & 21 deletions

File tree

.github/workflows/main.yaml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ env:
2929
WEAVIATE_135: 1.35.18
3030
WEAVIATE_136: 1.36.12
3131
WEAVIATE_137: 1.37.5-e0fe0d5.amd64
32+
WEAVIATE_139: 1.39.0-rc.0-b41225e.amd64
3233

3334
jobs:
3435
lint-and-format:
@@ -162,11 +163,11 @@ jobs:
162163
fail-fast: false
163164
matrix:
164165
versions: [
165-
{ py: "3.10", weaviate: $WEAVIATE_136, grpc: "1.59.0"},
166-
{ py: "3.11", weaviate: $WEAVIATE_136, grpc: "1.66.0"},
167-
{ py: "3.12", weaviate: $WEAVIATE_136, grpc: "1.70.0"},
168-
{ py: "3.13", weaviate: $WEAVIATE_136, grpc: "1.72.1"},
169-
{ py: "3.14", weaviate: $WEAVIATE_136, grpc: "1.76.0"}
166+
{ py: "3.10", weaviate: $WEAVIATE_139, grpc: "1.59.0"},
167+
{ py: "3.11", weaviate: $WEAVIATE_139, grpc: "1.66.0"},
168+
{ py: "3.12", weaviate: $WEAVIATE_139, grpc: "1.70.0"},
169+
{ py: "3.13", weaviate: $WEAVIATE_139, grpc: "1.72.1"},
170+
{ py: "3.14", weaviate: $WEAVIATE_139, grpc: "1.76.0"}
170171
]
171172
optional_dependencies: [false]
172173
steps:
@@ -320,7 +321,8 @@ jobs:
320321
$WEAVIATE_134,
321322
$WEAVIATE_135,
322323
$WEAVIATE_136,
323-
$WEAVIATE_137
324+
$WEAVIATE_137,
325+
$WEAVIATE_139
324326
]
325327
steps:
326328
- name: Checkout

integration/test_collection_config.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,19 @@
4848
WeaviateUnsupportedFeatureError,
4949
)
5050
from integration.conftest import retry_on_http_error
51+
from weaviate.util import _ServerVersion
52+
53+
54+
def _expected_async_enabled(version: _ServerVersion, factor: int) -> bool:
55+
"""Whether the server reports async replication as enabled for a collection.
56+
57+
Up to 1.38 the server stores whatever `async_enabled` the collection was created with. From
58+
1.39 it ignores that and derives the field as `factor > 1 and not globally disabled` instead,
59+
so a collection with a single replica always reports `False`.
60+
"""
61+
if version.is_at_least(1, 39, 0):
62+
return factor > 1
63+
return version.is_at_least(1, 26, 0)
5164

5265

5366
@pytest.fixture(scope="module")
@@ -353,10 +366,9 @@ def test_collection_config_full(collection_factory: CollectionFactory) -> None:
353366
assert config.multi_tenancy_config.auto_tenant_creation is False
354367

355368
assert config.replication_config.factor == 1
356-
if collection._connection._weaviate_version.is_at_least(1, 26, 0):
357-
assert config.replication_config.async_enabled is True
358-
else:
359-
assert config.replication_config.async_enabled is False
369+
assert config.replication_config.async_enabled is _expected_async_enabled(
370+
collection._connection._weaviate_version, factor=1
371+
)
360372

361373
if collection._connection._weaviate_version.is_at_least(1, 24, 25):
362374
assert (
@@ -1605,7 +1617,9 @@ def test_replication_config_with_async_config(collection_factory: CollectionFact
16051617
)
16061618
config = collection.config.get()
16071619
assert config.replication_config.factor == 1
1608-
assert config.replication_config.async_enabled is True
1620+
assert config.replication_config.async_enabled is _expected_async_enabled(
1621+
collection._connection._weaviate_version, factor=1
1622+
)
16091623
assert config.replication_config.async_config is not None
16101624
ac = config.replication_config.async_config
16111625
assert ac.propagation_concurrency == 4
@@ -1672,7 +1686,9 @@ def test_replication_config_remove_async_config(collection_factory: CollectionFa
16721686
),
16731687
)
16741688
config = collection.config.get()
1675-
assert config.replication_config.async_enabled is True
1689+
assert config.replication_config.async_enabled is _expected_async_enabled(
1690+
collection._connection._weaviate_version, factor=1
1691+
)
16761692
assert config.replication_config.async_config is None
16771693
assert config.replication_config.factor == 1
16781694

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Integration tests for hybrid search + MMR diversity selection.
2+
3+
``diversity_selection`` is a hybrid-level argument: the server applies it as a
4+
post-fusion MMR pass (Weaviate >= 1.38.6). These tests assert that ``balance=0``
5+
(pure diversity) produces a different ordering than ``balance=1`` (pure
6+
relevance), and that ``mmr.limit`` caps the result count.
7+
8+
The equivalent ``near_vector`` behaviour is covered in
9+
``test_collection_diversity.py``.
10+
"""
11+
12+
import pytest
13+
14+
from integration.conftest import CollectionFactory
15+
from weaviate.classes.query import Diversity, HybridVector
16+
from weaviate.collections.classes.config import Configure, DataType, Property
17+
from weaviate.collections.classes.data import DataObject
18+
19+
MIN_VERSION = (1, 38, 6)
20+
21+
22+
def _skip_if_unsupported(collection) -> None:
23+
if collection._connection._weaviate_version.is_lower_than(*MIN_VERSION):
24+
pytest.skip("Hybrid diversity selection requires Weaviate >= 1.38.6")
25+
26+
27+
def _create_clustered_collection(collection_factory: CollectionFactory):
28+
"""Create a collection with 3 tight clusters (a, b, c) of vectors in 3D."""
29+
collection = collection_factory(
30+
properties=[Property(name="text", data_type=DataType.TEXT)],
31+
vectorizer_config=Configure.Vectorizer.none(),
32+
)
33+
_skip_if_unsupported(collection)
34+
collection.data.insert_many(
35+
[
36+
DataObject(properties={"text": "a1"}, vector=[1.0, 0.0, 0.0]),
37+
DataObject(properties={"text": "a2"}, vector=[0.95, 0.05, 0.0]),
38+
DataObject(properties={"text": "a3"}, vector=[0.9, 0.1, 0.0]),
39+
DataObject(properties={"text": "b1"}, vector=[0.0, 1.0, 0.0]),
40+
DataObject(properties={"text": "b2"}, vector=[0.05, 0.95, 0.0]),
41+
DataObject(properties={"text": "c1"}, vector=[0.0, 0.0, 1.0]),
42+
]
43+
)
44+
return collection
45+
46+
47+
def _create_large_collection(collection_factory: CollectionFactory, n_items: int = 50):
48+
"""Create a collection with enough items (>25) that a small mmr.limit is distinguishable from the server's default limit."""
49+
collection = collection_factory(
50+
properties=[Property(name="text", data_type=DataType.TEXT)],
51+
vectorizer_config=Configure.Vectorizer.none(),
52+
)
53+
_skip_if_unsupported(collection)
54+
collection.data.insert_many(
55+
[
56+
DataObject(properties={"text": f"t{i}"}, vector=[1.0 - 0.001 * i, 0.0, 0.0])
57+
for i in range(n_items)
58+
]
59+
)
60+
return collection
61+
62+
63+
def test_hybrid_near_vector_balance_0_differs_from_balance_1(
64+
collection_factory: CollectionFactory,
65+
) -> None:
66+
"""Hybrid near-vector: balance=0 (diversity) must reorder vs balance=1 (relevance)."""
67+
collection = _create_clustered_collection(collection_factory)
68+
balance_0 = collection.query.hybrid(
69+
query=None,
70+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
71+
diversity_selection=Diversity.mmr(limit=3, balance=0.0),
72+
limit=3,
73+
).objects
74+
balance_1 = collection.query.hybrid(
75+
query=None,
76+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
77+
diversity_selection=Diversity.mmr(limit=3, balance=1.0),
78+
limit=3,
79+
).objects
80+
assert [o.uuid for o in balance_0] != [o.uuid for o in balance_1]
81+
82+
83+
def test_hybrid_near_vector_balance_1_matches_baseline(
84+
collection_factory: CollectionFactory,
85+
) -> None:
86+
"""Hybrid near-vector with MMR balance=1 (pure relevance) matches the plain baseline."""
87+
collection = _create_clustered_collection(collection_factory)
88+
baseline = collection.query.hybrid(
89+
query=None,
90+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
91+
limit=3,
92+
).objects
93+
mmr_balance_1 = collection.query.hybrid(
94+
query=None,
95+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
96+
diversity_selection=Diversity.mmr(limit=3, balance=1.0),
97+
limit=3,
98+
).objects
99+
assert [o.uuid for o in baseline] == [o.uuid for o in mmr_balance_1]
100+
101+
102+
def test_hybrid_alpha_1_balance_0_differs_from_balance_1(
103+
collection_factory: CollectionFactory,
104+
) -> None:
105+
"""Hybrid with explicit alpha=1.0 (pure vector) applies MMR like near_vector."""
106+
collection = _create_clustered_collection(collection_factory)
107+
balance_0 = collection.query.hybrid(
108+
query="irrelevant",
109+
alpha=1.0,
110+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
111+
diversity_selection=Diversity.mmr(limit=3, balance=0.0),
112+
limit=3,
113+
).objects
114+
balance_1 = collection.query.hybrid(
115+
query="irrelevant",
116+
alpha=1.0,
117+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
118+
diversity_selection=Diversity.mmr(limit=3, balance=1.0),
119+
limit=3,
120+
).objects
121+
assert [o.uuid for o in balance_0] != [o.uuid for o in balance_1]
122+
123+
124+
def test_hybrid_respects_mmr_limit(
125+
collection_factory: CollectionFactory,
126+
) -> None:
127+
"""Hybrid respects mmr.limit as the result-count cap when no outer limit is set."""
128+
mmr_limit = 5
129+
collection = _create_large_collection(collection_factory, n_items=50)
130+
131+
result = collection.query.hybrid(
132+
query=None,
133+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
134+
diversity_selection=Diversity.mmr(limit=mmr_limit, balance=0.5),
135+
).objects
136+
assert len(result) == mmr_limit
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Unit tests: hybrid search wires diversity_selection into the gRPC request.
2+
3+
Hybrid diversity is a post-fusion, hybrid-level operation, so the top-level
4+
``query.hybrid`` / ``generate.hybrid`` ``diversity_selection`` argument must
5+
populate the top-level ``Hybrid.selection.mmr`` in the SearchRequest proto (not
6+
the nested ``near_vector`` / ``near_text`` selection).
7+
"""
8+
9+
from weaviate.collections.grpc.query import _QueryGRPC
10+
from weaviate.classes.query import Diversity, HybridVector
11+
from weaviate.util import _ServerVersion
12+
13+
14+
_DEFAULT_VERSION = _ServerVersion(1, 38, 0)
15+
16+
17+
def _builder(version: _ServerVersion = _DEFAULT_VERSION) -> _QueryGRPC:
18+
return _QueryGRPC(
19+
weaviate_version=version,
20+
name="Dummy",
21+
tenant=None,
22+
consistency_level=None,
23+
validate_arguments=True,
24+
uses_125_api=True,
25+
uses_127_api=True,
26+
)
27+
28+
29+
def test_hybrid_near_vector_sets_top_level_selection() -> None:
30+
req = _builder().hybrid(
31+
query=None,
32+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
33+
diversity_selection=Diversity.mmr(limit=7, balance=0.0),
34+
limit=7,
35+
)
36+
# Canonical location: top-level Hybrid.selection, not the nested near_vector.
37+
mmr = req.hybrid_search.selection.mmr
38+
assert mmr.limit == 7
39+
assert mmr.balance == 0.0
40+
assert not req.hybrid_search.near_vector.HasField("selection")
41+
42+
43+
def test_hybrid_near_text_sets_top_level_selection() -> None:
44+
req = _builder().hybrid(
45+
query=None,
46+
vector=HybridVector.near_text(query="cats"),
47+
diversity_selection=Diversity.mmr(limit=3, balance=0.5),
48+
limit=3,
49+
)
50+
mmr = req.hybrid_search.selection.mmr
51+
assert mmr.limit == 3
52+
assert mmr.balance == 0.5
53+
assert not req.hybrid_search.near_text.HasField("selection")
54+
55+
56+
def test_hybrid_without_selection_leaves_it_unset() -> None:
57+
req = _builder().hybrid(
58+
query=None,
59+
vector=HybridVector.near_vector(vector=[1.0, 0.0, 0.0]),
60+
limit=5,
61+
)
62+
assert not req.hybrid_search.HasField("selection")

test/collection/test_validator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
[
1515
(1, [int], False),
1616
(1.0, [int], True),
17+
(True, [int], True),
1718
([1, 1], [List], False),
1819
(np.array([1, 2, 3]), [_ExtraTypes.NUMPY], False),
1920
(np.array([1, 2, 3]), [_ExtraTypes.NUMPY, List], False),

weaviate/cluster/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class ReplicateOperationState(str, Enum):
1818
HYDRATING = "HYDRATING"
1919
FINALIZING = "FINALIZING"
2020
DEHYDRATING = "DEHYDRATING"
21+
INTEGRATING = "INTEGRATING"
2122
READY = "READY"
2223
CANCELLED = "CANCELLED"
2324

weaviate/collections/classes/grpc.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -617,9 +617,13 @@ def blend(
617617
class MMR:
618618
"""Define MMR (Maximal Marginal Relevance) diversity selection.
619619
620+
Not supported for multi-vector indexes.
621+
620622
Args:
621-
limit: Optional number of candidates to consider for diversification.
622-
balance: Optional MMR lambda in [0.0, 1.0] — 1.0 is pure relevance, 0.0 is pure diversity.
623+
limit: Number of objects to select. The server requires it: it must be at least 1 and
624+
no larger than the query's own `limit` when one is set.
625+
balance: Optional MMR lambda in [0.0, 1.0]. 1.0 ranks purely by relevance to the query,
626+
0.0 purely by dissimilarity to the objects already selected.
623627
"""
624628

625629
limit: Optional[int] = None
@@ -636,9 +640,13 @@ def __init__(self) -> None:
636640
def mmr(limit: Optional[int] = None, balance: Optional[float] = None) -> MMR:
637641
"""Maximal Marginal Relevance diversity selection.
638642
643+
Not supported for multi-vector indexes.
644+
639645
Args:
640-
limit: Number of candidates to consider for diversification.
641-
balance: MMR lambda in [0.0, 1.0] — 1.0 pure relevance, 0.0 pure diversity.
646+
limit: Number of objects to select. The server requires it: it must be at least 1
647+
and no larger than the query's own `limit` when one is set.
648+
balance: MMR lambda in [0.0, 1.0]. 1.0 ranks purely by relevance to the query,
649+
0.0 purely by dissimilarity to the objects already selected.
642650
"""
643651
return MMR(limit=limit, balance=balance)
644652

weaviate/collections/grpc/query.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def hybrid(
173173
generative: Optional[_Generative] = None,
174174
rerank: Optional[Rerank] = None,
175175
boost: Optional[_Boost] = None,
176+
diversity_selection: Optional[MMR] = None,
176177
target_vector: Optional[TargetVectorJoinType] = None,
177178
) -> search_get_pb2.SearchRequest:
178179
return self.__create_request(
@@ -196,6 +197,7 @@ def hybrid(
196197
fusion_type,
197198
distance,
198199
target_vector,
200+
diversity_selection,
199201
),
200202
)
201203

weaviate/collections/grpc/shared.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ def _parse_hybrid(
600600
fusion_type: Optional[HybridFusion],
601601
distance: Optional[NUMBER],
602602
target_vector: Optional[TargetVectorJoinType],
603+
diversity_selection: Optional[MMR] = None,
603604
) -> Union[base_search_pb2.Hybrid, None]:
604605
if self._validate_arguments:
605606
_validate_input(
@@ -739,6 +740,7 @@ def _parse_hybrid(
739740
vector_bytes=vector_bytes,
740741
vector_distance=distance,
741742
vectors=vectors,
743+
selection=self._diversity_selection_to_grpc(diversity_selection),
742744
bm25_search_operator=base_search_pb2.SearchOperatorOptions(
743745
operator=bm25_operator.operator,
744746
minimum_or_tokens_match=bm25_operator.minimum_should_match

0 commit comments

Comments
 (0)