From cb6c1321cdd4e22f61d6c131d25bc38ac43a7847 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Wed, 12 Aug 2026 08:13:22 +0200
Subject: [PATCH 1/3] feat: add BM25Operator.AndCross for cross-property
keyword AND (Weaviate 1.38.8+)
---
.github/workflows/main.yaml | 2 +-
.../Integration/TestQueries.cs | 78 ++++
.../Integration/TestSearchHybrid.cs | 49 +++
.../Unit/Mocks/MockHelpers.cs | 1 +
.../Unit/TestBM25OperatorSyntax.cs | 332 ++++++++++++++++++
src/Weaviate.Client/Models/Search.cs | 7 +
src/Weaviate.Client/PublicAPI.Unshipped.txt | 13 +
src/Weaviate.Client/WeaviateClient.cs | 1 +
src/Weaviate.Client/gRPC/Client.cs | 14 +
src/Weaviate.Client/gRPC/Search.Builders.cs | 52 ++-
.../gRPC/proto/v1/base_search.proto | 2 +
11 files changed, 549 insertions(+), 2 deletions(-)
create mode 100644 src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index 00bf3c3a..e2fb139b 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -136,7 +136,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- version: ["1.32.27", "1.33.18", "1.34.20", "1.35.23", "1.36.21", "1.37.12", "1.38.4"]
+ version: ["1.32.27", "1.33.18", "1.34.20", "1.35.23", "1.36.23", "1.37.14", "1.38.9", "1.39.0"]
uses: ./.github/workflows/test-on-weaviate-version.yml
secrets: inherit
with:
diff --git a/src/Weaviate.Client.Tests/Integration/TestQueries.cs b/src/Weaviate.Client.Tests/Integration/TestQueries.cs
index 4c3ee2dd..cbb8c074 100644
--- a/src/Weaviate.Client.Tests/Integration/TestQueries.cs
+++ b/src/Weaviate.Client.Tests/Integration/TestQueries.cs
@@ -144,6 +144,84 @@ await collection.Data.InsertMany(
Assert.Equal("apples are big", firstObject.BelongsToGroup);
}
+ ///
+ /// Tests that test bm 25 operator and cross matches tokens across properties
+ ///
+ [Fact]
+ public async Task Test_BM25_Operator_AndCross()
+ {
+ RequireVersion("1.38.8");
+
+ var collection = await CollectionFactory(
+ properties: [Property.Text("title"), Property.Text("body")],
+ vectorConfig: Configure.Vector(t => t.SelfProvided())
+ );
+
+ // Neither of splitAcross's properties holds both tokens, so only cross-property AND matches it.
+ var splitAcross = await collection.Data.Insert(
+ new { title = "banana", body = "split" },
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ var singleProperty = await collection.Data.Insert(
+ new { title = "banana split", body = "dessert" },
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ await collection.Data.Insert(
+ new { title = "banana", body = "bread" },
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ var andObjs = await collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.And(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ Assert.Equal(new List { singleProperty }, andObjs.Select(o => o.UUID).ToList());
+
+ var andCrossObjs = await collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ var expected = new List { splitAcross, singleProperty };
+ expected.Sort();
+ Assert.Equal(expected, andCrossObjs.Select(o => o.UUID).OrderBy(x => x).ToList());
+ }
+
+ ///
+ /// Tests that test bm 25 operator and cross rejects mixed tokenization
+ ///
+ [Fact]
+ public async Task Test_BM25_Operator_AndCross_MixedTokenization_Errors()
+ {
+ RequireVersion("1.38.8");
+
+ var collection = await CollectionFactory(
+ properties:
+ [
+ Property.Text("title", tokenization: PropertyTokenization.Word),
+ Property.Text("code", tokenization: PropertyTokenization.Field),
+ ],
+ vectorConfig: Configure.Vector(t => t.SelfProvided())
+ );
+
+ await collection.Data.Insert(
+ new { title = "banana split", code = "banana-split" },
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ var exception = await Assert.ThrowsAnyAsync(async () =>
+ await collection.Query.BM25(
+ "banana split",
+ searchFields: ["title", "code"],
+ searchOperator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ )
+ );
+ Assert.NotNull(exception.InnerException);
+ Assert.Contains("tokenization", exception.InnerException.Message);
+ }
+
///
/// Tests that test collection generative fetch objects
///
diff --git a/src/Weaviate.Client.Tests/Integration/TestSearchHybrid.cs b/src/Weaviate.Client.Tests/Integration/TestSearchHybrid.cs
index 6882b61d..2ca6eee5 100644
--- a/src/Weaviate.Client.Tests/Integration/TestSearchHybrid.cs
+++ b/src/Weaviate.Client.Tests/Integration/TestSearchHybrid.cs
@@ -831,6 +831,55 @@ await collection.Query.Hybrid(
Assert.Equal(expected, rest);
}
+ ///
+ /// Tests that test hybrid bm 25 operator and cross
+ ///
+ [Fact]
+ public async Task Test_Hybrid_BM25_Operator_AndCross()
+ {
+ RequireVersion("1.38.8");
+
+ var collection = await CollectionFactory(
+ properties: new[] { Property.Text("title"), Property.Text("body") },
+ vectorConfig: Configure.Vector(t => t.SelfProvided())
+ );
+
+ // Neither of splitAcross's properties holds both tokens, so only cross-property AND matches it.
+ var splitAcross = await collection.Data.Insert(
+ new { title = "banana", body = "split" },
+ vectors: new float[] { 1, 0, 0, 0 },
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+ await collection.Data.Insert(
+ new { title = "banana", body = "bread" },
+ vectors: new float[] { 0, 1, 0, 0 },
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ var andObjs = (
+ await collection.Query.Hybrid(
+ "banana split",
+ vectors: null,
+ alpha: 0.0f,
+ bm25Operator: new BM25Operator.And(),
+ cancellationToken: TestContext.Current.CancellationToken
+ )
+ ).ToList();
+ Assert.Empty(andObjs);
+
+ var andCrossObjs = (
+ await collection.Query.Hybrid(
+ "banana split",
+ vectors: null,
+ alpha: 0.0f,
+ bm25Operator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ )
+ ).ToList();
+ Assert.Single(andCrossObjs);
+ Assert.Equal(splitAcross, andCrossObjs[0].UUID);
+ }
+
///
/// Tests that test aggregate max vector distance
///
diff --git a/src/Weaviate.Client.Tests/Unit/Mocks/MockHelpers.cs b/src/Weaviate.Client.Tests/Unit/Mocks/MockHelpers.cs
index 2c15f83b..4e6de637 100644
--- a/src/Weaviate.Client.Tests/Unit/Mocks/MockHelpers.cs
+++ b/src/Weaviate.Client.Tests/Unit/Mocks/MockHelpers.cs
@@ -217,6 +217,7 @@ public static (
{
grpcClient.SetUseAlphaParam(true);
}
+ grpcClient.SetServerVersion(weaviateVersion);
var client = new WeaviateClient(grpcClient: grpcClient, meta: meta);
diff --git a/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs b/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
new file mode 100644
index 00000000..df34d021
--- /dev/null
+++ b/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
@@ -0,0 +1,332 @@
+using Weaviate.Client.Models;
+using Weaviate.Client.Tests.Unit.Mocks;
+using V1 = Weaviate.Client.Grpc.Protobuf.V1;
+
+namespace Weaviate.Client.Tests.Unit;
+
+///
+/// Unit tests verifying the BM25Operator variants map to the expected
+/// SearchOperatorOptions in the gRPC request, for both the BM25 and hybrid sites.
+///
+[Collection("Unit Tests")]
+public class TestBM25OperatorSyntax : IAsyncLifetime
+{
+ private const string CollectionName = "TestCollection";
+
+ private Func _getRequest = null!;
+ private CollectionClient _collection = null!;
+
+ ///
+ /// Initializes this instance
+ ///
+ /// The value task
+ public ValueTask InitializeAsync()
+ {
+ var (client, getRequest) = MockGrpcClient.CreateWithSearchCapture(new Version(1, 39, 0));
+ _getRequest = getRequest;
+ _collection = client.Collections.Use(CollectionName);
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// Disposes this instance
+ ///
+ /// The value task
+ public ValueTask DisposeAsync()
+ {
+ GC.SuppressFinalize(this);
+ return ValueTask.CompletedTask;
+ }
+
+ #region QueryClient.BM25 Tests
+
+ ///
+ /// Tests that bm 25 without operator leaves search operator unset
+ ///
+ [Fact]
+ public async Task BM25_NoOperator_LeavesSearchOperatorUnset()
+ {
+ // Act
+ await _collection.Query.BM25(
+ "banana split",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Null(request.Bm25Search.SearchOperator);
+ }
+
+ ///
+ /// Tests that bm 25 operator and produces valid request
+ ///
+ [Fact]
+ public async Task BM25_Operator_And_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.And(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.And,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that bm 25 operator and cross produces valid request
+ ///
+ [Fact]
+ public async Task BM25_Operator_AndCross_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that bm 25 operator or produces valid request
+ ///
+ [Fact]
+ public async Task BM25_Operator_Or_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.Or(MinimumMatch: 2),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.Or,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ Assert.Equal(2, request.Bm25Search.SearchOperator.MinimumOrTokensMatch);
+ }
+
+ #endregion
+
+ #region QueryClient.Hybrid Tests
+
+ ///
+ /// Tests that hybrid bm 25 operator and cross produces valid request
+ ///
+ [Fact]
+ public async Task Hybrid_BM25Operator_AndCross_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Query.Hybrid(
+ query: "banana split",
+ vectors: (HybridVectorInput?)null,
+ bm25Operator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.HybridSearch.Bm25SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that hybrid bm 25 operator or produces valid request
+ ///
+ [Fact]
+ public async Task Hybrid_BM25Operator_Or_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Query.Hybrid(
+ query: "banana split",
+ vectors: (HybridVectorInput?)null,
+ bm25Operator: new BM25Operator.Or(MinimumMatch: 1),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.Or,
+ request.HybridSearch.Bm25SearchOperator.Operator
+ );
+ Assert.Equal(1, request.HybridSearch.Bm25SearchOperator.MinimumOrTokensMatch);
+ }
+
+ #endregion
+
+ #region Version Guard Tests
+
+ ///
+ /// Tests that bm 25 operator and cross is rejected on servers predating the backports
+ ///
+ [Theory]
+ [InlineData("1.36.9")]
+ [InlineData("1.37.14")]
+ [InlineData("1.38.4")]
+ [InlineData("1.38.7")]
+ public async Task BM25_Operator_AndCross_UnsupportedVersion_Throws(string version)
+ {
+ // Arrange
+ var (client, _) = MockGrpcClient.CreateWithSearchCapture(Version.Parse(version));
+ var collection = client.Collections.Use(CollectionName);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ )
+ );
+ }
+
+ ///
+ /// Tests that bm 25 operator and cross is sent on servers that support it
+ ///
+ [Theory]
+ [InlineData("1.37.15")]
+ [InlineData("1.38.8")]
+ [InlineData("1.38.9")]
+ [InlineData("1.39.0")]
+ [InlineData("1.40.0")]
+ public async Task BM25_Operator_AndCross_SupportedVersion_Sends(string version)
+ {
+ // Arrange
+ var (client, getRequest) = MockGrpcClient.CreateWithSearchCapture(Version.Parse(version));
+ var collection = client.Collections.Use(CollectionName);
+
+ // Act
+ await collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that hybrid bm 25 operator and cross is rejected on an unsupported server
+ ///
+ [Fact]
+ public async Task Hybrid_BM25Operator_AndCross_UnsupportedVersion_Throws()
+ {
+ // Arrange
+ var (client, _) = MockGrpcClient.CreateWithSearchCapture(new Version(1, 38, 4));
+ var collection = client.Collections.Use(CollectionName);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await collection.Query.Hybrid(
+ query: "banana split",
+ vectors: (HybridVectorInput?)null,
+ bm25Operator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ )
+ );
+ }
+
+ ///
+ /// Tests that and and or operators are unaffected by the guard on older servers
+ ///
+ [Fact]
+ public async Task BM25_Operator_AndOr_UnsupportedVersion_StillSend()
+ {
+ // Arrange
+ var (client, getRequest) = MockGrpcClient.CreateWithSearchCapture(new Version(1, 38, 4));
+ var collection = client.Collections.Use(CollectionName);
+
+ // Act
+ await collection.Query.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.And(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.And,
+ request.Bm25Search.SearchOperator.Operator
+ );
+
+ // Act
+ await collection.Query.Hybrid(
+ query: "banana split",
+ vectors: (HybridVectorInput?)null,
+ bm25Operator: new BM25Operator.Or(MinimumMatch: 2),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ request = getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.Or,
+ request.HybridSearch.Bm25SearchOperator.Operator
+ );
+ }
+
+ #endregion
+
+ #region AggregateClient.Hybrid Tests
+
+ ///
+ /// Tests that aggregate hybrid bm 25 operator and cross produces valid request
+ ///
+ [Fact]
+ public async Task Aggregate_Hybrid_BM25Operator_AndCross_ProducesValidRequest()
+ {
+ // Arrange
+ var (client, getRequest) = MockGrpcClient.CreateWithAggregateCapture();
+ var collection = client.Collections.Use(CollectionName);
+
+ // Act
+ await collection.Aggregate.Hybrid(
+ "banana split",
+ bm25Operator: new BM25Operator.AndCross(),
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.Hybrid.Bm25SearchOperator.Operator
+ );
+ }
+
+ #endregion
+}
diff --git a/src/Weaviate.Client/Models/Search.cs b/src/Weaviate.Client/Models/Search.cs
index 1ecc4427..8396f51a 100644
--- a/src/Weaviate.Client/Models/Search.cs
+++ b/src/Weaviate.Client/Models/Search.cs
@@ -85,6 +85,13 @@ public abstract record BM25Operator(string Operator)
///
public record And() : BM25Operator("And");
+ ///
+ /// The cross-property and; every query token must match in at least one searched property,
+ /// which all must share the same tokenization and analyzer settings.
+ /// Requires Weaviate 1.37.15, 1.38.8 or 1.39.0 or later.
+ ///
+ public record AndCross() : BM25Operator("AndCross");
+
///
/// The or
///
diff --git a/src/Weaviate.Client/PublicAPI.Unshipped.txt b/src/Weaviate.Client/PublicAPI.Unshipped.txt
index 9b7e6354..2ba157c5 100644
--- a/src/Weaviate.Client/PublicAPI.Unshipped.txt
+++ b/src/Weaviate.Client/PublicAPI.Unshipped.txt
@@ -30,3 +30,16 @@ Weaviate.Client.Models.Permissions.Namespaces.Manage.get -> bool
Weaviate.Client.Models.Permissions.Namespaces.Manage.set -> void
Weaviate.Client.Models.Permissions.Namespaces.Namespaces(string? namespace) -> void
Weaviate.Client.Models.Permissions.Namespaces.Resource.get -> Weaviate.Client.Models.NamespacesResource!
+override sealed Weaviate.Client.Models.BM25Operator.AndCross.Equals(Weaviate.Client.Models.BM25Operator? other) -> bool
+override Weaviate.Client.Models.BM25Operator.AndCross.$() -> Weaviate.Client.Models.BM25Operator.AndCross!
+override Weaviate.Client.Models.BM25Operator.AndCross.EqualityContract.get -> System.Type!
+override Weaviate.Client.Models.BM25Operator.AndCross.Equals(object? obj) -> bool
+override Weaviate.Client.Models.BM25Operator.AndCross.GetHashCode() -> int
+override Weaviate.Client.Models.BM25Operator.AndCross.PrintMembers(System.Text.StringBuilder! builder) -> bool
+override Weaviate.Client.Models.BM25Operator.AndCross.ToString() -> string!
+static Weaviate.Client.Models.BM25Operator.AndCross.operator !=(Weaviate.Client.Models.BM25Operator.AndCross? left, Weaviate.Client.Models.BM25Operator.AndCross? right) -> bool
+static Weaviate.Client.Models.BM25Operator.AndCross.operator ==(Weaviate.Client.Models.BM25Operator.AndCross? left, Weaviate.Client.Models.BM25Operator.AndCross? right) -> bool
+virtual Weaviate.Client.Models.BM25Operator.AndCross.Equals(Weaviate.Client.Models.BM25Operator.AndCross? other) -> bool
+Weaviate.Client.Models.BM25Operator.AndCross
+Weaviate.Client.Models.BM25Operator.AndCross.AndCross() -> void
+Weaviate.Client.Models.BM25Operator.AndCross.AndCross(Weaviate.Client.Models.BM25Operator.AndCross! original) -> void
diff --git a/src/Weaviate.Client/WeaviateClient.cs b/src/Weaviate.Client/WeaviateClient.cs
index 2abdf31b..8e70bc6f 100644
--- a/src/Weaviate.Client/WeaviateClient.cs
+++ b/src/Weaviate.Client/WeaviateClient.cs
@@ -636,6 +636,7 @@ internal static WeaviateGrpcClient CreateGrpcClient(
{
client.SetUseAlphaParam(true);
}
+ client.SetServerVersion(serverVersion);
return client;
}
diff --git a/src/Weaviate.Client/gRPC/Client.cs b/src/Weaviate.Client/gRPC/Client.cs
index a18a86e9..9502c07f 100644
--- a/src/Weaviate.Client/gRPC/Client.cs
+++ b/src/Weaviate.Client/gRPC/Client.cs
@@ -58,6 +58,20 @@ internal void SetUseAlphaParam(bool useParam)
_useAlphaParam = useParam;
}
+ ///
+ /// The connected server version, or null when unknown. Used to reject requests
+ /// the server cannot process (e.g. BM25Operator.AndCross on pre-backport versions).
+ ///
+ private Version? _serverVersion;
+
+ ///
+ /// Sets the connected server version.
+ ///
+ internal void SetServerVersion(Version? serverVersion)
+ {
+ _serverVersion = serverVersion;
+ }
+
///
/// Internal constructor for testing. Accepts a pre-configured GrpcChannel to bypass network initialization.
///
diff --git a/src/Weaviate.Client/gRPC/Search.Builders.cs b/src/Weaviate.Client/gRPC/Search.Builders.cs
index bb7df8c7..3d463e43 100644
--- a/src/Weaviate.Client/gRPC/Search.Builders.cs
+++ b/src/Weaviate.Client/gRPC/Search.Builders.cs
@@ -782,6 +782,52 @@ private static V1.NearVector BuildNearVector(
return nearVector;
}
+ ///
+ /// The minimum server version per release branch that supports cross-property BM25 AND;
+ /// the feature landed in 1.39.0 and was backported to the 1.37 and 1.38 branches.
+ ///
+ private static readonly Version[] AndCrossMinimumVersions =
+ [
+ new(1, 37, 15),
+ new(1, 38, 8),
+ new(1, 39, 0),
+ ];
+
+ ///
+ /// Throws when the operator is and the connected server
+ /// predates it. Pre-backport servers do not reject the unknown operator — they silently fall
+ /// back to 'Or' semantics. Does nothing when the server version is unknown.
+ ///
+ /// The search operator
+ /// The server does not support the operator.
+ private void EnsureBM25OperatorSupported(BM25Operator? searchOperator)
+ {
+ if (searchOperator is not BM25Operator.AndCross || _serverVersion is null)
+ {
+ return;
+ }
+
+ foreach (var minimum in AndCrossMinimumVersions)
+ {
+ if (_serverVersion.Major == minimum.Major && _serverVersion.Minor == minimum.Minor)
+ {
+ if (_serverVersion >= minimum)
+ {
+ return;
+ }
+ break;
+ }
+ }
+ if (_serverVersion >= AndCrossMinimumVersions[^1])
+ {
+ return;
+ }
+
+ throw new WeaviateFeatureNotSupportedException(
+ $"BM25Operator.AndCross requires Weaviate server version 1.37.15, 1.38.8 or 1.39.0 or later, but connected server is version {_serverVersion}."
+ );
+ }
+
///
/// Builds the bm 25 using the specified request
///
@@ -789,7 +835,7 @@ private static V1.NearVector BuildNearVector(
/// The query
/// The properties
/// The search operator
- private static void BuildBM25(
+ private void BuildBM25(
V1.SearchRequest request,
string query,
string[]? properties = null,
@@ -804,11 +850,13 @@ private static void BuildBM25(
}
if (searchOperator != null)
{
+ EnsureBM25OperatorSupported(searchOperator);
request.Bm25Search.SearchOperator = new()
{
Operator = searchOperator switch
{
BM25Operator.And => V1.SearchOperatorOptions.Types.Operator.And,
+ BM25Operator.AndCross => V1.SearchOperatorOptions.Types.Operator.AndCross,
BM25Operator.Or => V1.SearchOperatorOptions.Types.Operator.Or,
_ => V1.SearchOperatorOptions.Types.Operator.Unspecified,
},
@@ -960,11 +1008,13 @@ private V1.Hybrid BuildHybrid(
}
if (bm25Operator != null)
{
+ EnsureBM25OperatorSupported(bm25Operator);
hybrid.Bm25SearchOperator = new()
{
Operator = bm25Operator switch
{
BM25Operator.And => V1.SearchOperatorOptions.Types.Operator.And,
+ BM25Operator.AndCross => V1.SearchOperatorOptions.Types.Operator.AndCross,
BM25Operator.Or => V1.SearchOperatorOptions.Types.Operator.Or,
_ => V1.SearchOperatorOptions.Types.Operator.Unspecified,
},
diff --git a/src/Weaviate.Client/gRPC/proto/v1/base_search.proto b/src/Weaviate.Client/gRPC/proto/v1/base_search.proto
index 47c11528..5d11691b 100644
--- a/src/Weaviate.Client/gRPC/proto/v1/base_search.proto
+++ b/src/Weaviate.Client/gRPC/proto/v1/base_search.proto
@@ -51,6 +51,8 @@ message SearchOperatorOptions {
OPERATOR_UNSPECIFIED = 0;
OPERATOR_OR = 1;
OPERATOR_AND = 2;
+ // Fails unless every searched property shares the same tokenization and analyzer settings.
+ OPERATOR_AND_CROSS = 3;
}
Operator operator = 1;
optional int32 minimum_or_tokens_match = 2;
From 0894a16e104c82a3393d465082e45dec6d207bf4 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Thu, 13 Aug 2026 09:53:43 +0200
Subject: [PATCH 2/3] feat: accept BM25Operator on generate.bm25
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The operator was already accepted on query.bm25, all four hybrid surfaces
and aggregate.hybrid, but not on generate.bm25 — leaving AndCross
unreachable from generative keyword search. Adds the parameter to the four
GenerateClient/TypedGenerateClient overloads, in the same position the
query client uses. It flows into the existing BuildBM25 path, so the
server-version guard applies unchanged.
---
.../Unit/TestBM25OperatorSyntax.cs | 212 ++++++++++++++++++
src/Weaviate.Client/GenerateClient.BM25.cs | 6 +
src/Weaviate.Client/PublicAPI.Unshipped.txt | 8 +
.../Typed/TypedGenerateClient.BM25.cs | 6 +
4 files changed, 232 insertions(+)
diff --git a/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs b/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
index df34d021..e429f89a 100644
--- a/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
@@ -1,5 +1,6 @@
using Weaviate.Client.Models;
using Weaviate.Client.Tests.Unit.Mocks;
+using Weaviate.Client.Typed;
using V1 = Weaviate.Client.Grpc.Protobuf.V1;
namespace Weaviate.Client.Tests.Unit;
@@ -16,6 +17,17 @@ public class TestBM25OperatorSyntax : IAsyncLifetime
private Func _getRequest = null!;
private CollectionClient _collection = null!;
+ ///
+ /// The test document class
+ ///
+ private class TestDocument
+ {
+ ///
+ /// Gets or sets the value of the title
+ ///
+ public string Title { get; set; } = string.Empty;
+ }
+
///
/// Initializes this instance
///
@@ -127,6 +139,181 @@ await _collection.Query.BM25(
#endregion
+ #region GenerateClient.BM25 Tests
+
+ ///
+ /// Tests that generate bm 25 without operator leaves search operator unset
+ ///
+ [Fact]
+ public async Task Generate_BM25_NoOperator_LeavesSearchOperatorUnset()
+ {
+ // Act
+ await _collection.Generate.BM25(
+ "banana split",
+ singlePrompt: "Summarize this item",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Null(request.Bm25Search.SearchOperator);
+ }
+
+ ///
+ /// Tests that generate bm 25 operator and produces valid request
+ ///
+ [Fact]
+ public async Task Generate_BM25_Operator_And_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Generate.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.And(),
+ singlePrompt: "Summarize this item",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.And,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that generate bm 25 operator and cross produces valid request
+ ///
+ [Fact]
+ public async Task Generate_BM25_Operator_AndCross_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Generate.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ singlePrompt: "Summarize this item",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that generate bm 25 operator or produces valid request
+ ///
+ [Fact]
+ public async Task Generate_BM25_Operator_Or_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Generate.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.Or(MinimumMatch: 2),
+ singlePrompt: "Summarize this item",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.Or,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ Assert.Equal(2, request.Bm25Search.SearchOperator.MinimumOrTokensMatch);
+ }
+
+ ///
+ /// Tests that generate bm 25 with group by operator and cross produces valid request
+ ///
+ [Fact]
+ public async Task Generate_BM25_GroupBy_Operator_AndCross_ProducesValidRequest()
+ {
+ // Act
+ await _collection.Generate.BM25(
+ "banana split",
+ new GroupByRequest("category") { NumberOfGroups = 5 },
+ searchOperator: new BM25Operator.AndCross(),
+ groupedTask: "Summarize by category",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ #endregion
+
+ #region TypedGenerateClient.BM25 Tests
+
+ ///
+ /// Tests that typed generate bm 25 operator and cross produces valid request
+ ///
+ [Fact]
+ public async Task Typed_Generate_BM25_Operator_AndCross_ProducesValidRequest()
+ {
+ // Arrange
+ var typedGenerate = new TypedGenerateClient(_collection.Generate);
+
+ // Act
+ await typedGenerate.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ singlePrompt: "Summarize this item",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.AndCross,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ }
+
+ ///
+ /// Tests that typed generate bm 25 with group by operator or produces valid request
+ ///
+ [Fact]
+ public async Task Typed_Generate_BM25_GroupBy_Operator_Or_ProducesValidRequest()
+ {
+ // Arrange
+ var typedGenerate = new TypedGenerateClient(_collection.Generate);
+
+ // Act
+ await typedGenerate.BM25(
+ "banana split",
+ new GroupByRequest("category") { NumberOfGroups = 5 },
+ searchOperator: new BM25Operator.Or(MinimumMatch: 2),
+ groupedTask: "Summarize by category",
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ // Assert
+ var request = _getRequest();
+ Assert.NotNull(request);
+ Assert.Equal(
+ V1.SearchOperatorOptions.Types.Operator.Or,
+ request.Bm25Search.SearchOperator.Operator
+ );
+ Assert.Equal(2, request.Bm25Search.SearchOperator.MinimumOrTokensMatch);
+ }
+
+ #endregion
+
#region QueryClient.Hybrid Tests
///
@@ -235,6 +422,31 @@ await collection.Query.BM25(
);
}
+ ///
+ /// Tests that generate bm 25 operator and cross is rejected on servers predating the backports
+ ///
+ [Theory]
+ [InlineData("1.36.9")]
+ [InlineData("1.37.14")]
+ [InlineData("1.38.4")]
+ [InlineData("1.38.7")]
+ public async Task Generate_BM25_Operator_AndCross_UnsupportedVersion_Throws(string version)
+ {
+ // Arrange
+ var (client, _) = MockGrpcClient.CreateWithSearchCapture(Version.Parse(version));
+ var collection = client.Collections.Use(CollectionName);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await collection.Generate.BM25(
+ "banana split",
+ searchOperator: new BM25Operator.AndCross(),
+ singlePrompt: "Summarize this item",
+ cancellationToken: TestContext.Current.CancellationToken
+ )
+ );
+ }
+
///
/// Tests that hybrid bm 25 operator and cross is rejected on an unsupported server
///
diff --git a/src/Weaviate.Client/GenerateClient.BM25.cs b/src/Weaviate.Client/GenerateClient.BM25.cs
index e199cc24..1b729b9e 100644
--- a/src/Weaviate.Client/GenerateClient.BM25.cs
+++ b/src/Weaviate.Client/GenerateClient.BM25.cs
@@ -18,6 +18,7 @@ public partial class GenerateClient
/// Auto-cut threshold
/// Maximum number of results
/// Offset for pagination
+ /// BM25 search operator (AND/OR)
/// Rerank configuration
/// Single prompt for generation
/// Grouped prompt for generation
@@ -38,6 +39,7 @@ public async Task BM25(
uint? autoLimit = null,
uint? limit = null,
uint? offset = null,
+ BM25Operator? searchOperator = null,
Rerank? rerank = null,
SinglePrompt? singlePrompt = null,
GroupedTask? groupedTask = null,
@@ -59,6 +61,7 @@ public async Task BM25(
autoLimit: autoLimit,
limit: limit,
offset: offset,
+ searchOperator: searchOperator,
groupBy: groupBy,
rerank: rerank,
singlePrompt: EnrichPrompt(singlePrompt, provider) as SinglePrompt,
@@ -84,6 +87,7 @@ public async Task BM25(
/// Auto-cut threshold
/// Maximum number of results
/// Offset for pagination
+ /// BM25 search operator (AND/OR)
/// Rerank configuration
/// Single prompt for generation
/// Grouped prompt for generation
@@ -103,6 +107,7 @@ public async Task BM25(
uint? autoLimit = null,
uint? limit = null,
uint? offset = null,
+ BM25Operator? searchOperator = null,
Rerank? rerank = null,
SinglePrompt? singlePrompt = null,
GroupedTask? groupedTask = null,
@@ -124,6 +129,7 @@ public async Task BM25(
autoLimit: autoLimit,
limit: limit,
offset: offset,
+ searchOperator: searchOperator,
groupBy: null,
rerank: rerank,
singlePrompt: EnrichPrompt(singlePrompt, provider) as SinglePrompt,
diff --git a/src/Weaviate.Client/PublicAPI.Unshipped.txt b/src/Weaviate.Client/PublicAPI.Unshipped.txt
index 2ba157c5..265d8269 100644
--- a/src/Weaviate.Client/PublicAPI.Unshipped.txt
+++ b/src/Weaviate.Client/PublicAPI.Unshipped.txt
@@ -43,3 +43,11 @@ virtual Weaviate.Client.Models.BM25Operator.AndCross.Equals(Weaviate.Client.Mode
Weaviate.Client.Models.BM25Operator.AndCross
Weaviate.Client.Models.BM25Operator.AndCross.AndCross() -> void
Weaviate.Client.Models.BM25Operator.AndCross.AndCross(Weaviate.Client.Models.BM25Operator.AndCross! original) -> void
+*REMOVED*Weaviate.Client.GenerateClient.BM25(string! query, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+*REMOVED*Weaviate.Client.GenerateClient.BM25(string! query, Weaviate.Client.Models.GroupByRequest! groupBy, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+*REMOVED*Weaviate.Client.Typed.TypedGenerateClient.BM25(string! query, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+*REMOVED*Weaviate.Client.Typed.TypedGenerateClient.BM25(string! query, Weaviate.Client.Models.GroupByRequest! groupBy, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Weaviate.Client.GenerateClient.BM25(string! query, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.BM25Operator? searchOperator = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Weaviate.Client.GenerateClient.BM25(string! query, Weaviate.Client.Models.GroupByRequest! groupBy, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.BM25Operator? searchOperator = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Weaviate.Client.Typed.TypedGenerateClient.BM25(string! query, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.BM25Operator? searchOperator = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
+Weaviate.Client.Typed.TypedGenerateClient.BM25(string! query, Weaviate.Client.Models.GroupByRequest! groupBy, string![]? searchFields = null, Weaviate.Client.Models.Filter? filters = null, uint? autoLimit = null, uint? limit = null, uint? offset = null, Weaviate.Client.Models.BM25Operator? searchOperator = null, Weaviate.Client.Models.Rerank? rerank = null, Weaviate.Client.Models.SinglePrompt? singlePrompt = null, Weaviate.Client.Models.GroupedTask? groupedTask = null, Weaviate.Client.Models.GenerativeProvider? provider = null, System.Guid? after = null, Weaviate.Client.ConsistencyLevels? consistencyLevel = null, Weaviate.Client.Internal.AutoArray? returnProperties = null, Weaviate.Client.Models.MetadataQuery? returnMetadata = null, Weaviate.Client.Models.VectorQuery? includeVectors = null, System.Collections.Generic.IList? returnReferences = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>!
diff --git a/src/Weaviate.Client/Typed/TypedGenerateClient.BM25.cs b/src/Weaviate.Client/Typed/TypedGenerateClient.BM25.cs
index 3743ba7d..acbe81b6 100644
--- a/src/Weaviate.Client/Typed/TypedGenerateClient.BM25.cs
+++ b/src/Weaviate.Client/Typed/TypedGenerateClient.BM25.cs
@@ -19,6 +19,7 @@ public partial class TypedGenerateClient
/// Auto-cut threshold
/// Maximum number of results
/// Offset for pagination
+ /// BM25 search operator (AND/OR)
/// Rerank configuration
/// Single prompt for generation
/// Grouped prompt for generation
@@ -39,6 +40,7 @@ public async Task> BM25(
uint? autoLimit = null,
uint? limit = null,
uint? offset = null,
+ BM25Operator? searchOperator = null,
Rerank? rerank = null,
SinglePrompt? singlePrompt = null,
GroupedTask? groupedTask = null,
@@ -60,6 +62,7 @@ public async Task> BM25(
autoLimit: autoLimit,
limit: limit,
offset: offset,
+ searchOperator: searchOperator,
rerank: rerank,
singlePrompt: singlePrompt,
groupedTask: groupedTask,
@@ -84,6 +87,7 @@ public async Task> BM25(
/// Auto-cut threshold
/// Maximum number of results
/// Offset for pagination
+ /// BM25 search operator (AND/OR)
/// Rerank configuration
/// Single prompt for generation
/// Grouped prompt for generation
@@ -103,6 +107,7 @@ public async Task> BM25(
uint? autoLimit = null,
uint? limit = null,
uint? offset = null,
+ BM25Operator? searchOperator = null,
Rerank? rerank = null,
SinglePrompt? singlePrompt = null,
GroupedTask? groupedTask = null,
@@ -123,6 +128,7 @@ public async Task> BM25(
autoLimit: autoLimit,
limit: limit,
offset: offset,
+ searchOperator: searchOperator,
rerank: rerank,
singlePrompt: singlePrompt,
groupedTask: groupedTask,
From ba0a2058a608d3b45755e679f987dc81e5e6218e Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Fri, 14 Aug 2026 08:10:52 +0200
Subject: [PATCH 3/3] fix: throw the client-side exception family from the
AndCross version guard
EnsureBM25OperatorSupported runs before the request ever leaves the client,
but WeaviateFeatureNotSupportedException derives from WeaviateServerException.
Switched to WeaviateVersionMismatchException (WeaviateClientException), which
is what the client's other version gates throw via Internal/VersionGuard. The
two previously sat under different bases, so a caller could not catch both
with anything narrower than WeaviateException.
That exception's only constructor takes (operation, requiredVersion,
actualVersion), so the backport floors now travel in the operation argument,
sourced from the existing AndCrossMinimumVersions ladder rather than a
hand-written string. Tests assert the structured RequiredVersion and
ActualVersion alongside the exception type.
WeaviateFeatureNotSupportedException is left in place: ExceptionHelper still
maps a genuine server-side gRPC Unimplemented to it, where the server base
class is correct.
---
.../Unit/TestBM25OperatorSyntax.cs | 10 +++++++---
src/Weaviate.Client/gRPC/Search.Builders.cs | 8 +++++---
2 files changed, 12 insertions(+), 6 deletions(-)
diff --git a/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs b/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
index e429f89a..617591f0 100644
--- a/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestBM25OperatorSyntax.cs
@@ -382,13 +382,17 @@ public async Task BM25_Operator_AndCross_UnsupportedVersion_Throws(string versio
var collection = client.Collections.Use(CollectionName);
// Act & Assert
- await Assert.ThrowsAsync(async () =>
+ var exception = await Assert.ThrowsAsync(async () =>
await collection.Query.BM25(
"banana split",
searchOperator: new BM25Operator.AndCross(),
cancellationToken: TestContext.Current.CancellationToken
)
);
+ Assert.Equal(new Version(1, 39, 0), exception.RequiredVersion);
+ Assert.Equal(Version.Parse(version), exception.ActualVersion);
+ Assert.Contains("1.37.15", exception.Message);
+ Assert.Contains("1.38.8", exception.Message);
}
///
@@ -437,7 +441,7 @@ public async Task Generate_BM25_Operator_AndCross_UnsupportedVersion_Throws(stri
var collection = client.Collections.Use(CollectionName);
// Act & Assert
- await Assert.ThrowsAsync(async () =>
+ await Assert.ThrowsAsync(async () =>
await collection.Generate.BM25(
"banana split",
searchOperator: new BM25Operator.AndCross(),
@@ -458,7 +462,7 @@ public async Task Hybrid_BM25Operator_AndCross_UnsupportedVersion_Throws()
var collection = client.Collections.Use(CollectionName);
// Act & Assert
- await Assert.ThrowsAsync(async () =>
+ await Assert.ThrowsAsync(async () =>
await collection.Query.Hybrid(
query: "banana split",
vectors: (HybridVectorInput?)null,
diff --git a/src/Weaviate.Client/gRPC/Search.Builders.cs b/src/Weaviate.Client/gRPC/Search.Builders.cs
index 3d463e43..70420a6d 100644
--- a/src/Weaviate.Client/gRPC/Search.Builders.cs
+++ b/src/Weaviate.Client/gRPC/Search.Builders.cs
@@ -799,7 +799,7 @@ private static V1.NearVector BuildNearVector(
/// back to 'Or' semantics. Does nothing when the server version is unknown.
///
/// The search operator
- /// The server does not support the operator.
+ /// The server does not support the operator.
private void EnsureBM25OperatorSupported(BM25Operator? searchOperator)
{
if (searchOperator is not BM25Operator.AndCross || _serverVersion is null)
@@ -823,8 +823,10 @@ private void EnsureBM25OperatorSupported(BM25Operator? searchOperator)
return;
}
- throw new WeaviateFeatureNotSupportedException(
- $"BM25Operator.AndCross requires Weaviate server version 1.37.15, 1.38.8 or 1.39.0 or later, but connected server is version {_serverVersion}."
+ throw new WeaviateVersionMismatchException(
+ $"BM25Operator.AndCross (backported to {string.Join(" and ", AndCrossMinimumVersions[..^1])})",
+ AndCrossMinimumVersions[^1],
+ _serverVersion
);
}