From c067b5e54a690f2b310ae145c95397e760d0b91b Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Tue, 25 Aug 2026 16:01:25 -0300 Subject: [PATCH 1/2] fix(quantizers): use the field names the server actually reads Weaviate parses quantizer settings out of the config map by exact key, and returns them under the same names. The client used snake_case for half of them, so rescoreLimit, trainingLimit and bitCompression were dropped on write and came back null on read -- on every index type. A user who set a rescore limit never set one. rescore_limit -> rescoreLimit (BQ, SQ, RQ) training_limit -> trainingLimit (PQ, SQ) bit_compression -> bitCompression (PQ) PQ's encoder needed a shape change rather than a rename: the server nests it as encoder: {type, distribution} while the client had two flat components. PQ.encoderType() and PQ.encoderDistribution() are kept as derived accessors and the builder is unchanged, so only the canonical constructor differs. No alternate names: the snake_case spellings were never valid on the wire, so no stored config uses them. Closes #611 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WmY5dAGWCccWDoqkKNC2JU --- .../integration/CollectionsITest.java | 32 +++++++++++++++++++ .../v1/api/collections/quantizers/BQ.java | 2 +- .../v1/api/collections/quantizers/PQ.java | 31 ++++++++++++++---- .../v1/api/collections/quantizers/RQ.java | 2 +- .../v1/api/collections/quantizers/SQ.java | 4 +-- .../client6/v1/internal/json/JSONTest.java | 18 ++++++----- 6 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/it/java/io/weaviate/integration/CollectionsITest.java b/src/it/java/io/weaviate/integration/CollectionsITest.java index 03871d6cf..c4bc1ef19 100644 --- a/src/it/java/io/weaviate/integration/CollectionsITest.java +++ b/src/it/java/io/weaviate/integration/CollectionsITest.java @@ -29,6 +29,7 @@ import io.weaviate.client6.v1.api.collections.config.ShardStatus; import io.weaviate.client6.v1.api.collections.generative.DummyGenerative; import io.weaviate.client6.v1.api.collections.query.BaseQueryOptions; +import io.weaviate.client6.v1.api.collections.quantizers.RQ; import io.weaviate.client6.v1.api.collections.vectorindex.Hnsw; import io.weaviate.client6.v1.api.collections.vectorizers.SelfProvidedVectorizer; import io.weaviate.containers.Container; @@ -266,6 +267,37 @@ public void test_updateQuantization_uncompressed() throws IOException { .returns(Quantization.Kind.BQ, Quantization::_kind); } + /** + * The quantizer settings have to survive a round trip through the server. + * + *

+ * They used to be sent under snake_case names ({@code rescore_limit}, + * {@code training_limit}, ...) that Weaviate looks up verbatim and therefore + * never read, so every one of them was dropped on write and came back null. + * Asserting on {@code enabled} alone did not catch it. + */ + @Test + public void test_quantizerSettingsRoundTrip() throws IOException { + // Arrange + var nsThings = ns("Things"); + + var things = client.collections.create(nsThings, + c -> c.vectorConfig(VectorConfig.selfProvided( + self -> self.quantization(Quantization.rq(rq -> rq.rescoreLimit(42).bits(8)))))); + + // Act + var config = things.config.get(); + + // Assert + Assertions.assertThat(config).get() + .extracting(CollectionConfig::vectors) + .extracting("default", InstanceOfAssertFactories.type(VectorConfig.class)) + .extracting(VectorConfig::quantization) + .asInstanceOf(InstanceOfAssertFactories.type(RQ.class)) + .returns(42, RQ::rescoreLimit) + .returns(8, RQ::bits); + } + @Test public void test_updateGenerative() throws IOException { // Arrange diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/BQ.java b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/BQ.java index 9d4cdb691..bcc24d539 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/BQ.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/BQ.java @@ -9,7 +9,7 @@ public record BQ( @SerializedName("enabled") boolean enabled, - @SerializedName("rescore_limit") Integer rescoreLimit, + @SerializedName("rescoreLimit") Integer rescoreLimit, @SerializedName("cache") Boolean cache) implements Quantization { @Override diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/PQ.java b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/PQ.java index a4806c12b..348367fab 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/PQ.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/PQ.java @@ -11,10 +11,28 @@ public record PQ( @SerializedName("enabled") boolean enabled, @SerializedName("centroids") Integer centroids, @SerializedName("segments") Integer segments, - @SerializedName("encoder_type") EncoderType encoderType, - @SerializedName("encoder_distribution") EncoderDistribution encoderDistribution, - @SerializedName("training_limit") Integer trainingLimit, - @SerializedName("bit_compression") Boolean bitCompression) implements Quantization { + /** + * Encoder settings, which the server nests one level deeper as + * {@code encoder: {type, distribution}}. + */ + @SerializedName("encoder") Encoder encoder, + @SerializedName("trainingLimit") Integer trainingLimit, + @SerializedName("bitCompression") Boolean bitCompression) implements Quantization { + + /** Type of the encoder, or {@code null} if it was left at the server default. */ + public EncoderType encoderType() { + return encoder != null ? encoder.type() : null; + } + + /** Encoder distribution, or {@code null} if left at the server default. */ + public EncoderDistribution encoderDistribution() { + return encoder != null ? encoder.distribution() : null; + } + + public record Encoder( + @SerializedName("type") EncoderType type, + @SerializedName("distribution") EncoderDistribution distribution) { + } public enum EncoderType { @SerializedName("kmeans") @@ -53,8 +71,9 @@ public PQ(Builder builder) { builder.enabled, builder.centroids, builder.segments, - builder.encoderType, - builder.encoderDistribution, + builder.encoderType == null && builder.encoderDistribution == null + ? null + : new Encoder(builder.encoderType, builder.encoderDistribution), builder.trainingLimit, builder.bitCompression); } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/RQ.java b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/RQ.java index 43dbfb2e0..dc153c316 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/RQ.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/RQ.java @@ -9,7 +9,7 @@ public record RQ( @SerializedName("enabled") boolean enabled, - @SerializedName("rescore_limit") Integer rescoreLimit, + @SerializedName("rescoreLimit") Integer rescoreLimit, @SerializedName("bits") Integer bits, @SerializedName("cache") Boolean cache) implements Quantization { diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/SQ.java b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/SQ.java index ccd9f7070..7be39afa3 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/SQ.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/quantizers/SQ.java @@ -9,8 +9,8 @@ public record SQ( @SerializedName("enabled") boolean enabled, - @SerializedName("rescore_limit") Integer rescoreLimit, - @SerializedName("training_limit") Integer trainingLimit, + @SerializedName("rescoreLimit") Integer rescoreLimit, + @SerializedName("trainingLimit") Integer trainingLimit, @SerializedName("cache") Boolean cache) implements Quantization { @Override diff --git a/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java b/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java index e7c0d6203..481859e1f 100644 --- a/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java +++ b/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java @@ -1206,11 +1206,13 @@ public static Object[][] testCases() { "pq": { "enabled": true, "centroids": 8, - "encoder_distribution": "normal", - "encoder_type": "tile", + "encoder": { + "type": "tile", + "distribution": "normal" + }, "segments": 16, - "training_limit": 1024, - "bit_compression": true + "trainingLimit": 1024, + "bitCompression": true } } } @@ -1230,8 +1232,8 @@ public static Object[][] testCases() { "vectorIndexConfig": { "sq": { "enabled": true, - "rescore_limit": 10, - "training_limit": 1024, + "rescoreLimit": 10, + "trainingLimit": 1024, "cache": true } } @@ -1251,7 +1253,7 @@ public static Object[][] testCases() { "vectorIndexConfig": { "rq": { "enabled": true, - "rescore_limit": 10, + "rescoreLimit": 10, "bits": 8 } } @@ -1271,7 +1273,7 @@ public static Object[][] testCases() { "vectorIndexConfig": { "bq": { "enabled": true, - "rescore_limit": 10, + "rescoreLimit": 10, "cache": true } } From 48ef0f75cff00cf21a12bbccccc85f5ba2619496 Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Tue, 25 Aug 2026 16:12:15 -0300 Subject: [PATCH 2/2] fix(collections): nest the quantizer inside a dynamic index A dynamic index is {distance, threshold, hnsw: {...}, flat: {...}} and each sub-index carries its own quantizer, but the client put the quantizer beside them and looked for it there on the way back. So a dynamic index created through this client was silently unquantized, and one quantized by other means read as quantization() == null. VectorConfig has a single quantization slot, so a dynamic index is read from hnsw when it has a quantizer and from flat otherwise, and always written to hnsw -- the only sub-index accepting every quantizer type, flat being limited to bq. Giving hnsw and flat different quantizers stays inexpressible; that needs per-index slots on Hnsw and Flat. The same nesting applies to the skipDefaultQuantization flag that the update request has to preserve, so both call the shared QuantizerJson. Also on Dynamic: "distance" is read and written (the server keeps one at the dynamic level), and the response parser no longer assumes "threshold" is present -- a dropped index has neither. Closes #606 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WmY5dAGWCccWDoqkKNC2JU --- .../java/io/weaviate/containers/Weaviate.java | 9 ++ .../integration/CollectionsITest.java | 53 +++++++++ .../v1/api/collections/VectorConfig.java | 23 ++-- .../config/UpdateCollectionRequest.java | 11 +- .../api/collections/vectorindex/Dynamic.java | 24 +++- .../v1/internal/json/QuantizerJson.java | 76 ++++++++++++ .../collections/DynamicQuantizationTest.java | 112 ++++++++++++++++++ .../client6/v1/internal/json/JSONTest.java | 28 +++++ 8 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/weaviate/client6/v1/internal/json/QuantizerJson.java create mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/DynamicQuantizationTest.java diff --git a/src/it/java/io/weaviate/containers/Weaviate.java b/src/it/java/io/weaviate/containers/Weaviate.java index 881825def..b9f07ae28 100644 --- a/src/it/java/io/weaviate/containers/Weaviate.java +++ b/src/it/java/io/weaviate/containers/Weaviate.java @@ -282,6 +282,15 @@ public Builder enableAutoSchema(boolean enable) { return this; } + /** + * Enable asynchronous vector indexing. Required by the server to create + * "dynamic" vector indexes. + */ + public Builder enableAsyncIndexing(boolean enable) { + environment.put("ASYNC_INDEXING", Boolean.toString(enable)); + return this; + } + public Builder enableAnonymousAccess(boolean enable) { environment.put("AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED", Boolean.toString(enable)); return this; diff --git a/src/it/java/io/weaviate/integration/CollectionsITest.java b/src/it/java/io/weaviate/integration/CollectionsITest.java index c4bc1ef19..dbd35f499 100644 --- a/src/it/java/io/weaviate/integration/CollectionsITest.java +++ b/src/it/java/io/weaviate/integration/CollectionsITest.java @@ -30,6 +30,8 @@ import io.weaviate.client6.v1.api.collections.generative.DummyGenerative; import io.weaviate.client6.v1.api.collections.query.BaseQueryOptions; import io.weaviate.client6.v1.api.collections.quantizers.RQ; +import io.weaviate.client6.v1.api.collections.vectorindex.Dynamic; +import io.weaviate.client6.v1.api.collections.vectorindex.Flat; import io.weaviate.client6.v1.api.collections.vectorindex.Hnsw; import io.weaviate.client6.v1.api.collections.vectorizers.SelfProvidedVectorizer; import io.weaviate.containers.Container; @@ -38,6 +40,13 @@ public class CollectionsITest extends ConcurrentTest { private static WeaviateClient client = Container.WEAVIATE.getClient(); + /** + * "dynamic" vector indexes are only accepted by a server started with + * ASYNC_INDEXING=true, which the shared container is not. + */ + private static final Weaviate asyncIndexing = Weaviate.custom() + .enableAsyncIndexing(true).build(); + @Test public void testCreateGetDelete() throws IOException { var collectionName = ns("Things"); @@ -276,6 +285,50 @@ public void test_updateQuantization_uncompressed() throws IOException { * never read, so every one of them was dropped on write and came back null. * Asserting on {@code enabled} alone did not catch it. */ + /** + * A dynamic index keeps its quantizer inside "hnsw"/"flat". + * + *

+ * The client used to write it beside them, where the server never looks, so a + * dynamic index created through this client came back unquantized -- and even + * one quantized by other means read as {@code quantization() == null}. + */ + @Test + public void test_dynamicIndexQuantizationRoundTrip() throws IOException { + // Arrange: "dynamic" is only accepted by a server started with + // ASYNC_INDEXING=true, which the shared container is not -- asynchronous + // indexing would make freshly inserted vectors searchable only eventually, + // and the other suites rely on them being searchable at once. + var asyncClient = asyncIndexing.getClient(); + var nsThings = ns("Things"); + + var things = asyncClient.collections.create(nsThings, + c -> c.vectorConfig(VectorConfig.selfProvided( + self -> self + .vectorIndex(Dynamic.of(idx -> idx + .hnsw(Hnsw.of(hnsw -> hnsw.ef(64))) + .flat(Flat.of(flat -> flat.vectorCacheMaxObjects(1000))) + .threshold(5000))) + .quantization(Quantization.rq(rq -> rq.rescoreLimit(20).bits(8)))))); + + // Act + var config = things.config.get(); + + // Assert: the server kept it, and we can read it back off the dynamic index. + Assertions.assertThat(config).get() + .extracting(CollectionConfig::vectors) + .extracting("default", InstanceOfAssertFactories.type(VectorConfig.class)) + .satisfies(vector -> { + Assertions.assertThat(vector.vectorIndex()) + .as("index type").isInstanceOf(Dynamic.class); + Assertions.assertThat(vector.quantization()) + .as("quantizer nested under hnsw").isNotNull() + .asInstanceOf(InstanceOfAssertFactories.type(RQ.class)) + .returns(20, RQ::rescoreLimit) + .returns(8, RQ::bits); + }); + } + @Test public void test_quantizerSettingsRoundTrip() throws IOException { // Arrange diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/VectorConfig.java b/src/main/java/io/weaviate/client6/v1/api/collections/VectorConfig.java index 4fa6ae6d0..ca7d47c08 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/VectorConfig.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/VectorConfig.java @@ -6,6 +6,7 @@ import java.util.function.Function; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.TypeAdapter; @@ -50,6 +51,7 @@ import io.weaviate.client6.v1.internal.ObjectBuilder; import io.weaviate.client6.v1.internal.TaggedUnion; import io.weaviate.client6.v1.internal.json.JsonEnum; +import io.weaviate.client6.v1.internal.json.QuantizerJson; public interface VectorConfig extends TaggedUnion { public enum Kind implements JsonEnum { @@ -1815,8 +1817,10 @@ public void write(JsonWriter out, VectorConfig value) throws IOException { vectorIndex.getAsJsonObject().add("vectorizer", vectorizer); if (value.quantization() != null && !config.getAsJsonObject().get("quantization").isJsonNull()) { - vectorIndex.getAsJsonObject() - .get("vectorIndexConfig").getAsJsonObject() + QuantizerJson.host( + vectorIndex.getAsJsonObject().get("vectorIndexConfig").getAsJsonObject(), + vectorIndex.getAsJsonObject().get("vectorIndexType"), + true) .add(value.quantization()._kind().jsonValue(), config.getAsJsonObject().remove("quantization")); } @@ -1833,19 +1837,22 @@ public VectorConfig read(JsonReader in) throws IOException { vectorIndexConfig = jsonObject.get("vectorIndexConfig").getAsJsonObject(); } + // A dynamic index keeps its quantizer one level deeper, inside "hnsw"/"flat". + var quantizers = QuantizerJson.host(vectorIndexConfig, jsonObject.get("vectorIndexType"), false); + String quantizationKind = null; for (var kind : new String[] { Quantization.Kind.BQ.jsonValue(), Quantization.Kind.PQ.jsonValue(), Quantization.Kind.SQ.jsonValue(), Quantization.Kind.RQ.jsonValue() }) { - if (vectorIndexConfig.has(kind) - && vectorIndexConfig.get(kind).getAsJsonObject().get("enabled").getAsBoolean()) { + if (quantizers.has(kind) + && quantizers.get(kind).getAsJsonObject().get("enabled").getAsBoolean()) { quantizationKind = kind; } } - if (quantizationKind == null && vectorIndexConfig.has(Quantization.Kind.UNCOMPRESSED.jsonValue()) - && vectorIndexConfig.get(Quantization.Kind.UNCOMPRESSED.jsonValue()).getAsBoolean()) { + if (quantizationKind == null && quantizers.has(Quantization.Kind.UNCOMPRESSED.jsonValue()) + && quantizers.get(Quantization.Kind.UNCOMPRESSED.jsonValue()).getAsBoolean()) { quantizationKind = Quantization.Kind.UNCOMPRESSED.jsonValue(); } @@ -1880,9 +1887,9 @@ public VectorConfig read(JsonReader in) throws IOException { // Each individual vectorizer has a `Quantization quantization` field. // We need to specify the kind in order for // Quantization.CustomTypeAdapterFactory to be able to find the right adapter. - if (quantizationKind != null && vectorIndexConfig.has(quantizationKind)) { + if (quantizationKind != null && quantizers.has(quantizationKind)) { JsonObject quantization = new JsonObject(); - quantization.add(quantizationKind, vectorIndexConfig.get(quantizationKind)); + quantization.add(quantizationKind, quantizers.get(quantizationKind)); concreteVectorizer.add("quantization", quantization); } else { concreteVectorizer.add("quantization", null); diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/config/UpdateCollectionRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/config/UpdateCollectionRequest.java index ea79f8f7c..210f7b550 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/config/UpdateCollectionRequest.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/config/UpdateCollectionRequest.java @@ -16,6 +16,7 @@ import io.weaviate.client6.v1.api.collections.VectorConfig; import io.weaviate.client6.v1.internal.ObjectBuilder; import io.weaviate.client6.v1.internal.json.JSON; +import io.weaviate.client6.v1.internal.json.QuantizerJson; import io.weaviate.client6.v1.internal.rest.Endpoint; import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; @@ -42,9 +43,13 @@ public record UpdateCollectionRequest(CollectionConfig updated, CollectionConfig var vectorName = origVector.getKey(); var origQuantization = origVector.getValue().quantization(); if (vectors.has(vectorName) && origQuantization != null) { - vectors - .get(vectorName).getAsJsonObject() - .get("vectorIndexConfig").getAsJsonObject() + var vector = vectors.get(vectorName).getAsJsonObject(); + // A dynamic index keeps its quantizer settings inside "hnsw"/"flat", + // so the flag has to land at the same depth the server reads it from. + QuantizerJson.host( + vector.get("vectorIndexConfig").getAsJsonObject(), + vector.get("vectorIndexType"), + true) .addProperty(Quantization.Kind.UNCOMPRESSED.jsonValue(), origQuantization.isUncompressed()); } } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/vectorindex/Dynamic.java b/src/main/java/io/weaviate/client6/v1/api/collections/vectorindex/Dynamic.java index 63d822674..823c47594 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/vectorindex/Dynamic.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/vectorindex/Dynamic.java @@ -20,7 +20,9 @@ public record Dynamic( @SerializedName("hnsw") Hnsw hnsw, @SerializedName("flat") Flat flat, - @SerializedName("threshold") Long threshold) + @SerializedName("threshold") Long threshold, + /** Distance metric, which a dynamic index carries at its own level. */ + @SerializedName("distance") Distance distance) implements VectorIndex { @Override @@ -45,7 +47,8 @@ public Dynamic(Builder builder) { this( builder.hnsw, builder.flat, - builder.threshold); + builder.threshold, + builder.distance); } public static class Builder implements ObjectBuilder { @@ -53,6 +56,7 @@ public static class Builder implements ObjectBuilder { private Hnsw hnsw; private Flat flat; private Long threshold; + private Distance distance; public Builder hnsw(Hnsw hnsw) { this.hnsw = hnsw; @@ -69,6 +73,11 @@ public Builder threshold(long threshold) { return this; } + public Builder distance(Distance distance) { + this.distance = distance; + return this; + } + @Override public Dynamic build() { return new Dynamic(this); @@ -99,6 +108,9 @@ public void write(JsonWriter out, Dynamic value) throws IOException { var dynamic = new JsonObject(); dynamic.addProperty("threshold", value.threshold); + if (value.distance != null) { + dynamic.add("distance", gson.toJsonTree(value.distance)); + } dynamic.add("hnsw", hnswAdapter.toJsonTree(value.hnsw)); dynamic.add("flat", flatAdapter.toJsonTree(value.flat)); @@ -111,8 +123,12 @@ public Dynamic read(JsonReader in) throws IOException { var hnsw = hnswAdapter.fromJsonTree(jsonObject.get("hnsw")); var flat = flatAdapter.fromJsonTree(jsonObject.get("flat")); - var threshold = jsonObject.get("threshold").getAsLong(); - return new Dynamic(hnsw, flat, threshold); + // Both are optional in the response: a dropped index has neither. + var threshold = jsonObject.has("threshold") && jsonObject.get("threshold").isJsonPrimitive() + ? jsonObject.get("threshold").getAsLong() + : null; + Distance distance = gson.fromJson(jsonObject.get("distance"), Distance.class); + return new Dynamic(hnsw, flat, threshold, distance); } }.nullSafe(); } diff --git a/src/main/java/io/weaviate/client6/v1/internal/json/QuantizerJson.java b/src/main/java/io/weaviate/client6/v1/internal/json/QuantizerJson.java new file mode 100644 index 000000000..6331b1daa --- /dev/null +++ b/src/main/java/io/weaviate/client6/v1/internal/json/QuantizerJson.java @@ -0,0 +1,76 @@ +package io.weaviate.client6.v1.internal.json; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import io.weaviate.client6.v1.api.collections.Quantization; +import io.weaviate.client6.v1.api.collections.VectorIndex; + +/** + * Locating the quantizer inside a {@code vectorIndexConfig} payload. + * + *

+ * Shared by the read/write sides of {@code VectorConfig} and by the collection + * update request, which all have to agree on where the quantizer keys live. + */ +public final class QuantizerJson { + /** Prevent public initialization. */ + private QuantizerJson() { + } + + /** + * The object inside {@code vectorIndexConfig} which holds the quantizer keys. + * + *

+ * For {@code hnsw} and {@code flat} that is {@code vectorIndexConfig} itself, + * but a {@code dynamic} index is + * {distance, threshold, hnsw: {...}, flat: {...}} and each + * sub-index carries its own quantizer, so the keys sit one level deeper. + * + *

+ * A vector config has a single quantization slot, so a dynamic index is read + * from {@code hnsw} when it has a quantizer and from {@code flat} otherwise, + * and written to {@code hnsw} -- which is also the only sub-index accepting + * every quantizer type, {@code flat} being limited to {@code bq}. Giving + * {@code hnsw} and {@code flat} different quantizers is not + * expressible through this client. + * + * @param create when true the nested object is created if the payload does not + * have one yet; when false an empty object is returned instead, + * so the caller simply finds nothing. + */ + public static JsonObject host(JsonObject vectorIndexConfig, JsonElement vectorIndexType, boolean create) { + if (!isDynamic(vectorIndexType)) { + return vectorIndexConfig; + } + + for (var subIndex : new String[] { "hnsw", "flat" }) { + var nested = vectorIndexConfig.get(subIndex); + if (nested != null && nested.isJsonObject() && (create || hasQuantizer(nested.getAsJsonObject()))) { + return nested.getAsJsonObject(); + } + } + + if (create) { + var hnsw = new JsonObject(); + vectorIndexConfig.add("hnsw", hnsw); + return hnsw; + } + return new JsonObject(); + } + + private static boolean isDynamic(JsonElement vectorIndexType) { + return vectorIndexType != null + && vectorIndexType.isJsonPrimitive() + && VectorIndex.Kind.DYNAMIC.jsonValue().equals(vectorIndexType.getAsString()); + } + + private static boolean hasQuantizer(JsonObject subIndex) { + for (var kind : Quantization.Kind.values()) { + if (subIndex.has(kind.jsonValue())) { + return true; + } + } + return false; + } +} diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/DynamicQuantizationTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/DynamicQuantizationTest.java new file mode 100644 index 000000000..b902e1019 --- /dev/null +++ b/src/test/java/io/weaviate/client6/v1/api/collections/DynamicQuantizationTest.java @@ -0,0 +1,112 @@ +package io.weaviate.client6.v1.api.collections; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import io.weaviate.client6.v1.api.collections.vectorindex.Distance; +import io.weaviate.client6.v1.api.collections.vectorindex.Dynamic; +import io.weaviate.client6.v1.internal.json.JSON; + +/** + * Reading a quantizer off a dynamic index. + * + *

+ * The round-trip cases live in {@code JSONTest}; these cover the shapes the + * server can return but this client never writes, since a single + * {@code quantization()} slot always writes to {@code hnsw}. + */ +public class DynamicQuantizationTest { + + private static String dynamic(String hnswExtra, String flatExtra) { + return """ + { + "vectorIndexType": "dynamic", + "vectorizer": {"none": {}}, + "vectorIndexConfig": { + "threshold": 10000, + "hnsw": {"ef": -1%s}, + "flat": {"vectorCacheMaxObjects": 1000000%s} + } + } + """.formatted(hnswExtra, flatExtra); + } + + /** The reported case: a dynamic index with RQ enabled on hnsw. */ + @Test + public void test_readsQuantizerFromHnsw() { + var config = JSON.deserialize( + dynamic(", \"rq\": {\"enabled\": true, \"bits\": 8, \"rescoreLimit\": 20}", ""), + VectorConfig.class); + + Assertions.assertThat(config.quantization()) + .isNotNull() + .returns(Quantization.Kind.RQ, Quantization::_kind); + Assertions.assertThat(config.quantization().asRQ().rescoreLimit()).isEqualTo(20); + Assertions.assertThat(config.quantization().asRQ().bits()).isEqualTo(8); + } + + /** flat carries its own quantizer, and only bq is valid there. */ + @Test + public void test_readsQuantizerFromFlatWhenHnswHasNone() { + var config = JSON.deserialize( + dynamic("", ", \"bq\": {\"enabled\": true, \"rescoreLimit\": 5}"), + VectorConfig.class); + + Assertions.assertThat(config.quantization()) + .isNotNull() + .returns(Quantization.Kind.BQ, Quantization::_kind); + Assertions.assertThat(config.quantization().asBQ().rescoreLimit()).isEqualTo(5); + } + + /** + * hnsw and flat can carry different quantizers, which one slot cannot + * represent. hnsw wins; see QuantizerJson. + */ + @Test + public void test_prefersHnswWhenBothCarryOne() { + var config = JSON.deserialize( + dynamic(", \"rq\": {\"enabled\": true, \"bits\": 8}", ", \"bq\": {\"enabled\": true}"), + VectorConfig.class); + + Assertions.assertThat(config.quantization()) + .returns(Quantization.Kind.RQ, Quantization::_kind); + } + + @Test + public void test_noQuantizerIsNull() { + var config = JSON.deserialize(dynamic("", ""), VectorConfig.class); + + Assertions.assertThat(config.quantization()).isNull(); + } + + /** A disabled quantizer reads as absent, same as for hnsw and flat indexes. */ + @Test + public void test_disabledQuantizerIsNull() { + var config = JSON.deserialize( + dynamic(", \"rq\": {\"enabled\": false, \"bits\": 8}", ""), + VectorConfig.class); + + Assertions.assertThat(config.quantization()).isNull(); + } + + /** The dynamic level carries its own distance, which used to be dropped. */ + @Test + public void test_readsDistanceAtDynamicLevel() { + var config = JSON.deserialize(""" + { + "vectorIndexType": "dynamic", + "vectorizer": {"none": {}}, + "vectorIndexConfig": { + "threshold": 10000, + "distance": "l2-squared", + "hnsw": {"ef": -1}, + "flat": {"vectorCacheMaxObjects": 1000000} + } + } + """, VectorConfig.class); + + Assertions.assertThat(config.vectorIndex().asDynamic()) + .returns(Distance.L2_SQUARED, Dynamic::distance) + .returns(10000L, Dynamic::threshold); + } +} diff --git a/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java b/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java index 481859e1f..d4de18ba2 100644 --- a/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java +++ b/src/test/java/io/weaviate/client6/v1/internal/json/JSONTest.java @@ -1188,6 +1188,34 @@ public static Object[][] testCases() { } """, }, + // A dynamic index nests its quantizer inside "hnsw"/"flat" -- the client + // used to put it beside them, where the server never looks, and could not + // read it back either. + { + VectorConfig.class, + SelfProvidedVectorizer.of(none -> none + .vectorIndex(Dynamic.of(idx -> idx + .hnsw(Hnsw.of(hnsw -> hnsw.ef(1))) + .flat(Flat.of(flat -> flat.vectorCacheMaxObjects(100))) + .threshold(5) + .distance(Distance.COSINE))) + .quantization(Quantization.rq(rq -> rq.rescoreLimit(20).bits(8)))), + """ + { + "vectorIndexType": "dynamic", + "vectorizer": {"none": {}}, + "vectorIndexConfig": { + "flat": {"vectorCacheMaxObjects": 100}, + "hnsw": { + "ef": 1, + "rq": {"enabled": true, "rescoreLimit": 20, "bits": 8} + }, + "threshold": 5, + "distance": "cosine" + } + } + """, + }, { VectorConfig.class, SelfProvidedVectorizer.of(none -> none