Skip to content

Commit c3fcb0b

Browse files
committed
Add cross-property BM25 And operator
Exposes the OPERATOR_AND_CROSS search operator added in Weaviate 1.39.0 (backported to 1.37.15 and 1.38.8) as `BM25Operator.and_cross()`. Unlike `and_()`, a query token may be matched by any of the searched properties instead of having to occur within a single one. The gRPC construction for the bm25 and hybrid paths is folded into a shared `_BaseGRPC._bm25_operator_to_grpc()` so the version gate applies to query, generative-query and aggregation alike; sending the new enum to a server that predates it raises WeaviateUnsupportedFeatureError. `_ServerVersion.is_at_least_any()` is added to express minimums that were backported across several release branches.
1 parent ae327ca commit c3fcb0b

16 files changed

Lines changed: 492 additions & 117 deletions

File tree

.github/workflows/main.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +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
32+
WEAVIATE_139: 1.39.0-rc.1-89299a5.amd64
3333

3434
jobs:
3535
lint-and-format:

docs/changelog.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
Changelog
22
=========
33

4+
Version 4.23.0
5+
--------------
6+
This minor version includes:
7+
- Support for new 1.39 features:
8+
- Add support for the new cross-property ``And`` BM25 search operator through ``BM25Operator.and_cross()``, available on the ``bm25`` and ``hybrid`` query, generative-query, and aggregation methods. Unlike ``BM25Operator.and_()``, a query token may be matched by any of the searched properties instead of having to occur within a single one. All searched properties must share the same tokenization and analyzer settings. Requires Weaviate ``1.37.15``, ``1.38.8``, ``1.39.0`` or higher
9+
410
Version 4.22.0
511
--------------
612
This minor version includes:

integration/test_collection.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@
3636
)
3737
from weaviate.collections.classes.internal import Object, ReferenceToMulti, _CrossReference
3838
from weaviate.collections.classes.types import PhoneNumber, WeaviateProperties, _PhoneNumber
39+
from weaviate.collections.grpc.shared import (
40+
_BM25_AND_CROSS_MIN_VERSIONS,
41+
_BM25_AND_CROSS_MIN_VERSIONS_STR,
42+
)
3943
from weaviate.exceptions import (
4044
UnexpectedStatusCodeError,
4145
WeaviateInsertInvalidPropertyError,
4246
WeaviateInsertManyAllFailedError,
4347
WeaviateInvalidInputError,
4448
WeaviateQueryError,
49+
WeaviateUnsupportedFeatureError,
4550
)
4651
from weaviate.types import UUID, UUIDS
4752

@@ -1756,3 +1761,76 @@ def test_bm25_operators(collection_factory: CollectionFactory) -> None:
17561761
assert len(objs.objects) == 4
17571762
assert objs.objects[0].uuid == uuid2
17581763
assert sorted(obj.uuid for obj in objs.objects[1:]) == sorted([uuid1, uuid3, uuid4])
1764+
1765+
1766+
def test_bm25_operator_and_cross(collection_factory: CollectionFactory) -> None:
1767+
collection = collection_factory(
1768+
properties=[
1769+
Property(name="title", data_type=DataType.TEXT),
1770+
Property(name="body", data_type=DataType.TEXT),
1771+
],
1772+
vectorizer_config=Configure.Vectorizer.none(),
1773+
)
1774+
1775+
if not collection._connection._weaviate_version.is_at_least_any(*_BM25_AND_CROSS_MIN_VERSIONS):
1776+
pytest.skip(f"bm25 cross-property AND requires {_BM25_AND_CROSS_MIN_VERSIONS_STR}")
1777+
1778+
# Only `split_across` needs cross-property matching: neither of its properties holds both tokens.
1779+
split_across = collection.data.insert({"title": "banana", "body": "split"})
1780+
single_property = collection.data.insert({"title": "banana split", "body": "dessert"})
1781+
partial = collection.data.insert({"title": "banana", "body": "bread"})
1782+
1783+
and_cross = collection.query.bm25("banana split", operator=wvc.query.BM25Operator.and_cross())
1784+
assert sorted(obj.uuid for obj in and_cross.objects) == sorted([split_across, single_property])
1785+
1786+
or_ = collection.query.bm25(
1787+
"banana split", operator=wvc.query.BM25Operator.or_(minimum_match=1)
1788+
)
1789+
assert sorted(obj.uuid for obj in or_.objects) == sorted(
1790+
[split_across, single_property, partial]
1791+
)
1792+
1793+
# Per-property AND is at most as permissive as cross-property AND. Which of the two it is
1794+
# depends on whether the server took the BlockMax or the legacy WAND path, so only the
1795+
# subset relation is asserted.
1796+
and_ = collection.query.bm25("banana split", operator=wvc.query.BM25Operator.and_())
1797+
assert {obj.uuid for obj in and_.objects} <= {obj.uuid for obj in and_cross.objects}
1798+
1799+
1800+
def test_bm25_operator_and_cross_mismatched_tokenization(
1801+
collection_factory: CollectionFactory,
1802+
) -> None:
1803+
collection = collection_factory(
1804+
properties=[
1805+
Property(name="title", data_type=DataType.TEXT, tokenization=Tokenization.WORD),
1806+
Property(name="body", data_type=DataType.TEXT, tokenization=Tokenization.FIELD),
1807+
],
1808+
vectorizer_config=Configure.Vectorizer.none(),
1809+
)
1810+
1811+
if not collection._connection._weaviate_version.is_at_least_any(*_BM25_AND_CROSS_MIN_VERSIONS):
1812+
pytest.skip(f"bm25 cross-property AND requires {_BM25_AND_CROSS_MIN_VERSIONS_STR}")
1813+
1814+
collection.data.insert({"title": "banana", "body": "split"})
1815+
1816+
with pytest.raises(WeaviateQueryError):
1817+
collection.query.bm25(
1818+
"banana split",
1819+
query_properties=["title", "body"],
1820+
operator=wvc.query.BM25Operator.and_cross(),
1821+
)
1822+
1823+
1824+
def test_bm25_operator_and_cross_unsupported_version(
1825+
collection_factory: CollectionFactory,
1826+
) -> None:
1827+
collection = collection_factory(
1828+
properties=[Property(name="name", data_type=DataType.TEXT)],
1829+
vectorizer_config=Configure.Vectorizer.none(),
1830+
)
1831+
1832+
if collection._connection._weaviate_version.is_at_least_any(*_BM25_AND_CROSS_MIN_VERSIONS):
1833+
pytest.skip("server supports bm25 cross-property AND")
1834+
1835+
with pytest.raises(WeaviateUnsupportedFeatureError):
1836+
collection.query.bm25("banana split", operator=wvc.query.BM25Operator.and_cross())

integration/test_collection_hybrid.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
_HybridNearVector,
2222
)
2323
from weaviate.collections.classes.internal import Object
24+
from weaviate.collections.grpc.shared import (
25+
_BM25_AND_CROSS_MIN_VERSIONS,
26+
_BM25_AND_CROSS_MIN_VERSIONS_STR,
27+
)
2428
from weaviate.exceptions import (
2529
WeaviateUnsupportedFeatureError,
2630
)
@@ -528,3 +532,51 @@ def test_hybrid_bm25_operators(collection_factory: CollectionFactory) -> None:
528532
assert len(objs.objects) == 4
529533
assert objs.objects[0].uuid == uuid2
530534
assert sorted(obj.uuid for obj in objs.objects[1:]) == sorted([uuid1, uuid3, uuid4])
535+
536+
537+
def test_hybrid_bm25_operator_and_cross(collection_factory: CollectionFactory) -> None:
538+
collection = collection_factory(
539+
properties=[
540+
Property(name="title", data_type=DataType.TEXT),
541+
Property(name="body", data_type=DataType.TEXT),
542+
],
543+
vectorizer_config=Configure.Vectorizer.none(),
544+
)
545+
546+
if not collection._connection._weaviate_version.is_at_least_any(*_BM25_AND_CROSS_MIN_VERSIONS):
547+
pytest.skip(f"bm25 cross-property AND requires {_BM25_AND_CROSS_MIN_VERSIONS_STR}")
548+
549+
# Only `split_across` needs cross-property matching: neither of its properties holds both tokens.
550+
split_across = collection.data.insert({"title": "banana", "body": "split"}, vector=[1, 0, 0, 0])
551+
single_property = collection.data.insert(
552+
{"title": "banana split", "body": "dessert"}, vector=[0, 1, 0, 0]
553+
)
554+
collection.data.insert({"title": "banana", "body": "bread"}, vector=[0, 0, 1, 0])
555+
556+
objs = collection.query.hybrid(
557+
"banana split",
558+
vector=None,
559+
alpha=0.0,
560+
bm25_operator=wvc.query.BM25Operator.and_cross(),
561+
)
562+
assert sorted(obj.uuid for obj in objs.objects) == sorted([split_across, single_property])
563+
564+
565+
def test_hybrid_bm25_operator_and_cross_unsupported_version(
566+
collection_factory: CollectionFactory,
567+
) -> None:
568+
collection = collection_factory(
569+
properties=[Property(name="name", data_type=DataType.TEXT)],
570+
vectorizer_config=Configure.Vectorizer.none(),
571+
)
572+
573+
if collection._connection._weaviate_version.is_at_least_any(*_BM25_AND_CROSS_MIN_VERSIONS):
574+
pytest.skip("server supports bm25 cross-property AND")
575+
576+
with pytest.raises(WeaviateUnsupportedFeatureError):
577+
collection.query.hybrid(
578+
"banana split",
579+
vector=None,
580+
alpha=0.0,
581+
bm25_operator=wvc.query.BM25Operator.and_cross(),
582+
)
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Unit tests: BM25 search operators are wired into the gRPC request and version-gated.
2+
3+
``BM25Operator.and_cross()`` maps to the ``OPERATOR_AND_CROSS`` enum added in Weaviate 1.39.0 and
4+
backported to 1.38.8 and 1.37.15, so the client must reject it on servers outside those ranges
5+
rather than send an enum value the server will not understand.
6+
"""
7+
8+
from typing import Optional
9+
10+
import pytest
11+
12+
from weaviate.classes.query import BM25Operator
13+
from weaviate.collections.classes.grpc import BM25OperatorOptions
14+
from weaviate.collections.grpc.aggregate import _AggregateGRPC
15+
from weaviate.collections.grpc.query import _QueryGRPC
16+
from weaviate.exceptions import WeaviateUnsupportedFeatureError
17+
from weaviate.proto.v1 import base_search_pb2
18+
from weaviate.util import _ServerVersion
19+
20+
_SUPPORTED = ["1.37.15", "1.38.8", "1.39.0", "1.40.0"]
21+
_UNSUPPORTED = ["1.36.9", "1.37.14", "1.38.0", "1.38.7"]
22+
23+
24+
def _query(version: str = "1.39.0") -> _QueryGRPC:
25+
return _QueryGRPC(
26+
weaviate_version=_ServerVersion.from_string(version),
27+
name="Dummy",
28+
tenant=None,
29+
consistency_level=None,
30+
validate_arguments=True,
31+
uses_125_api=True,
32+
uses_127_api=True,
33+
)
34+
35+
36+
def _aggregate(version: str = "1.39.0") -> _AggregateGRPC:
37+
return _AggregateGRPC(
38+
weaviate_version=_ServerVersion.from_string(version),
39+
name="Dummy",
40+
tenant=None,
41+
consistency_level=None,
42+
validate_arguments=True,
43+
)
44+
45+
46+
def _aggregate_hybrid(
47+
builder: _AggregateGRPC, operator: Optional[BM25OperatorOptions]
48+
) -> base_search_pb2.Hybrid:
49+
return builder.hybrid(
50+
query="banana two",
51+
alpha=0.0,
52+
vector=None,
53+
properties=None,
54+
target_vector=None,
55+
bm25_operator=operator,
56+
aggregations=[],
57+
filters=None,
58+
group_by=None,
59+
limit=None,
60+
object_limit=None,
61+
objects_count=False,
62+
).hybrid
63+
64+
65+
@pytest.mark.parametrize(
66+
"operator,expected,minimum_or_tokens_match",
67+
[
68+
(BM25Operator.and_(), base_search_pb2.SearchOperatorOptions.OPERATOR_AND, 0),
69+
(BM25Operator.or_(minimum_match=2), base_search_pb2.SearchOperatorOptions.OPERATOR_OR, 2),
70+
(
71+
BM25Operator.and_cross(),
72+
base_search_pb2.SearchOperatorOptions.OPERATOR_AND_CROSS,
73+
0,
74+
),
75+
],
76+
)
77+
def test_bm25_operator_wired_into_request(
78+
operator: BM25OperatorOptions,
79+
expected: "base_search_pb2.SearchOperatorOptions.Operator",
80+
minimum_or_tokens_match: int,
81+
) -> None:
82+
search_operator = (
83+
_query().bm25(query="banana two", operator=operator).bm25_search.search_operator
84+
)
85+
assert search_operator.operator == expected
86+
assert search_operator.minimum_or_tokens_match == minimum_or_tokens_match
87+
88+
89+
@pytest.mark.parametrize(
90+
"operator,expected",
91+
[
92+
(BM25Operator.and_(), base_search_pb2.SearchOperatorOptions.OPERATOR_AND),
93+
(BM25Operator.or_(minimum_match=2), base_search_pb2.SearchOperatorOptions.OPERATOR_OR),
94+
(BM25Operator.and_cross(), base_search_pb2.SearchOperatorOptions.OPERATOR_AND_CROSS),
95+
],
96+
)
97+
def test_hybrid_bm25_operator_wired_into_request(
98+
operator: BM25OperatorOptions, expected: "base_search_pb2.SearchOperatorOptions.Operator"
99+
) -> None:
100+
req = _query().hybrid(query="banana two", alpha=0.0, bm25_operator=operator)
101+
assert req.hybrid_search.bm25_search_operator.operator == expected
102+
103+
104+
def test_no_operator_leaves_search_operator_unset() -> None:
105+
assert not _query().bm25(query="banana two").bm25_search.HasField("search_operator")
106+
req = _query().hybrid(query="banana two", alpha=0.0)
107+
assert not req.hybrid_search.HasField("bm25_search_operator")
108+
109+
110+
@pytest.mark.parametrize("version", _SUPPORTED)
111+
def test_and_cross_allowed_on_supported_versions(version: str) -> None:
112+
req = _query(version).bm25(query="banana two", operator=BM25Operator.and_cross())
113+
assert (
114+
req.bm25_search.search_operator.operator
115+
== base_search_pb2.SearchOperatorOptions.OPERATOR_AND_CROSS
116+
)
117+
118+
119+
@pytest.mark.parametrize("version", _UNSUPPORTED)
120+
def test_and_cross_rejected_on_unsupported_versions(version: str) -> None:
121+
with pytest.raises(WeaviateUnsupportedFeatureError) as e:
122+
_query(version).bm25(query="banana two", operator=BM25Operator.and_cross())
123+
assert "1.37.15 or 1.38.8 or 1.39.0" in str(e.value)
124+
assert version in str(e.value)
125+
126+
127+
@pytest.mark.parametrize("version", _UNSUPPORTED)
128+
def test_hybrid_and_cross_rejected_on_unsupported_versions(version: str) -> None:
129+
with pytest.raises(WeaviateUnsupportedFeatureError):
130+
_query(version).hybrid(
131+
query="banana two", alpha=0.0, bm25_operator=BM25Operator.and_cross()
132+
)
133+
134+
135+
@pytest.mark.parametrize("version", _UNSUPPORTED)
136+
def test_aggregate_hybrid_and_cross_rejected_on_unsupported_versions(version: str) -> None:
137+
with pytest.raises(WeaviateUnsupportedFeatureError):
138+
_aggregate_hybrid(_aggregate(version), BM25Operator.and_cross())
139+
140+
141+
def test_aggregate_hybrid_and_cross_allowed_on_supported_version() -> None:
142+
hybrid = _aggregate_hybrid(_aggregate("1.39.0"), BM25Operator.and_cross())
143+
assert (
144+
hybrid.bm25_search_operator.operator
145+
== base_search_pb2.SearchOperatorOptions.OPERATOR_AND_CROSS
146+
)
147+
148+
149+
@pytest.mark.parametrize("version", _UNSUPPORTED)
150+
@pytest.mark.parametrize("operator", [BM25Operator.and_(), BM25Operator.or_(minimum_match=1), None])
151+
def test_other_operators_unaffected_by_gate(
152+
version: str, operator: Optional[BM25OperatorOptions]
153+
) -> None:
154+
_query(version).bm25(query="banana two", operator=operator)
155+
_query(version).hybrid(query="banana two", alpha=0.0, bm25_operator=operator)

test/test_server_version.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ def test_server_version_is_at_least(is_valid: bool) -> None:
6060
assert is_valid
6161

6262

63+
@pytest.mark.parametrize(
64+
"version,expected",
65+
[
66+
("1.36.9", False),
67+
("1.37.14", False),
68+
("1.37.15", True),
69+
("1.37.99", True),
70+
("1.38.0", False),
71+
("1.38.7", False),
72+
("1.38.8", True),
73+
("1.38.20", True),
74+
("1.39.0", True),
75+
("1.40.0", True),
76+
("2.0.0", True),
77+
],
78+
)
79+
def test_server_version_is_at_least_any(version: str, expected: bool) -> None:
80+
assert (
81+
_ServerVersion.from_string(version).is_at_least_any((1, 37, 15), (1, 38, 8), (1, 39, 0))
82+
is expected
83+
)
84+
85+
86+
def test_server_version_is_at_least_any_single_minimum() -> None:
87+
assert _ServerVersion(1, 39, 0).is_at_least_any((1, 39, 0))
88+
assert not _ServerVersion(1, 38, 99).is_at_least_any((1, 39, 0))
89+
90+
6391
def test_server_version_magic_methods() -> None:
6492
# Test __eq__
6593
assert _ServerVersion(1, 2, 3) == _ServerVersion(1, 2, 3)

0 commit comments

Comments
 (0)