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..0aa0a4a0 100644 --- a/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs +++ b/src/Weaviate.Client.Tests/Unit/TestVectorIndexConfig.cs @@ -264,4 +264,91 @@ 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); + } + + /// + /// 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}'." 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