diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java index 2986063649..9c59b1fc95 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSort.java @@ -3,7 +3,9 @@ import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.node.NullNode; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.util.recordable.Recordable; import java.util.*; @@ -11,23 +13,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.eclipse.microprofile.openapi.annotations.media.SchemaProperty; -/** - * The sort clause of a findAndRerank command, see {@link FindAndRerankSortClauseDeserializer} for - * the forms it can take. - * - * @param rerankLegMode {@link RerankLegMode#MULTI_LEG} when the sort clause uses $hybrid, otherwise - * {@link RerankLegMode#SINGLE_LEG}. - * @param hybridSort If null then the $hybrid field was not text, OR was blank text, - * otherwise it is the trimmed text. - * @param vectorizeSort If null then the $vectorize field was not present in the sort - * clause, OR was present but set to JSON null OR it was blank string, otherwise it is the value - * provided. - * @param lexicalSort If null then the $lexical field was not present in the sort - * clause, OR was present but set to JSON null OR it was blank string, otherwise it is the value - * provided. - * @param vectorSort If null then the vector field was not present in the sort clause, - * or present and JSON null, otherwise it is the value provided. - */ +/** The sort clause of a findAndRerank command. */ @JsonDeserialize(using = FindAndRerankSortClauseDeserializer.class) @Schema( name = "FindAndRerankSort", @@ -56,14 +42,106 @@ {"$hybrid": {"$vector": {"$binary": "P4AAAEAAAABAQAAA"}, "$lexical": "lexical query"}} """ }) -public record FindAndRerankSort( - @JsonIgnore RerankLegMode rerankLegMode, - @JsonIgnore String hybridSort, - @JsonIgnore String vectorizeSort, - @JsonIgnore String lexicalSort, - @JsonIgnore float[] vectorSort, - @JsonIgnore CommandFeatures commandFeatures) - implements Recordable { +public sealed interface FindAndRerankSort extends Recordable + permits FindAndRerankSort.MultiLeg, FindAndRerankSort.SingleLeg { + + @JsonIgnore + CommandFeatures commandFeatures(); + + sealed interface MultiLeg extends FindAndRerankSort permits HybridText, HybridObject {} + + record HybridText(@JsonIgnore String text, @JsonIgnore CommandFeatures commandFeatures) + implements MultiLeg { + public HybridText { + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + } + } + + record HybridObject( + @JsonIgnore String vectorize, + @JsonIgnore String lexical, + @JsonIgnore float[] vector, + @JsonIgnore CommandFeatures commandFeatures) + implements MultiLeg { + public HybridObject { + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + vector = vector == null ? null : vector.clone(); + } + + @Override + public float[] vector() { + return vector == null ? null : vector.clone(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof HybridObject other + && Objects.equals(vectorize, other.vectorize) + && Objects.equals(lexical, other.lexical) + && Arrays.equals(vector, other.vector) + && Objects.equals(commandFeatures, other.commandFeatures); + } + + @Override + public int hashCode() { + return Objects.hash(vectorize, lexical, Arrays.hashCode(vector), commandFeatures); + } + } + + sealed interface SingleLeg extends FindAndRerankSort + permits UnresolvedSingleLeg, LexicalLeg, VectorLeg, FilterLeg {} + + /** Values remain raw until the planner validates them with find's sort builder. */ + record UnresolvedSingleLeg(@JsonIgnore JsonNode sort, @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public UnresolvedSingleLeg { + Objects.requireNonNull(sort, "sort must not be null"); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + } + } + + record LexicalLeg( + @JsonIgnore List sortExpressions, + @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public LexicalLeg { + sortExpressions = List.copyOf(sortExpressions); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + if (sortExpressions.size() != 1 + || !(sortExpressions.getFirst() instanceof SortExpression2.LexicalSe)) { + throw new IllegalArgumentException("LexicalLeg requires one lexical expression"); + } + } + } + + record VectorLeg( + @JsonIgnore List sortExpressions, + @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public VectorLeg { + sortExpressions = List.copyOf(sortExpressions); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + if (sortExpressions.size() != 1 + || !(sortExpressions.getFirst() instanceof SortExpression2.VectorSe + || sortExpressions.getFirst() instanceof SortExpression2.VectorizeSe)) { + throw new IllegalArgumentException("VectorLeg requires one vector or vectorize expression"); + } + } + } + + record FilterLeg( + @JsonIgnore List sortExpressions, + @JsonIgnore CommandFeatures commandFeatures) + implements SingleLeg { + public FilterLeg { + sortExpressions = List.copyOf(sortExpressions); + Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); + if (sortExpressions.stream() + .anyMatch(expression -> !(expression instanceof SortExpression2.FieldSe))) { + throw new IllegalArgumentException("FilterLeg accepts only field expressions"); + } + } + } // Schema-only classes. @SchemaProperty can point at a nested schema only through a class, so // these empty classes carry the @Schema for each nested part of the sort. Nothing creates them. @@ -110,52 +188,23 @@ public static class BinaryVectorSchema {} requiredProperties = {VECTOR_EMBEDDING_FIELD, VECTOR_EMBEDDING_TEXT_FIELD}) public static class ConflictingVectorSortSchema {} - public static final FindAndRerankSort NO_ARG_SORT = - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, null, null, null, null, CommandFeatures.EMPTY); - - public FindAndRerankSort { - Objects.requireNonNull(rerankLegMode, "rerankLegMode must not be null"); - Objects.requireNonNull(commandFeatures, "commandFeatures must not be null"); - } - - @Override - public DataRecorder recordTo(DataRecorder dataRecorder) { - return dataRecorder - .append("rerankLegMode", rerankLegMode) - .append("hybridSort", hybridSort) - .append("vectorizeSort", vectorizeSort) - .append("lexicalSort", lexicalSort) - .append("vectorSort", Arrays.toString(vectorSort)) - .append("commandFeatures", commandFeatures); - } - - /** - * Override to do a value equality check on the vector - * - * @param obj the reference object with which to compare. - * @return - */ - @Override - public boolean equals(Object obj) { - return (obj instanceof FindAndRerankSort other) - && rerankLegMode == other.rerankLegMode - && Objects.equals(hybridSort, other.hybridSort) - && Objects.equals(vectorizeSort, other.vectorizeSort) - && Objects.equals(lexicalSort, other.lexicalSort) - && Arrays.equals(vectorSort, other.vectorSort) - && Objects.equals(commandFeatures, other.commandFeatures); - } + FindAndRerankSort NO_ARG_SORT = new UnresolvedSingleLeg(NullNode.instance, CommandFeatures.EMPTY); - /** Override to do a value equality hash on the vector */ @Override - public int hashCode() { - return Objects.hash( - rerankLegMode, - hybridSort, - vectorizeSort, - lexicalSort, - Arrays.hashCode(vectorSort), - commandFeatures); + default DataRecorder recordTo(DataRecorder dataRecorder) { + dataRecorder.append("sortType", getClass().getSimpleName()); + switch (this) { + case HybridText sort -> dataRecorder.append("text", sort.text()); + case HybridObject sort -> + dataRecorder + .append("vectorize", sort.vectorize()) + .append("lexical", sort.lexical()) + .append("vector", Arrays.toString(sort.vector())); + case UnresolvedSingleLeg sort -> dataRecorder.append("sort", sort.sort()); + case LexicalLeg sort -> dataRecorder.append("sortExpressions", sort.sortExpressions()); + case VectorLeg sort -> dataRecorder.append("sortExpressions", sort.sortExpressions()); + case FilterLeg sort -> dataRecorder.append("sortExpressions", sort.sortExpressions()); + } + return dataRecorder.append("commandFeatures", commandFeatures()); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java index a3294cf9a0..5f669e38ee 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java @@ -1,6 +1,9 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; -import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.HYBRID_FIELD; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.LEXICAL_CONTENT_FIELD; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_TEXT_FIELD; import static io.stargate.sgv2.jsonapi.util.JsonUtil.arrayNodeToVector; import com.fasterxml.jackson.core.JsonParser; @@ -13,6 +16,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.EJSONWrapper; +import io.stargate.sgv2.jsonapi.exception.SortException; import io.stargate.sgv2.jsonapi.metrics.CommandFeature; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.util.JsonFieldMatcher; @@ -22,32 +26,41 @@ /** * {@link StdDeserializer} for the {@link FindAndRerankSort}. * - *

Note: This only checks the shape of the sort clause, the other validation is handled when - * resolving the command to an operation. + *

Checks the shape and value types of $hybrid. Other sorts retain their JSON values for the + * planner to validate with find's sort builder. */ public class FindAndRerankSortClauseDeserializer extends StdDeserializer { private static final String ERROR_CONTEXT = "`sort` clause"; - // the only field we accept at top level is $hybrid - // can be string or object + // $hybrid is the only top-level field for a multi-leg sort // { "sort" : { "$hybrid" : "i like cheese" } } // { "sort" : { "$hybrid" : { "$vectorize" : "i like cheese", "$lexical" : "cows"} } } // { "sort" : { "$hybrid" : { "$vector" : [1, 2, 3], "$lexical" : "cows"} } } - private static final JsonFieldMatcher MATCH_HYBRID_FIELD = - new JsonFieldMatcher<>(JsonNode.class, List.of(HYBRID_FIELD), List.of()); + private static final JsonFieldMatcher MATCH_TOP_LEVEL_SORT_FIELDS = + new JsonFieldMatcher<>( + JsonNode.class, + List.of(), + List.of( + SortField.HYBRID.sortField(), + SortField.VECTORIZE.sortField(), + SortField.VECTOR.sortField(), + SortField.LEXICAL.sortField())); - // user is specifying the sorts individually + // user is specifying the sorts individually inside the $hybrid object // can be // { "sort" : { "$hybrid" : { "$vectorize" : "i like cheese", "$lexical" : "cows"} } } // { "sort" : { "$hybrid" : { "$vector" : [1, 2, 3], "$lexical" : "cows"} } } // they could also set something to null to skip that read, that is handled by the resolver // later. - private static final JsonFieldMatcher MATCH_SORT_FIELDS = + private static final JsonFieldMatcher MATCH_HYBRID_OBJECT_FIELDS = new JsonFieldMatcher<>( JsonNode.class, List.of(), - List.of(LEXICAL_CONTENT_FIELD, VECTOR_EMBEDDING_TEXT_FIELD, VECTOR_EMBEDDING_FIELD)); + List.of( + SortField.LEXICAL.sortField(), + SortField.VECTORIZE.sortField(), + SortField.VECTOR.sortField())); public FindAndRerankSortClauseDeserializer() { super(FindAndRerankSort.class); @@ -69,31 +82,37 @@ public FindAndRerankSort deserialize( } /** - * Deserialize the sort clause from an object node. + * Deserialize the sort clause, retaining non-hybrid values until the collection is available. * * @param sort The sort clause as an object node, no check for nulls. - * @return The {@link FindAndRerankSort} that reflects the request without validation, e.g. - * checking that a vectorize or vector sort is provided but not both. */ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode sort) throws JsonMappingException { - // { "sort" : { } } - if (sort.isEmpty()) { - return FindAndRerankSort.NO_ARG_SORT; + if (!sort.has(HYBRID_FIELD)) { + var commandFeatures = CommandFeatures.create(); + for (var sortField : SortField.values()) { + if (sort.has(sortField.sortField())) { + commandFeatures.addFeature(sortField.commandFeature()); + } + } + return new FindAndRerankSort.UnresolvedSingleLeg(sort, commandFeatures); } - var hybridMatch = MATCH_HYBRID_FIELD.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); + var sortMatch = MATCH_TOP_LEVEL_SORT_FIELDS.matchAndThrow(sort, jsonParser, ERROR_CONTEXT); + if (sortMatch.matched().size() != 1) { + throw SortException.Code.SORT_CLAUSE_INVALID.get( + Map.of( + "problem", + "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical, but got: " + + String.join(", ", sortMatch.matched().keySet()))); + } - return switch (hybridMatch.matched().get(HYBRID_FIELD)) { + return switch (sort.get(HYBRID_FIELD)) { case TextNode textNode -> - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, + new FindAndRerankSort.HybridText( normalizedText(textNode.asText()), - null, - null, - null, - CommandFeatures.of(CommandFeature.HYBRID)); + CommandFeatures.of(SortField.HYBRID.commandFeature())); case ObjectNode objectNode -> deserializeHybridObject(jsonParser, objectNode); case JsonNode node -> throw JsonFieldMatcher.errorForWrongType( @@ -104,117 +123,154 @@ private static FindAndRerankSort deserialize(JsonParser jsonParser, ObjectNode s private static FindAndRerankSort deserializeHybridObject( JsonParser jsonParser, ObjectNode hybridObject) throws JsonMappingException { - var sortMatch = MATCH_SORT_FIELDS.matchAndThrow(hybridObject, jsonParser, ERROR_CONTEXT); - CommandFeatures commandFeatures = CommandFeatures.of(CommandFeature.HYBRID); + var sortMatch = + MATCH_HYBRID_OBJECT_FIELDS.matchAndThrow(hybridObject, jsonParser, ERROR_CONTEXT); + // NOTE: commandFeatures are only for metrics, the FindAndRerankPlanner decides what to run + // from the sort values + CommandFeatures commandFeatures = CommandFeatures.of(SortField.HYBRID.commandFeature()); var vectorizeText = - switch (sortMatch.matched().get(VECTOR_EMBEDDING_TEXT_FIELD)) { - case null -> { - // undefined in the object, up to the resolver to check if both vectorize and vector are - // not set - // { "sort" : { "$hybrid" : { }} - yield null; - } - case NullNode ignored -> { - // explict setting to null is allowed - // { "sort" : { "$hybrid" : { "$vectorize" : null, - commandFeatures.addFeature(CommandFeature.VECTORIZE); - yield null; - } - case TextNode textNode -> { - // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese", - commandFeatures.addFeature(CommandFeature.VECTORIZE); - yield normalizedText(textNode.asText()); - } - case JsonNode node -> - throw JsonFieldMatcher.errorForWrongType( - jsonParser, - ERROR_CONTEXT, - VECTOR_EMBEDDING_TEXT_FIELD, - node, - NullNode.class, - TextNode.class); - }; - + textSortValue( + jsonParser, + SortField.VECTORIZE, + sortMatch.matched().get(SortField.VECTORIZE.sortField()), + commandFeatures); var lexicalText = - switch (sortMatch.matched().get(LEXICAL_CONTENT_FIELD)) { - case null -> { - // undefined in the object, up to the resolver to check if valid - // { "sort" : { "$hybrid" : { }} - yield null; - } - case NullNode ignored -> { - // explict setting to null is allowed - // { "sort" : { "$hybrid" : { "$lexical" : null, - commandFeatures.addFeature(CommandFeature.LEXICAL); - yield null; - } - case TextNode textNode -> { - // { "sort" : { "$hybrid" : { "$lexical" : "cheese", - commandFeatures.addFeature(CommandFeature.LEXICAL); - yield normalizedText(textNode.asText()); - } - case JsonNode node -> - throw JsonFieldMatcher.errorForWrongType( - jsonParser, - ERROR_CONTEXT, - LEXICAL_CONTENT_FIELD, - node, - NullNode.class, - TextNode.class); - }; - + textSortValue( + jsonParser, + SortField.LEXICAL, + sortMatch.matched().get(SortField.LEXICAL.sortField()), + commandFeatures); var vector = - switch (sortMatch.matched().get(VECTOR_EMBEDDING_FIELD)) { - case null -> { - // undefined in the object, up to the resolver to check if valid - // { "sort" : { "$hybrid" : { }} - yield null; - } - case NullNode ignored -> { - // explict setting to null is allowed - // { "sort" : { "$hybrid" : { "$vector" : null, - commandFeatures.addFeature(CommandFeature.VECTOR); - yield null; - } - case ArrayNode arrayNode -> { - // { "sort" : { "$hybrid" : { "vector" : [1,2,3], - commandFeatures.addFeature(CommandFeature.VECTOR); - yield arrayNodeToVector(arrayNode); - } - case ObjectNode objectNode -> { - // binary vector - // { "sort" : { "$hybrid" : { "$vector" : {"$binary": "c3VyZS4="}, - var ejson = EJSONWrapper.maybeFrom(objectNode); - if (ejson == null || ejson.type() != EJSONWrapper.EJSONType.BINARY) { - // not the best error, wont tell people how to do a binary vector - // and it will say it got an object node and that is not supported - // TODO: better error message - throw JsonFieldMatcher.errorForWrongType( - jsonParser, - ERROR_CONTEXT + " (binary vector)", - VECTOR_EMBEDDING_FIELD, - objectNode, - NullNode.class, - ArrayNode.class, - ObjectNode.class); - } - commandFeatures.addFeature(CommandFeature.VECTOR); - yield ejson.getVectorValueForBinary(); - } - case JsonNode node -> - throw JsonFieldMatcher.errorForWrongType( - jsonParser, - ERROR_CONTEXT, - VECTOR_EMBEDDING_FIELD, - node, - NullNode.class, - ArrayNode.class, - ObjectNode.class); - }; - - return new FindAndRerankSort( - RerankLegMode.MULTI_LEG, null, vectorizeText, lexicalText, vector, commandFeatures); + vectorSortValue( + jsonParser, sortMatch.matched().get(SortField.VECTOR.sortField()), commandFeatures); + + return new FindAndRerankSort.HybridObject(vectorizeText, lexicalText, vector, commandFeatures); + } + + /** + * A text sort value from inside the {@code $hybrid} object. The field and its metrics feature + * both come from {@code sortField}, so they cannot mismatch. + * + * @param sortField {@link SortField#VECTORIZE} or {@link SortField#LEXICAL}. + * @param node The field value, or {@code null} when the field is missing. + * @param commandFeatures The mode's feature is added when the field was present, even if null. + * @return The trimmed text, or {@code null} when the field is missing, JSON null or blank. This + * skips the leg inside {@code $hybrid}. + */ + private static String textSortValue( + JsonParser jsonParser, SortField sortField, JsonNode node, CommandFeatures commandFeatures) + throws JsonMappingException { + return switch (node) { + case null -> { + // undefined in the object, up to the resolver to check if valid + // { "sort" : { "$hybrid" : { }} + yield null; + } + case NullNode ignored -> { + // explicit setting to null is allowed + // { "sort" : { "$hybrid" : { "$vectorize" : null } } } + commandFeatures.addFeature(sortField.commandFeature()); + yield null; + } + case TextNode textNode -> { + // { "sort" : { "$hybrid" : { "$vectorize" : "I like cheese" } } } + commandFeatures.addFeature(sortField.commandFeature()); + yield normalizedText(textNode.asText()); + } + case JsonNode wrongType -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, + ERROR_CONTEXT, + sortField.sortField(), + wrongType, + NullNode.class, + TextNode.class); + }; + } + + /** + * A {@code $vector} sort value from inside the {@code $hybrid} object. + * + * @param node The field value, or {@code null} when the field is missing. + * @param commandFeatures The vector feature is added when the field was present, even if null. + * @return The vector as written, or {@code null} when the field is missing or JSON null. An empty + * array fails in {@code arrayNodeToVector} with SHRED_BAD_VECTOR_SIZE. + */ + private static float[] vectorSortValue( + JsonParser jsonParser, JsonNode node, CommandFeatures commandFeatures) + throws JsonMappingException { + return switch (node) { + case null -> { + // undefined in the object, up to the resolver to check if valid + // { "sort" : { "$hybrid" : { }} + yield null; + } + case NullNode ignored -> { + // explicit setting to null is allowed + // { "sort" : { "$hybrid" : { "$vector" : null } } } + commandFeatures.addFeature(SortField.VECTOR.commandFeature()); + yield null; + } + case ArrayNode arrayNode -> { + // { "sort" : { "$hybrid" : { "$vector" : [1,2,3] } } } + commandFeatures.addFeature(SortField.VECTOR.commandFeature()); + yield arrayNodeToVector(arrayNode); + } + case ObjectNode objectNode -> { + // binary vector + // { "sort" : { "$hybrid" : { "$vector" : {"$binary": "c3VyZS4="} } } } + var ejson = EJSONWrapper.maybeFrom(objectNode); + if (ejson == null || ejson.type() != EJSONWrapper.EJSONType.BINARY) { + // not the best error, wont tell people how to do a binary vector + // and it will say it got an object node and that is not supported + // TODO: better error message + throw JsonFieldMatcher.errorForWrongType( + jsonParser, + ERROR_CONTEXT + " (binary vector)", + SortField.VECTOR.sortField(), + objectNode, + NullNode.class, + ArrayNode.class, + ObjectNode.class); + } + commandFeatures.addFeature(SortField.VECTOR.commandFeature()); + yield ejson.getVectorValueForBinary(); + } + case JsonNode wrongType -> + throw JsonFieldMatcher.errorForWrongType( + jsonParser, + ERROR_CONTEXT, + SortField.VECTOR.sortField(), + wrongType, + NullNode.class, + ArrayNode.class, + ObjectNode.class); + }; + } + + /** JSON sort field names and their metrics features. */ + private enum SortField { + HYBRID(HYBRID_FIELD, CommandFeature.HYBRID), + VECTORIZE(VECTOR_EMBEDDING_TEXT_FIELD, CommandFeature.VECTORIZE), + VECTOR(VECTOR_EMBEDDING_FIELD, CommandFeature.VECTOR), + LEXICAL(LEXICAL_CONTENT_FIELD, CommandFeature.LEXICAL); + + private final String sortField; + private final CommandFeature commandFeature; + + SortField(String sortField, CommandFeature commandFeature) { + this.sortField = sortField; + this.commandFeature = commandFeature; + } + + String sortField() { + return sortField; + } + + CommandFeature commandFeature() { + return commandFeature; + } } /** diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java deleted file mode 100644 index fc2d0d7918..0000000000 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/RerankLegMode.java +++ /dev/null @@ -1,13 +0,0 @@ -package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; - -/** - * If a findAndRerank command reads with one leg or with more than one, decided by its sort clause: - * {@link #MULTI_LEG} when the sort clause uses $hybrid, otherwise {@link #SINGLE_LEG}. - * - *

A MULTI_LEG command may still read with only one leg, e.g. {"$hybrid": "cheese"} - * on a collection without a lexical index. - */ -public enum RerankLegMode { - SINGLE_LEG, - MULTI_LEG -} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java new file mode 100644 index 0000000000..c51df34046 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/SortExpression2.java @@ -0,0 +1,74 @@ +package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; + +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.LEXICAL_CONTENT_FIELD; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_TEXT_FIELD; + +import java.util.Arrays; +import java.util.Objects; + +/** A validated expression in a single-leg findAndRerank sort. */ +public sealed interface SortExpression2 + permits SortExpression2.VectorizeSe, + SortExpression2.VectorSe, + SortExpression2.LexicalSe, + SortExpression2.FieldSe { + String path(); + + record VectorizeSe(String vectorizeText) implements SortExpression2 { + public VectorizeSe { + Objects.requireNonNull(vectorizeText, "vectorizeText must not be null"); + if (vectorizeText.isBlank()) { + throw new IllegalArgumentException("vectorizeText must not be blank"); + } + } + + @Override + public String path() { + return VECTOR_EMBEDDING_TEXT_FIELD; + } + } + + record VectorSe(float[] vector) implements SortExpression2 { + public VectorSe { + vector = Objects.requireNonNull(vector, "vector must not be null").clone(); + } + + @Override + public float[] vector() { + return vector.clone(); + } + + @Override + public String path() { + return VECTOR_EMBEDDING_FIELD; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof VectorSe other && Arrays.equals(vector, other.vector); + } + + @Override + public int hashCode() { + return Arrays.hashCode(vector); + } + } + + record LexicalSe(String lexicalText) implements SortExpression2 { + public LexicalSe { + Objects.requireNonNull(lexicalText, "lexicalText must not be null"); + } + + @Override + public String path() { + return LEXICAL_CONTENT_FIELD; + } + } + + record FieldSe(String path, boolean ascending) implements SortExpression2 { + public FieldSe { + Objects.requireNonNull(path, "path must not be null"); + } + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java index 2d02a5acbc..b3040c9295 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlanner.java @@ -5,7 +5,11 @@ import static io.stargate.sgv2.jsonapi.util.ApiOptionUtils.getOrDefault; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; +import io.stargate.sgv2.jsonapi.api.model.command.builders.CollectionSortClauseBuilder; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; +import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*; +import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2; +import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2.*; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; import io.stargate.sgv2.jsonapi.config.IntConfigWithBounds; import io.stargate.sgv2.jsonapi.config.OperationsConfig; @@ -45,8 +49,11 @@ static FindAndRerankPlan plan( Objects.requireNonNull(command, "command must not be null"); Objects.requireNonNull(commandContext, "commandContext must not be null"); - var sort = command.sortClause(); var schemaObject = commandContext.schemaObject(); + var sort = + command.sortClause() instanceof UnresolvedSingleLeg unresolved + ? resolveSingleLeg(unresolved, schemaObject) + : command.sortClause(); var operationsConfig = commandContext.config().get(OperationsConfig.class); checkSortSupported(sort, schemaObject); @@ -56,7 +63,7 @@ static FindAndRerankPlan plan( RerankingQuery.create( getOrDefault(command.options(), FindAndRerankCommand.Options::rerankQuery, null), vectorizeText(sort)); - var passageLocator = passageLocator(command); + var passageLocator = passageLocator(command, sort); var projector = command.buildProjector(); // NOTE: the legs are planned after the options and the projection are checked, to keep the @@ -66,11 +73,15 @@ static FindAndRerankPlan plan( // NOTE: a sort with no $vector or $vectorize fails here with a SORT error, not later with an // UNEXPECTED_SERVER_ERROR, see GH #2577 var legs = - switch (sort.rerankLegMode()) { - // TODO: GH #2602, GH #2603 - plan single leg sorts, until then only a command with no - // sort is SINGLE_LEG - case SINGLE_LEG -> throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get(); - case MULTI_LEG -> multiLegs(command, schemaObject); + switch (sort) { + case MultiLeg multiLeg -> multiLegs(command, multiLeg, schemaObject); + // TODO: GH #2602, GH #2603 - execute the validated single-leg sorts. + case LexicalLeg ignored -> + throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get(); + case VectorLeg ignored -> throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get(); + case FilterLeg ignored -> throw SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT.get(); + case UnresolvedSingleLeg ignored -> + throw new IllegalStateException("sort was not resolved"); }; var vectorizeStep = isVectorizeSort(sort) ? planVectorizeStep(sort, schemaObject) : null; @@ -89,6 +100,31 @@ static FindAndRerankPlan plan( getOrDefault(command.options(), FindAndRerankCommand.Options::includeSortVector, false)); } + /** Validate single-leg values with find's builder before assigning their typed form. */ + static SingleLeg resolveSingleLeg(UnresolvedSingleLeg sort, CollectionSchemaObject schemaObject) { + var clause = new CollectionSortClauseBuilder(schemaObject).build(sort.sort()); + clause.validate(schemaObject); + var features = sort.commandFeatures(); + var lexical = clause.lexicalSortExpression(); + if (lexical != null) { + return new LexicalLeg(List.of(new LexicalSe(lexical.getLexicalQuery())), features); + } + if (clause.hasVsearchClause()) { + return new VectorLeg( + List.of(new VectorSe(clause.sortExpressions().getFirst().getVector())), features); + } + if (clause.hasVectorizeSearchClause()) { + return new VectorLeg( + List.of(new VectorizeSe(clause.sortExpressions().getFirst().getVectorize())), features); + } + return new FilterLeg( + clause.sortExpressions().stream() + .map( + expression -> new FieldSe(expression.getPath(), expression.isAscending())) + .toList(), + features); + } + /** * Validate user-supplied hybridLimits against the dynamic OperationsConfig bounds. Cannot be done * in the Jackson deserializer because it has no access to OperationsConfig (config-file and @@ -166,9 +202,8 @@ private static void checkSortSupported( * vector for the vector leg. */ private static List multiLegs( - FindAndRerankCommand command, CollectionSchemaObject schemaObject) { + FindAndRerankCommand command, MultiLeg sort, CollectionSchemaObject schemaObject) { - var sort = command.sortClause(); if (!isVectorizeSort(sort) && !isVectorSort(sort)) { throw isLexicalSort(sort) ? SortException.Code.MISSING_VECTOR_OR_VECTORIZE_IN_HYBRID_SORT.get() @@ -189,7 +224,7 @@ private static List multiLegs( legs.add( new FindAndRerankPlan.RerankLeg( RerankLegType.VECTOR_LEG, - isVectorizeSort(sort) ? null : sort.vectorSort(), + isVectorizeSort(sort) ? null : vector(sort), null, hybridLimits.vectorLimit())); @@ -258,14 +293,15 @@ private static CollectionRerankDef.RerankServiceDef resolveRerankServiceDef( return serviceDef; } - private static PathMatchLocator passageLocator(FindAndRerankCommand command) { + private static PathMatchLocator passageLocator( + FindAndRerankCommand command, FindAndRerankSort sort) { var rerankOn = getOrDefault(command.options(), FindAndRerankCommand.Options::rerankOn, null); var isRerankOn = rerankOn != null && !rerankOn.isBlank(); String finalRerankField; - if (isVectorizeSort(command.sortClause())) { + if (isVectorizeSort(sort)) { // use the vectorize field, unless the user has overridden finalRerankField = isRerankOn ? rerankOn : VECTOR_EMBEDDING_TEXT_FIELD; } else if (isRerankOn) { @@ -286,14 +322,36 @@ private static void throwIfNoLexicalIndex(CollectionSchemaObject schemaObject) { /** The text to vectorize: the $hybrid text, or the $vectorize value, or null. */ private static String vectorizeText(FindAndRerankSort sort) { - return sort.hybridSort() != null ? sort.hybridSort() : sort.vectorizeSort(); + return switch (sort) { + case HybridText text -> text.text(); + case HybridObject object -> object.vectorize(); + case VectorLeg leg -> + switch (leg.sortExpressions().getFirst()) { + case VectorizeSe expression -> expression.vectorizeText(); + case VectorSe ignored -> null; + case LexicalSe ignored -> + throw new IllegalStateException("lexical expression in vector leg"); + case FieldSe ignored -> + throw new IllegalStateException("field expression in vector leg"); + }; + case LexicalLeg ignored -> null; + case FilterLeg ignored -> null; + case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved"); + }; } /** * The text for the lexical sort: the $hybrid text, or the $lexical value, or null. */ private static String lexicalText(FindAndRerankSort sort) { - return sort.hybridSort() != null ? sort.hybridSort() : sort.lexicalSort(); + return switch (sort) { + case HybridText text -> text.text(); + case HybridObject object -> object.lexical(); + case LexicalLeg leg -> ((LexicalSe) leg.sortExpressions().getFirst()).lexicalText(); + case VectorLeg ignored -> null; + case FilterLeg ignored -> null; + case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved"); + }; } private static boolean isLexicalSort(FindAndRerankSort sort) { @@ -302,7 +360,14 @@ private static boolean isLexicalSort(FindAndRerankSort sort) { /** The $hybrid object has $lexical text, so the collection must have a lexical index. */ private static boolean isExplicitLexicalSort(FindAndRerankSort sort) { - return sort.lexicalSort() != null; + return switch (sort) { + case HybridObject object -> object.lexical() != null; + case LexicalLeg ignored -> true; + case HybridText ignored -> false; + case VectorLeg ignored -> false; + case FilterLeg ignored -> false; + case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved"); + }; } private static boolean isVectorizeSort(FindAndRerankSort sort) { @@ -310,6 +375,25 @@ private static boolean isVectorizeSort(FindAndRerankSort sort) { } private static boolean isVectorSort(FindAndRerankSort sort) { - return sort.vectorSort() != null; + return vector(sort) != null; + } + + private static float[] vector(FindAndRerankSort sort) { + return switch (sort) { + case HybridObject object -> object.vector(); + case VectorLeg leg -> + switch (leg.sortExpressions().getFirst()) { + case VectorSe expression -> expression.vector(); + case VectorizeSe ignored -> null; + case LexicalSe ignored -> + throw new IllegalStateException("lexical expression in vector leg"); + case FieldSe ignored -> + throw new IllegalStateException("field expression in vector leg"); + }; + case HybridText ignored -> null; + case LexicalLeg ignored -> null; + case FilterLeg ignored -> null; + case UnresolvedSingleLeg ignored -> throw new IllegalStateException("sort was not resolved"); + }; } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java index e3a66280f2..c5110c0b85 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java @@ -1,5 +1,6 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; +import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*; import static io.stargate.sgv2.jsonapi.metrics.CommandFeature.*; import static io.stargate.sgv2.jsonapi.util.Base64Util.encodeAsMimeBase64; import static io.stargate.sgv2.jsonapi.util.CqlVectorUtil.floatsToBytes; @@ -12,6 +13,8 @@ import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.stargate.sgv2.jsonapi.exception.DocumentException; +import io.stargate.sgv2.jsonapi.exception.SortException; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.util.recordable.PrettyPrintable; import java.util.stream.Stream; @@ -20,6 +23,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.slf4j.Logger; @@ -43,84 +47,6 @@ void setUp() { deserializer = new FindAndRerankSortClauseDeserializer(); } - @Test - public void testEqualsAndHash() { - var value1 = - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort", - "lexical sort", - new float[] {1.1f, 2.2f, 3.3f}, - CommandFeatures.EMPTY); - - var diffVectorize = - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort 2", - "lexical sort", - new float[] {1.1f, 2.2f, 3.3f}, - CommandFeatures.EMPTY); - var diffLexical = - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort", - "lexical sort 2", - new float[] {1.1f, 2.2f, 3.3f}, - CommandFeatures.EMPTY); - var diffVector = - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort", - "lexical sort", - new float[] {1.1f, 2.2f, 3.3f, 4.4f}, - CommandFeatures.EMPTY); - - var diffMode = - new FindAndRerankSort( - RerankLegMode.SINGLE_LEG, - null, - "vectorize sort", - "lexical sort", - new float[] {1.1f, 2.2f, 3.3f}, - CommandFeatures.EMPTY); - var diffHybridSort = - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - "hybrid sort", - "vectorize sort", - "lexical sort", - new float[] {1.1f, 2.2f, 3.3f}, - CommandFeatures.EMPTY); - - assertThat(value1).as("Object equals self").isEqualTo(value1); - assertThat(value1).as("different rerankLegMode").isNotEqualTo(diffMode); - assertThat(value1).as("different hybrid sort").isNotEqualTo(diffHybridSort); - assertThat(value1).as("different vectorize sort").isNotEqualTo(diffVectorize); - assertThat(value1).as("different lexical sort").isNotEqualTo(diffLexical); - assertThat(value1).as("different vector").isNotEqualTo(diffVector); - - assertThat(value1.hashCode()).as("hash code equals self").isEqualTo(value1.hashCode()); - assertThat(value1.hashCode()) - .as("hash code different vectorize sort") - .isNotEqualTo(diffVectorize.hashCode()); - assertThat(value1.hashCode()) - .as("hash code different lexical sort") - .isNotEqualTo(diffLexical.hashCode()); - assertThat(value1.hashCode()) - .as("hash code different vector") - .isNotEqualTo(diffVector.hashCode()); - assertThat(value1.hashCode()) - .as("hash code different rerankLegMode") - .isNotEqualTo(diffMode.hashCode()); - assertThat(value1.hashCode()) - .as("hash code different hybrid sort") - .isNotEqualTo(diffHybridSort.hashCode()); - } - @ParameterizedTest @MethodSource("validSortsTestCases") public void validSorts(String jsonString, FindAndRerankSort expected) throws Exception { @@ -147,7 +73,7 @@ private static Stream validSortsTestCases() { """ {} """, - FindAndRerankSort.NO_ARG_SORT), + new UnresolvedSingleLeg(new ObjectMapper().createObjectNode(), CommandFeatures.EMPTY)), Arguments.of( """ null @@ -159,45 +85,29 @@ private static Stream validSortsTestCases() { """ { "$hybrid" : "same for hybrid and lexical" } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - "same for hybrid and lexical", - null, - null, - null, - CommandFeatures.of(HYBRID))), + new HybridText("same for hybrid and lexical", CommandFeatures.of(HYBRID))), Arguments.of( """ { "$hybrid" : " padded text " } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - "padded text", - null, - null, - null, - CommandFeatures.of(HYBRID))), + new HybridText("padded text", CommandFeatures.of(HYBRID))), Arguments.of( """ { "$hybrid" : "" } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))), + new HybridText(null, CommandFeatures.of(HYBRID))), Arguments.of( """ { "$hybrid" : " " } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, null, null, null, null, CommandFeatures.of(HYBRID))), + new HybridText(null, CommandFeatures.of(HYBRID))), // ---- // maximum fields, resolver works out the valid combinations Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : "lexical sort", "$vector" : [1.1, 2.2, 3.3]} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize sort", "lexical sort", new float[] {1.1f, 2.2f, 3.3f}, @@ -206,9 +116,7 @@ private static Stream validSortsTestCases() { """ { "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : "lexical sort"} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize sort", "lexical sort", null, @@ -219,44 +127,26 @@ private static Stream validSortsTestCases() { """ { "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : null} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort", - null, - null, - CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))), + new HybridObject( + "vectorize sort", null, null, CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))), Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : ""} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort", - null, - null, - CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))), + new HybridObject( + "vectorize sort", null, null, CommandFeatures.of(HYBRID, VECTORIZE, LEXICAL))), Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize sort"} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize sort", - null, - null, - CommandFeatures.of(HYBRID, VECTORIZE))), + new HybridObject("vectorize sort", null, null, CommandFeatures.of(HYBRID, VECTORIZE))), // ---- // $vectorize variations Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize sort", "$lexical" : "lexical sort"} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize sort", "lexical sort", null, @@ -265,44 +155,37 @@ private static Stream validSortsTestCases() { """ { "$hybrid" : { "$vectorize" : null, "$lexical" : "lexical sort"} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - null, - "lexical sort", - null, - CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))), + new HybridObject( + null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))), Arguments.of( """ { "$hybrid" : { "$vectorize" : "", "$lexical" : "lexical sort"} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - null, - "lexical sort", - null, - CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))), + new HybridObject( + null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))), + Arguments.of( + """ + { "$hybrid" : { "$vectorize" : " ", "$lexical" : "lexical sort"} } + """, + new HybridObject( + null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE))), Arguments.of( """ { "$hybrid" : {"$lexical" : "lexical sort"} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - null, - "lexical sort", - null, - CommandFeatures.of(HYBRID, LEXICAL))), + new HybridObject(null, "lexical sort", null, CommandFeatures.of(HYBRID, LEXICAL))), // ---- // $vector variations + Arguments.of( + """ + { "$hybrid" : { "$lexical" : "lexical", "$vector" : null} } + """, + new HybridObject(null, "lexical", null, CommandFeatures.of(HYBRID, LEXICAL, VECTOR))), Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : null} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize", "lexical", null, @@ -311,9 +194,7 @@ private static Stream validSortsTestCases() { """ { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : [0.1, 0.2, 0.3]} } """, - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize", "lexical", new float[] {0.1f, 0.2f, 0.3f}, @@ -323,9 +204,7 @@ private static Stream validSortsTestCases() { { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : {"$binary": "%s"}} } """ .formatted(emptyVectorBase64), - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize", "lexical", emptyVector, @@ -335,9 +214,7 @@ private static Stream validSortsTestCases() { { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : {"$binary": "%s"}} } """ .formatted(vectorBase64), - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, + new HybridObject( "vectorize", "lexical", vector, @@ -370,11 +247,6 @@ private static Stream invalidSortsTestCases() { 1 """, "sort clause must be an object"), - Arguments.of( - """ - { "fake" : 1} - """, - "Expected fields: $hybrid. Unexpected fields: fake"), Arguments.of( """ { "$hybrid" : {"fake" : 1} } @@ -387,8 +259,18 @@ private static Stream invalidSortsTestCases() { { "$hybrid" : 1} """, "Field $hybrid may only be of types TextNode, ObjectNode, but got: IntNode"), + Arguments.of( + """ + { "$hybrid" : null} + """, + "Field $hybrid may only be of types TextNode, ObjectNode, but got: NullNode"), // ---- // Combinations + Arguments.of( + """ + { "$hybrid" : "cheese", "age" : -1 } + """, + "Expected fields: $hybrid, $vectorize, $vector, $lexical. Unexpected fields: age"), Arguments.of( """ { "$hybrid" : { "$vectorize" : 1, "$lexical" : "", "$vector" : [1.1, 2.2, 3.3]} } @@ -410,4 +292,82 @@ private static Stream invalidSortsTestCases() { """, "`sort` clause (binary vector) contained fields with the wrong type. Field $vector may only be of types NullNode, ArrayNode, ObjectNode, but got: ObjectNode")); } + + @ParameterizedTest + @ValueSource( + strings = { + "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }", + "{ \"$hybrid\" : \"cheese\", \"$lexical\" : \"cows\" }" + }) + public void invalidSortWithTwoTopLevelFields(String jsonString) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + when(deserializationContext.readTree(jsonParser)).thenReturn(mapper.readTree(jsonString)); + + var error = + assertThrows( + SortException.class, + () -> deserializer.deserialize(jsonParser, deserializationContext)); + assertThat(error.code).isEqualTo(SortException.Code.SORT_CLAUSE_INVALID.name()); + assertThat(error.body) + .contains("sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical"); + } + + @ParameterizedTest + @ValueSource(strings = {"{ \"$hybrid\" : { \"$vector\" : [] } }"}) + public void invalidEmptyVector(String jsonString) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + when(deserializationContext.readTree(jsonParser)).thenReturn(mapper.readTree(jsonString)); + + var error = + assertThrows( + DocumentException.class, + () -> deserializer.deserialize(jsonParser, deserializationContext)); + assertThat(error.code).isEqualTo(DocumentException.Code.SHRED_BAD_VECTOR_SIZE.name()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "{}", + "{\"age\": -1}", + "{\"age\": 1, \"name\": -1}", + "{\"$lexical\": \" cows \"}", + "{\"$lexical\": \" \"}", + "{\"$lexical\": null}", + "{\"$lexical\": 1}", + "{\"$lexical\": \"cows\", \"age\": -1}", + "{\"$vectorize\": \" cheese \"}", + "{\"$vectorize\": null}", + "{\"$vectorize\": 1}", + "{\"$vector\": [1, 2, 3]}", + "{\"$vector\": []}", + "{\"$vector\": null}", + "{\"$vector\": {\"$binary\": \"P4AAAEAAAABAQAAA\"}}", + "{\"$vector\": [1, 2, 3], \"$vectorize\": \"cheese\"}", + "{\"age\": 2}", + "{\"$unknown\": 1}" + }) + void preservesSingleLegSortUntilPlanning(String jsonString) throws Exception { + var rawSort = new ObjectMapper().readTree(jsonString); + when(deserializationContext.readTree(jsonParser)).thenReturn(rawSort); + + var actual = deserializer.deserialize(jsonParser, deserializationContext); + + assertThat(actual).isInstanceOf(UnresolvedSingleLeg.class); + assertThat(((UnresolvedSingleLeg) actual).sort()).isEqualTo(rawSort); + } + + @Test + void distinguishesExplicitNullFromEmptySingleLegSort() throws Exception { + var mapper = new ObjectMapper(); + when(deserializationContext.readTree(jsonParser)) + .thenReturn(mapper.readTree("{\"$lexical\": null}"), mapper.readTree("{}")); + + var explicitNull = deserializer.deserialize(jsonParser, deserializationContext); + var empty = deserializer.deserialize(jsonParser, deserializationContext); + + assertThat(explicitNull).isNotEqualTo(empty); + assertThat(explicitNull.commandFeatures()).isEqualTo(CommandFeatures.of(LEXICAL)); + assertThat(empty.commandFeatures()).isEqualTo(CommandFeatures.EMPTY); + } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java new file mode 100644 index 0000000000..aebb6d3f36 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortTest.java @@ -0,0 +1,106 @@ +package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; + +import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*; +import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class FindAndRerankSortTest { + @Test + void hybridObjectComparesVectorValues() { + var first = new HybridObject("cheese", "cows", new float[] {1, 2, 3}, CommandFeatures.EMPTY); + var equal = new HybridObject("cheese", "cows", new float[] {1, 2, 3}, CommandFeatures.EMPTY); + var different = + new HybridObject("cheese", "cows", new float[] {1, 2, 4}, CommandFeatures.EMPTY); + + assertThat(first).isEqualTo(equal).isNotEqualTo(different); + assertThat(first.hashCode()).isEqualTo(equal.hashCode()); + } + + @Test + void vectorExpressionComparesVectorValues() { + var first = new VectorSe(new float[] {1, 2, 3}); + var equal = new VectorSe(new float[] {1, 2, 3}); + var different = new VectorSe(new float[] {1, 2, 4}); + + assertThat(first).isEqualTo(equal).isNotEqualTo(different); + assertThat(first.hashCode()).isEqualTo(equal.hashCode()); + } + + @Test + void hybridVectorDoesNotChangeWhenArraysAreModified() { + var vector = new float[] {1, 2, 3}; + var sort = new HybridObject(null, "cows", vector, CommandFeatures.EMPTY); + vector[0] = 4; + sort.vector()[1] = 5; + + assertThat(sort.vector()).containsExactly(1, 2, 3); + } + + @Test + void vectorExpressionDoesNotChangeWhenArraysAreModified() { + var vector = new float[] {1, 2, 3}; + var expression = new VectorSe(vector); + vector[0] = 4; + expression.vector()[1] = 5; + + assertThat(expression.vector()).containsExactly(1, 2, 3); + } + + @Test + void lexicalLegRequiresOneLexicalExpression() { + assertThrows( + IllegalArgumentException.class, () -> new LexicalLeg(List.of(), CommandFeatures.EMPTY)); + assertThrows( + IllegalArgumentException.class, + () -> new LexicalLeg(List.of(new FieldSe("age", true)), CommandFeatures.EMPTY)); + assertThrows( + IllegalArgumentException.class, + () -> + new LexicalLeg( + List.of(new LexicalSe("cows"), new LexicalSe("cheese")), CommandFeatures.EMPTY)); + } + + @Test + void vectorLegRequiresOneVectorOrVectorizeExpression() { + assertThrows( + IllegalArgumentException.class, () -> new VectorLeg(List.of(), CommandFeatures.EMPTY)); + assertThrows( + IllegalArgumentException.class, + () -> new VectorLeg(List.of(new LexicalSe("cows")), CommandFeatures.EMPTY)); + assertThrows( + IllegalArgumentException.class, + () -> + new VectorLeg( + List.of(new VectorSe(new float[] {1}), new VectorizeSe("cheese")), + CommandFeatures.EMPTY)); + } + + @Test + void filterLegRejectsSearchExpressions() { + assertThrows( + IllegalArgumentException.class, + () -> + new FilterLeg( + List.of(new FieldSe("age", true), new LexicalSe("cows")), CommandFeatures.EMPTY)); + assertThat(new FilterLeg(List.of(), CommandFeatures.EMPTY).sortExpressions()).isEmpty(); + } + + @Test + void legOwnsItsExpressionList() { + var expressions = new ArrayList(); + expressions.add(new FieldSe("age", true)); + var leg = new FilterLeg(expressions, CommandFeatures.EMPTY); + expressions.add(new FieldSe("name", false)); + + assertThat(leg.sortExpressions()).containsExactly(new FieldSe("age", true)); + assertThrows( + UnsupportedOperationException.class, + () -> leg.sortExpressions().add(new FieldSe("name", false))); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java new file mode 100644 index 0000000000..79d4466ad3 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankRequestTest.java @@ -0,0 +1,46 @@ +package io.stargate.sgv2.jsonapi.api.v1; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsError; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import io.stargate.sgv2.jsonapi.config.constants.HttpConstants; +import io.stargate.sgv2.jsonapi.exception.SortException; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class FindAndRerankRequestTest { + + @ParameterizedTest + @ValueSource( + strings = { + "{ \"$hybrid\" : \"cheese\", \"$lexical\" : \"cows\" }", + "{ \"$hybrid\" : \"cheese\", \"$vectorize\" : \"cheese\" }" + }) + void failOnHybridSortWithOtherTopLevelFields(String sort) { + // The request fails during deserialization before any database access. + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, "test-token") + .contentType(ContentType.JSON) + .body("{\"findAndRerank\": {\"sort\": %s}}".formatted(sort)) + .when() + .post(CollectionResource.BASE_PATH, "keyspace", "collection") + .then() + .statusCode(200) + .body("$", responseIsError()) + .body("errors", hasSize(1)) + .body("errors[0].errorCode", is(SortException.Code.SORT_CLAUSE_INVALID.name())) + .body( + "errors[0].message", + containsString( + "sort clause must have exactly one of $hybrid, $vectorize, $vector, $lexical")); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java index 9d078e36ac..f256c20bd1 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankPlannerTest.java @@ -2,8 +2,12 @@ import static io.stargate.sgv2.jsonapi.TestConstants.rerankingModelConfig; import static io.stargate.sgv2.jsonapi.TestConstants.rerankingProvidersConfig; +import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort.*; +import static io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortExpression2.*; import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.VECTOR_EMBEDDING_TEXT_FIELD; +import static io.stargate.sgv2.jsonapi.metrics.CommandFeature.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -13,6 +17,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.CommandConfig; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.CommandName; +import io.stargate.sgv2.jsonapi.api.model.command.builders.CollectionSortClauseBuilder; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; import io.stargate.sgv2.jsonapi.config.IntConfigWithBounds; import io.stargate.sgv2.jsonapi.config.OperationsConfig; @@ -24,6 +29,8 @@ import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.exception.SortException; import io.stargate.sgv2.jsonapi.exception.UpdateException; +import io.stargate.sgv2.jsonapi.metrics.CommandFeature; +import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.service.operation.reranking.RerankingQuery; import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef; @@ -35,6 +42,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests for {@link FindAndRerankPlanner}: the legs it plans for each kind of sort, and the error @@ -304,6 +313,38 @@ static Stream sortErrorCases() { {"$hybrid": {"$lexical": "cows", "$vector": null}} """, SortException.Code.MISSING_VECTOR_OR_VECTORIZE_IN_HYBRID_SORT), + Arguments.of( + "$vectorize single leg", + "{\"$vectorize\": \"cheese\"}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of( + "$vector single leg", + "{\"$vector\": [0.1, 0.2, 0.3]}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of( + "$lexical single leg", + "{\"$lexical\": \"cows\"}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of( + "$vectorize single leg null", + "{\"$vectorize\": null}", + SortException.Code.SORT_CLAUSE_VALUE_INVALID), + Arguments.of( + "$vector single leg null", + "{\"$vector\": null}", + SortException.Code.SORT_CLAUSE_VALUE_INVALID), + Arguments.of( + "$lexical single leg null", + "{\"$lexical\": null}", + SortException.Code.SORT_CLAUSE_INVALID), + Arguments.of( + "single field sort", + "{\"age\": -1}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), + Arguments.of( + "multiple field sorts", + "{\"age\": 1, \"name\": -1}", + SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), Arguments.of("no sort", null, SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT), Arguments.of( "sort empty", @@ -319,6 +360,90 @@ static Stream sortErrorCases() { SortException.Code.UNSUPPORTED_FIND_AND_RERANK_SORT)); } + @ParameterizedTest + @MethodSource("resolvedSingleLegCases") + void resolvesSingleLegSortWithFindRules(String json, SingleLeg expected) throws Exception { + var sort = (UnresolvedSingleLeg) command(json, RERANK_OPTIONS).sortClause(); + + var actual = FindAndRerankPlanner.resolveSingleLeg(sort, schema(Collection.VECTORIZE_LEXICAL)); + + assertThat(actual).isEqualTo(expected); + } + + static Stream resolvedSingleLegCases() { + return Stream.of( + Arguments.of("{}", new FilterLeg(List.of(), CommandFeatures.EMPTY)), + Arguments.of( + "{\"age\": -1}", + new FilterLeg(List.of(new FieldSe("age", false)), CommandFeatures.EMPTY)), + Arguments.of( + "{\"age\": 1, \"name\": -1}", + new FilterLeg( + List.of(new FieldSe("age", true), new FieldSe("name", false)), + CommandFeatures.EMPTY)), + Arguments.of( + "{\"$lexical\": \" cows \"}", + new LexicalLeg(List.of(new LexicalSe(" cows ")), CommandFeatures.of(LEXICAL))), + Arguments.of( + "{\"$lexical\": \" \"}", + new LexicalLeg(List.of(new LexicalSe(" ")), CommandFeatures.of(LEXICAL))), + Arguments.of( + "{\"$vectorize\": \" cheese \"}", + new VectorLeg(List.of(new VectorizeSe(" cheese ")), CommandFeatures.of(VECTORIZE))), + Arguments.of( + "{\"$vector\": [0.1, 0.2, 0.3]}", + new VectorLeg( + List.of(new VectorSe(VECTOR)), CommandFeatures.of(CommandFeature.VECTOR))), + Arguments.of( + "{\"$vector\": {\"$binary\": \"PczMzT5MzM0+mZma\"}}", + new VectorLeg( + List.of(new VectorSe(VECTOR)), CommandFeatures.of(CommandFeature.VECTOR)))); + } + + @ParameterizedTest + @ValueSource( + strings = { + "{\"$lexical\": null}", + "{\"$lexical\": 1}", + "{\"$lexical\": \"cows\", \"age\": -1}", + "{\"$vectorize\": null}", + "{\"$vectorize\": \" \"}", + "{\"$vectorize\": 1}", + "{\"$vector\": null}", + "{\"$vector\": 1}", + "{\"$vector\": []}", + "{\"$vector\": [1, \"bad\"]}", + "{\"$vector\": {\"$date\": \"2026-01-01\"}}", + "{\"$vector\": [1, 2, 3], \"$vectorize\": \"cheese\"}", + "{\"$vectorize\": \"cheese\", \"$lexical\": \"cows\"}", + "{\"age\": 0}", + "{\"age\": 2}", + "{\"age\": -2}", + "{\"age\": 1.0}", + "{\"age\": true}", + "{\"age\": null}", + "{\"$unknown\": 1}" + }) + void rejectsInvalidSingleLegSortWithFindError(String json) throws Exception { + var schema = schema(Collection.VECTORIZE_LEXICAL); + var rawSort = objectMapper.readTree(json); + var expected = + assertThrows( + APIException.class, () -> new CollectionSortClauseBuilder(schema).build(rawSort)); + + var actual = + assertThrows( + APIException.class, + () -> + FindAndRerankPlanner.plan( + command(json, RERANK_OPTIONS), commandContext(Collection.VECTORIZE_LEXICAL))); + + assertThat(actual.getClass()).isEqualTo(expected.getClass()); + assertThat(actual.code).isEqualTo(expected.code); + assertThat(actual.title).isEqualTo(expected.title); + assertThat(actual.body).isEqualTo(expected.body); + } + // =========================================================================================== // Sorts the collection cannot run // =========================================================================================== @@ -610,6 +735,18 @@ void reportsMissingRerankQueryBeforeHybridSortWithNoValue() throws Exception { RequestException.Code.MISSING_RERANK_QUERY_TEXT); } + @ParameterizedTest + @NullSource + @ValueSource(strings = {"{}", "null"}) + void reportsMissingRerankQueryWhenSortIsEmptyOrMissing(String sort) throws Exception { + assertPlanFails( + "no rerank query and no sort expressions", + command(sort, null), + Collection.VECTOR_LEXICAL, + RequestException.class, + RequestException.Code.MISSING_RERANK_QUERY_TEXT); + } + // =========================================================================================== // Whole plan // ===========================================================================================