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; }