From cb4969173690e6f082bf66fd39b7d4e51603e4e5 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Wed, 9 Sep 2026 13:39:31 +0200
Subject: [PATCH 1/4] feat: add centered RQ4 quantization (Weaviate 1.39.3)
---
.../Integration/TestCollections.cs | 41 ++++++++++++++
.../Unit/TestVectorIndexConfig.cs | 54 +++++++++++++++++++
src/Weaviate.Client/Models/VectorIndex.cs | 13 +++++
src/Weaviate.Client/PublicAPI.Unshipped.txt | 4 ++
4 files changed, 112 insertions(+)
diff --git a/src/Weaviate.Client.Tests/Integration/TestCollections.cs b/src/Weaviate.Client.Tests/Integration/TestCollections.cs
index 79838570..fadf2332 100644
--- a/src/Weaviate.Client.Tests/Integration/TestCollections.cs
+++ b/src/Weaviate.Client.Tests/Integration/TestCollections.cs
@@ -1354,6 +1354,47 @@ await collection.Config.Update(
Assert.Equal(456, rqQuantizer.RescoreLimit);
}
+ ///
+ /// Tests that test hnsw centered rq4
+ ///
+ [Fact]
+ public async Task Test_hnsw_centered_rq4()
+ {
+ RequireVersion("1.39.3", message: "RQ centering only supported in server version 1.39.3+");
+
+ var collection = await CollectionFactory(
+ vectorConfig:
+ [
+ Configure.Vector(
+ "hnswRq4c",
+ t => t.SelfProvided(),
+ new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ
+ {
+ Bits = 4,
+ Centering = true,
+ RescoreLimit = 20,
+ TrainingLimit = 5000,
+ },
+ }
+ ),
+ ]
+ );
+ var config = await collection.Config.Get(TestContext.Current.CancellationToken);
+ Assert.NotNull(config);
+ var vcRQ = config.VectorConfig["hnswRq4c"];
+ Assert.NotNull(vcRQ);
+ var hnswConfig = vcRQ.VectorIndexConfig as VectorIndex.HNSW;
+ Assert.NotNull(hnswConfig);
+ var rqQuantizer = hnswConfig.Quantizer as VectorIndex.Quantizers.RQ;
+ Assert.NotNull(rqQuantizer);
+ Assert.Equal(4, rqQuantizer.Bits);
+ Assert.True(rqQuantizer.Centering);
+ Assert.Equal(20, rqQuantizer.RescoreLimit);
+ Assert.Equal(5000, rqQuantizer.TrainingLimit);
+ }
+
///
/// Tests that test flat rq
///
diff --git a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
index 79bfdae9..585647ac 100644
--- a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
@@ -264,4 +264,58 @@ public void VectorIndexConfig_HFresh_From_Json_With_NamedVector()
Assert.Equal(8, rq.Bits);
Assert.Equal(50, rq.RescoreLimit);
}
+
+ ///
+ /// Tests that a centered 4-bit RQ config serializes centering and
+ /// trainingLimit under the exact keys the server reads
+ /// (entities/vectorindex/hnsw/rq_config.go), omits them when unset so the server
+ /// defaults apply, and round-trips.
+ ///
+ [Fact]
+ public void VectorIndexConfig_HNSW_With_Centered_RQ4_Roundtrip()
+ {
+ var original = new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ
+ {
+ Bits = 4,
+ Centering = true,
+ RescoreLimit = 123,
+ TrainingLimit = 5012,
+ },
+ };
+ var plain = new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, RescoreLimit = 123 },
+ };
+
+ var json = VectorIndexSerialization.SerializeHnsw(original);
+ var jsonPlain = VectorIndexSerialization.SerializeHnsw(plain);
+
+ using var doc = JsonDocument.Parse(json);
+ var rqJson = doc.RootElement.GetProperty("rq");
+ Assert.Equal(4, rqJson.GetProperty("bits").GetInt32());
+ Assert.True(rqJson.GetProperty("centering").GetBoolean());
+ Assert.Equal(123, rqJson.GetProperty("rescoreLimit").GetInt32());
+ Assert.Equal(5012, rqJson.GetProperty("trainingLimit").GetInt32());
+
+ // The default PQ/SQ blocks carry their own trainingLimit, so scope the omit check to rq.
+ using var docPlain = JsonDocument.Parse(jsonPlain);
+ var rqJsonPlain = docPlain.RootElement.GetProperty("rq");
+ Assert.False(rqJsonPlain.TryGetProperty("centering", out _));
+ Assert.False(rqJsonPlain.TryGetProperty("trainingLimit", out _));
+
+ var dict = JsonSerializer.Deserialize>(
+ json,
+ Weaviate.Client.Rest.WeaviateRestClient.RestJsonSerializerOptions
+ );
+ var roundtripped = (VectorIndex.HNSW?)VectorIndexSerialization.Factory("hnsw", dict);
+
+ Assert.NotNull(roundtripped?.Quantizer);
+ var rq4 = Assert.IsType(roundtripped?.Quantizer);
+ Assert.Equal(4, rq4.Bits);
+ Assert.True(rq4.Centering);
+ Assert.Equal(123, rq4.RescoreLimit);
+ Assert.Equal(5012, rq4.TrainingLimit);
+ }
}
diff --git a/src/Weaviate.Client/Models/VectorIndex.cs b/src/Weaviate.Client/Models/VectorIndex.cs
index 802f6147..a0fde77f 100644
--- a/src/Weaviate.Client/Models/VectorIndex.cs
+++ b/src/Weaviate.Client/Models/VectorIndex.cs
@@ -253,6 +253,19 @@ public record RQ : QuantizerConfigFlat
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Cache { get; set; }
+ ///
+ /// Gets or sets whether to center the data before quantizing. Requires 4 bits and
+ /// is immutable once the collection is created (Weaviate 1.39.3 or later).
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public bool Centering { get; set; }
+
+ ///
+ /// Gets or sets the number of vectors used to train the centering statistics.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int TrainingLimit { get; set; }
+
///
/// Gets the type identifier for the quantizer.
///
diff --git a/src/Weaviate.Client/PublicAPI.Unshipped.txt b/src/Weaviate.Client/PublicAPI.Unshipped.txt
index c1daa908..970bd570 100644
--- a/src/Weaviate.Client/PublicAPI.Unshipped.txt
+++ b/src/Weaviate.Client/PublicAPI.Unshipped.txt
@@ -127,3 +127,7 @@ Weaviate.Client.Models.GenerativeConfig.Meta.Temperature.set -> void
Weaviate.Client.Models.GenerativeConfig.Meta.TopP.get -> double?
Weaviate.Client.Models.GenerativeConfig.Meta.TopP.set -> void
Weaviate.Client.Models.GenerativeConfig.Meta.Type.get -> string!
+Weaviate.Client.Models.VectorIndex.Quantizers.RQ.Centering.get -> bool
+Weaviate.Client.Models.VectorIndex.Quantizers.RQ.Centering.set -> void
+Weaviate.Client.Models.VectorIndex.Quantizers.RQ.TrainingLimit.get -> int
+Weaviate.Client.Models.VectorIndex.Quantizers.RQ.TrainingLimit.set -> void
From 92ac7b8dd815d3a27802a5dc2ecd01841ccc92bc Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Thu, 10 Sep 2026 10:48:35 +0200
Subject: [PATCH 2/4] feat: reject RQ centering without bits 4; run the
centered-RQ4 test in CI
---
.../Unit/TestVectorIndexConfig.cs | 33 +++++++++++++++++++
.../Models/Serialization.VectorIndexConfig.cs | 20 +++++++++--
2 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
index 585647ac..0aa0a4a0 100644
--- a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
@@ -318,4 +318,37 @@ public void VectorIndexConfig_HNSW_With_Centered_RQ4_Roundtrip()
Assert.Equal(123, rq4.RescoreLimit);
Assert.Equal(5012, rq4.TrainingLimit);
}
+
+ ///
+ /// Tests that serializing an RQ config with centering but without 4 bits throws, matching
+ /// the server rule, while a training limit without centering passes through — the server
+ /// accepts that combination and simply ignores the value.
+ ///
+ [Fact]
+ public void VectorIndexConfig_RQ_Centering_Without_4_Bits_Throws()
+ {
+ var withBits8 = new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, Centering = true },
+ };
+ var withBitsUnset = new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ { Centering = true },
+ };
+ var trainingLimitOnly = new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, TrainingLimit = 5000 },
+ };
+
+ var ex = Assert.Throws(() =>
+ VectorIndexSerialization.SerializeHnsw(withBits8)
+ );
+ Assert.Contains("RQ centering requires bits: 4", ex.Message);
+
+ Assert.Throws(() =>
+ VectorIndexSerialization.SerializeHnsw(withBitsUnset)
+ );
+
+ _ = VectorIndexSerialization.SerializeHnsw(trainingLimitOnly);
+ }
}
diff --git a/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs b/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
index 6fb80d7a..02911295 100644
--- a/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
+++ b/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
@@ -336,6 +336,20 @@ params QuantizerConfigBase?[] quantizers
return quantizers.FirstOrDefault(q => q?.Enabled == true);
}
+ // The server enforces the same rule (entities/vectorindex/hnsw/rq_config.go); failing here
+ // just surfaces it before the request. TrainingLimit is deliberately not checked: the server
+ // accepts it with any bits value and simply ignores it unless centering is enabled.
+ private static VectorIndex.Quantizers.RQ? ValidateRQ(VectorIndex.Quantizers.RQ? rq)
+ {
+ if (rq is { Centering: true } && rq.Bits != 4)
+ {
+ throw new WeaviateClientException(
+ $"RQ centering requires bits: 4, but got bits: {rq.Bits?.ToString() ?? "unset"}."
+ );
+ }
+ return rq;
+ }
+
// HNSW mapping
///
/// Returns the hnsw using the specified dto
@@ -471,7 +485,7 @@ public static HnswDto ToDto(this VectorIndex.HNSW hnsw)
dto.SQ = hnsw.Quantizer as VectorIndex.Quantizers.SQ;
break;
case "rq":
- dto.RQ = hnsw.Quantizer as VectorIndex.Quantizers.RQ;
+ dto.RQ = ValidateRQ(hnsw.Quantizer as VectorIndex.Quantizers.RQ);
break;
case "none":
dto.SkipDefaultQuantization = true;
@@ -530,7 +544,7 @@ public static FlatDto ToDto(this VectorIndex.Flat flat)
// dto.SQ = flat.Quantizer as VectorIndex.Quantizers.SQ;
// break;
case "rq":
- dto.RQ = flat.Quantizer as VectorIndex.Quantizers.RQ;
+ dto.RQ = ValidateRQ(flat.Quantizer as VectorIndex.Quantizers.RQ);
break;
}
}
@@ -615,7 +629,7 @@ public static HFreshDto ToDto(this VectorIndex.HFresh hfresh)
SearchProbe = hfresh.SearchProbe,
RQ = hfresh.Quantizer switch
{
- VectorIndex.Quantizers.RQ rq => rq,
+ VectorIndex.Quantizers.RQ rq => ValidateRQ(rq),
null => null,
_ => throw new WeaviateClientException(
$"HFresh only supports RQ quantization, but got '{hfresh.Quantizer.Type}'."
From a5ed1bbce14e1c0e174220bed3c5cebc17f412e9 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Fri, 11 Sep 2026 08:39:46 +0200
Subject: [PATCH 3/4] feat: reject RQ trainingLimit without bits 4
---
.../Unit/TestVectorIndexConfig.cs | 29 ++++++++++++-------
.../Models/Serialization.VectorIndexConfig.cs | 11 +++++--
2 files changed, 27 insertions(+), 13 deletions(-)
diff --git a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
index 0aa0a4a0..28dcbe73 100644
--- a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
@@ -320,35 +320,44 @@ public void VectorIndexConfig_HNSW_With_Centered_RQ4_Roundtrip()
}
///
- /// Tests that serializing an RQ config with centering but without 4 bits throws, matching
- /// the server rule, while a training limit without centering passes through — the server
- /// accepts that combination and simply ignores the value.
+ /// Tests that serializing an RQ config with centering or a training limit but without
+ /// 4 bits throws — deliberately stricter than the server, which ignores a training limit
+ /// when centering is off — while either parameter with 4 bits passes through.
///
[Fact]
- public void VectorIndexConfig_RQ_Centering_Without_4_Bits_Throws()
+ public void VectorIndexConfig_RQ_CenteredParams_Without_4_Bits_Throw()
{
- var withBits8 = new VectorIndex.HNSW
+ var centeringWithBits8 = new VectorIndex.HNSW
{
Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, Centering = true },
};
- var withBitsUnset = new VectorIndex.HNSW
+ var centeringWithBitsUnset = new VectorIndex.HNSW
{
Quantizer = new VectorIndex.Quantizers.RQ { Centering = true },
};
- var trainingLimitOnly = new VectorIndex.HNSW
+ var trainingLimitWithBits8 = new VectorIndex.HNSW
{
Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, TrainingLimit = 5000 },
};
+ var trainingLimitWithBits4 = new VectorIndex.HNSW
+ {
+ Quantizer = new VectorIndex.Quantizers.RQ { Bits = 4, TrainingLimit = 5000 },
+ };
var ex = Assert.Throws(() =>
- VectorIndexSerialization.SerializeHnsw(withBits8)
+ VectorIndexSerialization.SerializeHnsw(centeringWithBits8)
);
Assert.Contains("RQ centering requires bits: 4", ex.Message);
Assert.Throws(() =>
- VectorIndexSerialization.SerializeHnsw(withBitsUnset)
+ VectorIndexSerialization.SerializeHnsw(centeringWithBitsUnset)
+ );
+
+ var exTrainingLimit = Assert.Throws(() =>
+ VectorIndexSerialization.SerializeHnsw(trainingLimitWithBits8)
);
+ Assert.Contains("RQ trainingLimit requires bits: 4", exTrainingLimit.Message);
- _ = VectorIndexSerialization.SerializeHnsw(trainingLimitOnly);
+ _ = VectorIndexSerialization.SerializeHnsw(trainingLimitWithBits4);
}
}
diff --git a/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs b/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
index 02911295..ac732bb5 100644
--- a/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
+++ b/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
@@ -336,9 +336,8 @@ params QuantizerConfigBase?[] quantizers
return quantizers.FirstOrDefault(q => q?.Enabled == true);
}
- // The server enforces the same rule (entities/vectorindex/hnsw/rq_config.go); failing here
- // just surfaces it before the request. TrainingLimit is deliberately not checked: the server
- // accepts it with any bits value and simply ignores it unless centering is enabled.
+ // Deliberately stricter than the server, which ignores trainingLimit without centering:
+ // both centered-RQ4 parameters fail fast here unless bits is 4.
private static VectorIndex.Quantizers.RQ? ValidateRQ(VectorIndex.Quantizers.RQ? rq)
{
if (rq is { Centering: true } && rq.Bits != 4)
@@ -347,6 +346,12 @@ params QuantizerConfigBase?[] quantizers
$"RQ centering requires bits: 4, but got bits: {rq.Bits?.ToString() ?? "unset"}."
);
}
+ if (rq is { TrainingLimit: not 0 } && rq.Bits != 4)
+ {
+ throw new WeaviateClientException(
+ $"RQ trainingLimit requires bits: 4, but got bits: {rq.Bits?.ToString() ?? "unset"}."
+ );
+ }
return rq;
}
From 870126b20bf52f5a090f3154d9f629553fb30f16 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Mon, 14 Sep 2026 12:00:55 +0200
Subject: [PATCH 4/4] Revert "feat: reject RQ trainingLimit without bits 4"
From 1.39.3 the server echoes trainingLimit on every RQ config it returns,
so the check made read-modify-write of any RQ8 collection fail client-side
(caught by Test_sq_and_rq in CI). Centering-only guard restored.
---
.../Unit/TestVectorIndexConfig.cs | 29 +++++++------------
.../Models/Serialization.VectorIndexConfig.cs | 11 ++-----
2 files changed, 13 insertions(+), 27 deletions(-)
diff --git a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
index 28dcbe73..0aa0a4a0 100644
--- a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs
@@ -320,44 +320,35 @@ public void VectorIndexConfig_HNSW_With_Centered_RQ4_Roundtrip()
}
///
- /// Tests that serializing an RQ config with centering or a training limit but without
- /// 4 bits throws — deliberately stricter than the server, which ignores a training limit
- /// when centering is off — while either parameter with 4 bits passes through.
+ /// Tests that serializing an RQ config with centering but without 4 bits throws, matching
+ /// the server rule, while a training limit without centering passes through — the server
+ /// accepts that combination and simply ignores the value.
///
[Fact]
- public void VectorIndexConfig_RQ_CenteredParams_Without_4_Bits_Throw()
+ public void VectorIndexConfig_RQ_Centering_Without_4_Bits_Throws()
{
- var centeringWithBits8 = new VectorIndex.HNSW
+ var withBits8 = new VectorIndex.HNSW
{
Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, Centering = true },
};
- var centeringWithBitsUnset = new VectorIndex.HNSW
+ var withBitsUnset = new VectorIndex.HNSW
{
Quantizer = new VectorIndex.Quantizers.RQ { Centering = true },
};
- var trainingLimitWithBits8 = new VectorIndex.HNSW
+ var trainingLimitOnly = new VectorIndex.HNSW
{
Quantizer = new VectorIndex.Quantizers.RQ { Bits = 8, TrainingLimit = 5000 },
};
- var trainingLimitWithBits4 = new VectorIndex.HNSW
- {
- Quantizer = new VectorIndex.Quantizers.RQ { Bits = 4, TrainingLimit = 5000 },
- };
var ex = Assert.Throws(() =>
- VectorIndexSerialization.SerializeHnsw(centeringWithBits8)
+ VectorIndexSerialization.SerializeHnsw(withBits8)
);
Assert.Contains("RQ centering requires bits: 4", ex.Message);
Assert.Throws(() =>
- VectorIndexSerialization.SerializeHnsw(centeringWithBitsUnset)
- );
-
- var exTrainingLimit = Assert.Throws(() =>
- VectorIndexSerialization.SerializeHnsw(trainingLimitWithBits8)
+ VectorIndexSerialization.SerializeHnsw(withBitsUnset)
);
- Assert.Contains("RQ trainingLimit requires bits: 4", exTrainingLimit.Message);
- _ = VectorIndexSerialization.SerializeHnsw(trainingLimitWithBits4);
+ _ = VectorIndexSerialization.SerializeHnsw(trainingLimitOnly);
}
}
diff --git a/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs b/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
index ac732bb5..02911295 100644
--- a/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
+++ b/src/Weaviate.Client/Models/Serialization.VectorIndexConfig.cs
@@ -336,8 +336,9 @@ params QuantizerConfigBase?[] quantizers
return quantizers.FirstOrDefault(q => q?.Enabled == true);
}
- // Deliberately stricter than the server, which ignores trainingLimit without centering:
- // both centered-RQ4 parameters fail fast here unless bits is 4.
+ // The server enforces the same rule (entities/vectorindex/hnsw/rq_config.go); failing here
+ // just surfaces it before the request. TrainingLimit is deliberately not checked: the server
+ // accepts it with any bits value and simply ignores it unless centering is enabled.
private static VectorIndex.Quantizers.RQ? ValidateRQ(VectorIndex.Quantizers.RQ? rq)
{
if (rq is { Centering: true } && rq.Bits != 4)
@@ -346,12 +347,6 @@ params QuantizerConfigBase?[] quantizers
$"RQ centering requires bits: 4, but got bits: {rq.Bits?.ToString() ?? "unset"}."
);
}
- if (rq is { TrainingLimit: not 0 } && rq.Bits != 4)
- {
- throw new WeaviateClientException(
- $"RQ trainingLimit requires bits: 4, but got bits: {rq.Bits?.ToString() ?? "unset"}."
- );
- }
return rq;
}