Ver código fonte

Support Struct field (#1619)

Signed-off-by: yhmo <yihua.mo@zilliz.com>
groot 2 semanas atrás
pai
commit
d194d7cbbe
24 arquivos alterados com 1024 adições e 209 exclusões
  1. 2 2
      docker-compose.yml
  2. 1 1
      examples/src/main/java/io/milvus/v1/CommonUtils.java
  3. 4 9
      sdk-bulkwriter/src/main/java/io/milvus/bulkwriter/BulkWriter.java
  4. 1 1
      sdk-bulkwriter/src/test/java/io/milvus/bulkwriter/TestUtils.java
  5. 48 52
      sdk-core/src/main/java/io/milvus/param/ParamUtils.java
  6. 140 43
      sdk-core/src/main/java/io/milvus/response/FieldDataWrapper.java
  7. 3 1
      sdk-core/src/main/java/io/milvus/v2/common/DataType.java
  8. 7 1
      sdk-core/src/main/java/io/milvus/v2/common/IndexParam.java
  9. 6 0
      sdk-core/src/main/java/io/milvus/v2/service/collection/CollectionService.java
  10. 18 0
      sdk-core/src/main/java/io/milvus/v2/service/collection/request/AddFieldReq.java
  11. 35 1
      sdk-core/src/main/java/io/milvus/v2/service/collection/request/CreateCollectionReq.java
  12. 2 1
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/InsertReq.java
  13. 19 0
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/data/EmbeddedText.java
  14. 84 0
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/data/EmbeddingList.java
  15. 19 0
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/DecayRanker.java
  16. 19 0
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/ModelRanker.java
  17. 0 1
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/RRFRanker.java
  18. 0 1
      sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/WeightedRanker.java
  19. 25 8
      sdk-core/src/main/java/io/milvus/v2/utils/ConvertUtils.java
  20. 256 75
      sdk-core/src/main/java/io/milvus/v2/utils/DataUtils.java
  21. 131 9
      sdk-core/src/main/java/io/milvus/v2/utils/SchemaUtils.java
  22. 1 1
      sdk-core/src/main/milvus-proto
  23. 2 2
      sdk-core/src/test/java/io/milvus/TestUtils.java
  24. 201 0
      sdk-core/src/test/java/io/milvus/v2/client/MilvusClientV2DockerTest.java

+ 2 - 2
docker-compose.yml

@@ -3,7 +3,7 @@ version: '3.5'
 services:
   standalone:
     container_name: milvus-javasdk-standalone-1
-    image: milvusdb/milvus:v2.6.1
+    image: milvusdb/milvus:master-20250922-200ee4cb-amd64
     command: [ "milvus", "run", "standalone" ]
     environment:
       - COMMON_STORAGETYPE=local
@@ -24,7 +24,7 @@ services:
 
   standaloneslave:
     container_name: milvus-javasdk-standalone-2
-    image: milvusdb/milvus:v2.6.1
+    image: milvusdb/milvus:master-20250922-200ee4cb-amd64
     command: [ "milvus", "run", "standalone" ]
     environment:
       - COMMON_STORAGETYPE=local

+ 1 - 1
examples/src/main/java/io/milvus/v1/CommonUtils.java

@@ -300,7 +300,7 @@ public class CommonUtils {
         Random ran = new Random();
         SortedMap<Long, Float> sparse = new TreeMap<>();
         int dim = ran.nextInt(10) + 10;
-        for (int i = 0; i < dim; ++i) {
+        while (sparse.size() < dim) {
             sparse.put((long)ran.nextInt(1000000), ran.nextFloat());
         }
         return sparse;

+ 4 - 9
sdk-bulkwriter/src/main/java/io/milvus/bulkwriter/BulkWriter.java

@@ -33,11 +33,9 @@ import io.milvus.bulkwriter.writer.JSONFileWriter;
 import io.milvus.bulkwriter.writer.ParquetFileWriter;
 import io.milvus.common.utils.ExceptionUtils;
 import io.milvus.common.utils.Float16Utils;
-import io.milvus.grpc.FieldSchema;
-import io.milvus.param.ParamUtils;
 import io.milvus.v2.common.DataType;
 import io.milvus.v2.service.collection.request.CreateCollectionReq;
-import io.milvus.v2.utils.SchemaUtils;
+import io.milvus.v2.utils.DataUtils;
 import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.slf4j.Logger;
@@ -362,8 +360,7 @@ public abstract class BulkWriter implements AutoCloseable {
     }
 
     private Pair<Object, Integer> verifyVector(JsonElement object, CreateCollectionReq.FieldSchema field) {
-        FieldSchema grpcField = SchemaUtils.convertToGrpcFieldSchema(field);
-        Object vector = ParamUtils.checkFieldValue(ParamUtils.ConvertField(grpcField), object);
+        Object vector = DataUtils.checkFieldValue(field, object);
         io.milvus.v2.common.DataType dataType = field.getDataType();
         switch (dataType) {
             case FloatVector:
@@ -396,8 +393,7 @@ public abstract class BulkWriter implements AutoCloseable {
             return Pair.of(null, 0);
         }
 
-        FieldSchema grpcField = SchemaUtils.convertToGrpcFieldSchema(field);
-        Object varchar = ParamUtils.checkFieldValue(ParamUtils.ConvertField(grpcField), object);
+        Object varchar = DataUtils.checkFieldValue(field, object);
         return Pair.of(varchar, String.valueOf(varchar).length());
     }
 
@@ -411,8 +407,7 @@ public abstract class BulkWriter implements AutoCloseable {
     }
 
     private Pair<Object, Integer> verifyArray(JsonElement object, CreateCollectionReq.FieldSchema field) {
-        FieldSchema grpcField = SchemaUtils.convertToGrpcFieldSchema(field);
-        Object array = ParamUtils.checkFieldValue(ParamUtils.ConvertField(grpcField), object);
+        Object array = DataUtils.checkFieldValue(field, object);
         if (array == null) {
             return Pair.of(null, 0);
         }

+ 1 - 1
sdk-bulkwriter/src/test/java/io/milvus/bulkwriter/TestUtils.java

@@ -91,7 +91,7 @@ public class TestUtils {
     public SortedMap<Long, Float> generateSparseVector() {
         SortedMap<Long, Float> sparse = new TreeMap<>();
         int dim = RANDOM.nextInt(10) + 10;
-        for (int i = 0; i < dim; ++i) {
+        while (sparse.size() < dim) {
             sparse.put((long) RANDOM.nextInt(1000000), RANDOM.nextFloat());
         }
         return sparse;

+ 48 - 52
sdk-core/src/main/java/io/milvus/param/ParamUtils.java

@@ -72,7 +72,7 @@ public class ParamUtils {
         return typeErrMsg;
     }
 
-    private static HashMap<DataType, String> getTypeErrorMsgForRowInsert() {
+    public static HashMap<DataType, String> getTypeErrorMsgForRowInsert() {
         final HashMap<DataType, String> typeErrMsg = new HashMap<>();
         typeErrMsg.put(DataType.None, "Type mismatch for field '%s': the field type is illegal.");
         typeErrMsg.put(DataType.Bool, "Type mismatch for field '%s': Bool field value type must be JsonPrimitive.");
@@ -99,7 +99,7 @@ public class ParamUtils {
         checkFieldData(fieldSchema, values, false);
     }
 
-    private static int calculateBinVectorDim(DataType dataType, int byteCount) {
+    public static int calculateBinVectorDim(DataType dataType, int byteCount) {
         if (dataType == DataType.BinaryVector) {
             return byteCount*8; // for BinaryVector, each byte is 8 dimensions
         } else if (dataType == DataType.Int8Vector) {
@@ -313,8 +313,8 @@ public class ParamUtils {
         }
     }
 
-    public static Object checkFieldValue(FieldType fieldSchema, JsonElement value) {
-        DataType dataType = fieldSchema.getDataType();
+    public static Object checkFieldValue(String fieldName, DataType dataType, DataType elementType, int dim, int maxLength,
+                                         int maxCapacity, boolean isNullable, Object defaultVal, JsonElement value) {
         // nullable and default value check
         // 1. if the field is nullable, user can input JsonNull/JsonObject(for row-based insert)
         //    1) if user input JsonNull, this value is replaced by default value
@@ -323,17 +323,17 @@ public class ParamUtils {
         //    1) if user input JsonNull, and default value is null, throw error
         //    2) if user input JsonNull, and default value is not null, this value is replaced by default value
         //    3) if user input JsonObject, infer this value by type
-        if (fieldSchema.isNullable()) {
+        if (isNullable) {
             if (value instanceof JsonNull) {
-                return fieldSchema.getDefaultValue(); // 1.1
+                return defaultVal; // 1.1
             }
         } else {
             if (value instanceof JsonNull) {
-                if (fieldSchema.getDefaultValue() == null) {
+                if (defaultVal == null) {
                     String msg = "Field '%s' is not nullable but the input value is null";
-                    throw new ParamException(String.format(msg, fieldSchema.getName())); // 2.1
+                    throw new ParamException(String.format(msg, fieldName)); // 2.1
                 } else {
-                    return fieldSchema.getDefaultValue(); // 2.2
+                    return defaultVal; // 2.2
                 }
             }
         }
@@ -344,19 +344,18 @@ public class ParamUtils {
         switch (dataType) {
             case FloatVector: {
                 if (!(value.isJsonArray())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
-                int dim = fieldSchema.getDimension();
                 try {
                     List<Float> vector = JsonUtils.fromJson(value, new TypeToken<List<Float>>() {}.getType());
                     if (vector.size() != dim) {
                         String msg = "Incorrect dimension for field '%s': dimension: %d is not equal to field's dimension: %d";
-                        throw new ParamException(String.format(msg, fieldSchema.getName(), vector.size(), dim));
+                        throw new ParamException(String.format(msg, fieldName, vector.size(), dim));
                     }
                     return vector; // return List<Float> for genFieldData()
                 } catch (JsonSyntaxException e) {
                     throw new ParamException(String.format("Unable to convert JsonArray to List<Float> for field '%s'. Reason: %s",
-                            fieldSchema.getName(), e.getCause().getMessage()));
+                            fieldName, e.getCause().getMessage()));
                 }
             }
             case BinaryVector:
@@ -364,85 +363,84 @@ public class ParamUtils {
             case BFloat16Vector:
             case Int8Vector: {
                 if (!(value.isJsonArray())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
-                int dim = fieldSchema.getDimension();
                 try {
                     byte[] v = JsonUtils.fromJson(value, new TypeToken<byte[]>() {}.getType());
                     int real_dim = calculateBinVectorDim(dataType, v.length);
                     if (real_dim != dim) {
                         String msg = "Incorrect dimension for field '%s': dimension: %d is not equal to field's dimension: %d";
-                        throw new ParamException(String.format(msg, fieldSchema.getName(), real_dim, dim));
+                        throw new ParamException(String.format(msg, fieldName, real_dim, dim));
                     }
                     return ByteBuffer.wrap(v); // return ByteBuffer for genFieldData()
                 } catch (JsonSyntaxException e) {
                     throw new ParamException(String.format("Unable to convert JsonArray to List<Float> for field '%s'. Reason: %s",
-                            fieldSchema.getName(), e.getCause().getMessage()));
+                            fieldName, e.getCause().getMessage()));
                 }
             }
             case SparseFloatVector:
                 if (!(value.isJsonObject())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 try {
                     // return SortedMap<Long, Float> for genFieldData()
                     return JsonUtils.fromJson(value, new TypeToken<SortedMap<Long, Float>>() {}.getType());
                 } catch (JsonSyntaxException e) {
                     throw new ParamException(String.format("Unable to convert JsonObject to SortedMap<Long, Float> for field '%s'. Reason: %s",
-                            fieldSchema.getName(), e.getCause().getMessage()));
+                            fieldName, e.getCause().getMessage()));
                 }
             case Int64:
                 if (!(value.isJsonPrimitive())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return value.getAsLong(); // return long for genFieldData()
             case Int32:
             case Int16:
             case Int8:
                 if (!(value.isJsonPrimitive())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return value.getAsInt(); // return int for genFieldData()
             case Bool:
                 if (!(value.isJsonPrimitive())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return value.getAsBoolean(); // return boolean for genFieldData()
             case Float:
                 if (!(value.isJsonPrimitive())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return value.getAsFloat(); // return float for genFieldData()
             case Double:
                 if (!(value.isJsonPrimitive())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return value.getAsDouble(); // return double for genFieldData()
             case VarChar:
             case String:
                 if (!(value.isJsonPrimitive())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 JsonPrimitive p = value.getAsJsonPrimitive();
                 if (!p.isString()) {
-                    throw new ParamException(String.format("JsonPrimitive should be String type for field '%s'", fieldSchema.getName()));
+                    throw new ParamException(String.format("JsonPrimitive should be String type for field '%s'", fieldName));
                 }
 
                 String str = p.getAsString();
-                if (str.length() > fieldSchema.getMaxLength()) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                if (str.length() > maxLength) {
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return str; // return String for genFieldData()
             case JSON:
                 return value; // return JsonElement for genFieldData()
             case Array:
                 if (!(value.isJsonArray())) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
 
-                List<Object> array = convertJsonArray(value.getAsJsonArray(), fieldSchema.getElementType(), fieldSchema.getName());
-                if (array.size() > fieldSchema.getMaxCapacity()) {
-                    throw new ParamException(String.format(errMsgs.get(dataType), fieldSchema.getName()));
+                List<Object> array = convertJsonArray(value.getAsJsonArray(), elementType, fieldName);
+                if (array.size() > maxCapacity) {
+                    throw new ParamException(String.format(errMsgs.get(dataType), fieldName));
                 }
                 return array; // return List<Object> for genFieldData()
             default:
@@ -603,7 +601,7 @@ public class ParamUtils {
                         checkFieldData(fieldType, field);
 
                         found = true;
-                        this.addFieldsData(genFieldData(fieldType, field.getValues()));
+                        this.addFieldsData(genFieldData(fieldType, field.getValues(), false));
                         break;
                     }
 
@@ -668,7 +666,9 @@ public class ParamUtils {
                         String msg = String.format("The primary key: %s is auto generated, no need to input.", fieldName);
                         throw new ParamException(msg);
                     }
-                    Object fieldValue = checkFieldValue(fieldType, rowFieldData);
+                    Object fieldValue = checkFieldValue(fieldType.getName(), fieldType.getDataType(),
+                            fieldType.getElementType(), fieldType.getDimension(), fieldType.getMaxLength(),
+                            fieldType.getMaxCapacity(), fieldType.isNullable(), fieldType.getDefaultValue(), rowFieldData);
                     insertDataInfo.getData().add(fieldValue);
                     nameInsertInfo.put(fieldName, insertDataInfo);
                 }
@@ -687,10 +687,10 @@ public class ParamUtils {
 
             for (String fieldNameKey : nameInsertInfo.keySet()) {
                 InsertDataInfo insertDataInfo = nameInsertInfo.get(fieldNameKey);
-                this.addFieldsData(genFieldData(insertDataInfo.getFieldType(), insertDataInfo.getData()));
+                this.addFieldsData(genFieldData(insertDataInfo.getFieldType(), insertDataInfo.getData(), false));
             }
             if (wrapper.getEnableDynamicField()) {
-                this.addFieldsData(genFieldData(insertDynamicDataInfo.getFieldType(), insertDynamicDataInfo.getData(), Boolean.TRUE));
+                this.addFieldsData(genFieldData(insertDynamicDataInfo.getFieldType(), insertDynamicDataInfo.getData(), true));
             }
         }
 
@@ -1161,23 +1161,23 @@ public class ParamUtils {
         return isDenseVectorDataType(dataType) || dataType == DataType.SparseFloatVector;
     }
 
-    public static FieldData genFieldData(FieldType fieldType, List<?> objects) {
-        return genFieldData(fieldType, objects, Boolean.FALSE);
+    private static FieldData genFieldData(FieldType fieldType, List<?> objects, boolean isDynamic) {
+        return genFieldData(fieldType.getName(), fieldType.getDataType(), fieldType.getElementType(),
+                fieldType.isNullable(), fieldType.getDefaultValue(), objects, isDynamic);
     }
 
-    public static FieldData genFieldData(FieldType fieldType, List<?> objects, boolean isDynamic) {
+    public static FieldData genFieldData(String fieldName, DataType dataType, DataType elementType, boolean isNullable,
+                                         Object defaultVal, List<?> objects, boolean isDynamic) {
         if (objects == null) {
             throw new ParamException("Cannot generate FieldData from null object");
         }
-        DataType dataType = fieldType.getDataType();
-        String fieldName = fieldType.getName();
-        FieldData.Builder builder = FieldData.newBuilder();
 
+        FieldData.Builder builder = FieldData.newBuilder();
         if (isVectorDataType(dataType)) {
             VectorField vectorField = genVectorField(dataType, objects);
             return builder.setFieldName(fieldName).setType(dataType).setVectors(vectorField).build();
         } else {
-            if (fieldType.isNullable() || fieldType.getDefaultValue() != null) {
+            if (isNullable || defaultVal != null) {
                 List<Object> tempObjects = new ArrayList<>();
                 for (Object obj : objects) {
                     builder.addValidData(obj != null);
@@ -1188,7 +1188,7 @@ public class ParamUtils {
                 objects = tempObjects;
             }
 
-            ScalarField scalarField = genScalarField(fieldType, objects);
+            ScalarField scalarField = genScalarField(dataType, elementType, objects);
             if (isDynamic) {
                 return builder.setType(dataType).setScalars(scalarField).setIsDynamic(true).build();
             }
@@ -1197,7 +1197,7 @@ public class ParamUtils {
     }
 
     @SuppressWarnings("unchecked")
-    private static VectorField genVectorField(DataType dataType, List<?> objects) {
+    public static VectorField genVectorField(DataType dataType, List<?> objects) {
         if (dataType == DataType.FloatVector) {
             List<Float> floats = new ArrayList<>();
             // each object is List<Float>
@@ -1323,22 +1323,18 @@ public class ParamUtils {
         return builder.setDim(dim).build();
     }
 
-    private static ScalarField genScalarField(FieldType fieldType, List<?> objects) {
-        if (fieldType.getDataType() == DataType.Array) {
+    public static ScalarField genScalarField(DataType dataType, DataType elementType, List<?> objects) {
+        if (dataType == DataType.Array) {
             ArrayArray.Builder builder = ArrayArray.newBuilder();
             for (Object object : objects) {
                 List<?> temp = (List<?>)object;
-                ScalarField arrayField = genScalarField(fieldType.getElementType(), temp);
+                ScalarField arrayField = genScalarField(elementType, DataType.None, temp);
                 builder.addData(arrayField);
             }
 
             return ScalarField.newBuilder().setArrayData(builder.build()).build();
-        } else {
-            return genScalarField(fieldType.getDataType(), objects);
         }
-    }
 
-    private static ScalarField genScalarField(DataType dataType, List<?> objects) {
         switch (dataType) {
             case None:
             case UNRECOGNIZED:

+ 140 - 43
sdk-core/src/main/java/io/milvus/response/FieldDataWrapper.java

@@ -30,9 +30,7 @@ import lombok.NonNull;
 
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.SortedMap;
+import java.util.*;
 import java.util.stream.Collectors;
 
 import com.google.protobuf.ByteString;
@@ -72,7 +70,11 @@ public class FieldDataWrapper {
         if (!isVectorField()) {
             throw new IllegalResponseException("Not a vector field");
         }
-        return (int) fieldData.getVectors().getDim();
+        return getDimInternal(fieldData.getVectors());
+    }
+
+    private int getDimInternal(VectorField vector) {
+        return (int) vector.getDim();
     }
 
     // this method returns bytes size of each vector according to vector type
@@ -106,16 +108,16 @@ public class FieldDataWrapper {
         return 0;
     }
 
-    private ByteString getVectorBytes(FieldData fieldData, DataType dt) {
+    private ByteString getVectorBytes(VectorField vd, DataType dt) {
         ByteString data;
         if (dt == DataType.BinaryVector) {
-            data = fieldData.getVectors().getBinaryVector();
+            data = vd.getBinaryVector();
         } else if (dt == DataType.Float16Vector) {
-            data = fieldData.getVectors().getFloat16Vector();
+            data = vd.getFloat16Vector();
         } else if (dt == DataType.BFloat16Vector) {
-            data = fieldData.getVectors().getBfloat16Vector();
+            data = vd.getBfloat16Vector();
         } else if (dt == DataType.Int8Vector) {
-            data = fieldData.getVectors().getInt8Vector();
+            data = vd.getInt8Vector();
         } else {
             String msg = String.format("Unsupported data type %s returned by FieldData", dt.name());
             throw new IllegalResponseException(msg);
@@ -148,7 +150,7 @@ public class FieldDataWrapper {
             case BFloat16Vector:
             case Int8Vector: {
                 int dim = getDim();
-                ByteString data = getVectorBytes(fieldData, dt);
+                ByteString data = getVectorBytes(fieldData.getVectors(), dt);
                 int bytePerVec = checkDim(dt, data, dim);
 
                 return data.size()/bytePerVec;
@@ -176,6 +178,20 @@ public class FieldDataWrapper {
                 return fieldData.getScalars().getJsonData().getDataCount();
             case Array:
                 return fieldData.getScalars().getArrayData().getDataCount();
+            case ArrayOfStruct: {
+                List<FieldData> structData = fieldData.getStructArrays().getFieldsList();
+                for (FieldData fd : structData) {
+                    if (fd.getType() == DataType.Array) {
+                        return fd.getScalars().getArrayData().getDataCount();
+                    } else if (fd.getType() == DataType.ArrayOfVector) {
+                        FieldDataWrapper tempWrapper = new FieldDataWrapper(fd);
+                        return tempWrapper.getRowCount();
+                    }
+                }
+            }
+            case ArrayOfVector: {
+                return fieldData.getVectors().getVectorArray().getDataCount();
+            }
             default:
                 throw new IllegalResponseException("Unsupported data type returned by FieldData");
         }
@@ -194,6 +210,7 @@ public class FieldDataWrapper {
      *      Varchar field returns List of String
      *      Array field returns List of List
      *      JSON field returns List of String;
+     *      Struct field returns List of List<Map<String, Object>>
      *      etc.
      *
      * Throws {@link IllegalResponseException} if the field type is illegal.
@@ -211,10 +228,51 @@ public class FieldDataWrapper {
 
     private List<?> getFieldDataInternal() throws IllegalResponseException {
         DataType dt = fieldData.getType();
+        switch (dt) {
+            case FloatVector:
+            case BinaryVector:
+            case Float16Vector:
+            case BFloat16Vector:
+            case Int8Vector:
+            case SparseFloatVector:
+                return getVectorData(dt, fieldData.getVectors());
+            case Array:
+            case Int64:
+            case Int32:
+            case Int16:
+            case Int8:
+            case Bool:
+            case Float:
+            case Double:
+            case VarChar:
+            case String:
+            case JSON:
+                return getScalarData(dt, fieldData.getScalars(), fieldData.getValidDataList());
+            case ArrayOfStruct:
+                return getStructData(fieldData.getStructArrays(), fieldData.getFieldName());
+            default:
+                throw new IllegalResponseException("Unsupported data type returned by FieldData");
+        }
+    }
+
+    private List<?> setNoneData(List<?> data, List<Boolean> validData) {
+        if (validData != null && validData.size() == data.size()) {
+            List<?> newData = new ArrayList<>(data); // copy the list since the data is come from grpc is not mutable
+            for (int i = 0; i < validData.size(); i++) {
+                if (validData.get(i) == Boolean.FALSE) {
+                    newData.set(i, null);
+                }
+            }
+            return newData;
+        }
+        return data;
+    }
+
+    private List<?> getVectorData(DataType dt, VectorField vector) {
         switch (dt) {
             case FloatVector: {
-                int dim = getDim();
-                List<Float> data = fieldData.getVectors().getFloatVector().getDataList();
+                int dim = getDimInternal(vector);
+                List<Float> data = vector.getFloatVector().getDataList();
                 if (data.size() % dim != 0) {
                     String msg = String.format("Returned float vector data array size %d doesn't match dimension %d",
                             data.size(), dim);
@@ -232,10 +290,10 @@ public class FieldDataWrapper {
             case Float16Vector:
             case BFloat16Vector:
             case Int8Vector: {
-                int dim = getDim();
-                ByteString data = getVectorBytes(fieldData, dt);
+                int dim = getDimInternal(vector);
+                ByteString data = getVectorBytes(vector, dt);
                 int bytePerVec = checkDim(dt, data, dim);
-                int count = data.size()/bytePerVec;
+                int count = data.size() / bytePerVec;
                 List<ByteBuffer> packData = new ArrayList<>();
                 for (int i = 0; i < count; ++i) {
                     ByteBuffer bf = ByteBuffer.allocate(bytePerVec);
@@ -252,7 +310,7 @@ public class FieldDataWrapper {
                 // in Java sdk, each sparse vector is pairs of long+float
                 // in server side, each sparse vector is stored as uint+float (8 bytes)
                 // don't use sparseArray.getDim() because the dim is the max index of each rows
-                SparseFloatArray sparseArray = fieldData.getVectors().getSparseFloatVector();
+                SparseFloatArray sparseArray = vector.getSparseFloatVector();
                 List<SortedMap<Long, Float>> packData = new ArrayList<>();
                 for (int i = 0; i < sparseArray.getContentsCount(); ++i) {
                     ByteString bs = sparseArray.getContents(i);
@@ -262,34 +320,9 @@ public class FieldDataWrapper {
                 }
                 return packData;
             }
-            case Array:
-            case Int64:
-            case Int32:
-            case Int16:
-            case Int8:
-            case Bool:
-            case Float:
-            case Double:
-            case VarChar:
-            case String:
-            case JSON:
-                return getScalarData(dt, fieldData.getScalars(), fieldData.getValidDataList());
             default:
-                throw new IllegalResponseException("Unsupported data type returned by FieldData");
-        }
-    }
-
-    private List<?> setNoneData(List<?> data, List<Boolean> validData) {
-        if (validData != null && validData.size() == data.size()) {
-            List<?> newData = new ArrayList<>(data); // copy the list since the data is come from grpc is not mutable
-            for (int i = 0; i < validData.size(); i++) {
-                if (validData.get(i) == Boolean.FALSE) {
-                    newData.set(i, null);
-                }
-            }
-            return newData;
+                return new ArrayList<>();
         }
-        return data;
     }
 
     private List<?> getScalarData(DataType dt, ScalarField scalar, List<Boolean> validData) {
@@ -315,7 +348,7 @@ public class FieldDataWrapper {
                 return dataList.stream().map(ByteString::toStringUtf8).collect(Collectors.toList());
             case Array:
                 List<List<?>> array = new ArrayList<>();
-                ArrayArray arrArray = fieldData.getScalars().getArrayData();
+                ArrayArray arrArray = scalar.getArrayData();
                 boolean nullable = validData != null && validData.size() == arrArray.getDataCount();
                 for (int i = 0; i < arrArray.getDataCount(); i++) {
                     if (nullable && validData.get(i) == Boolean.FALSE) {
@@ -331,6 +364,70 @@ public class FieldDataWrapper {
         }
     }
 
+    private List<?> getStructData(StructArrayField field, String fieldName) {
+        List<List<Map<String, Object>>> packData = new ArrayList<>();
+        if (field.getFieldsCount() == 0) {
+            return packData;
+        }
+
+        // read column data from FieldData
+        // for a struct with two sub-fields "int" and "emb", search with nq=2, topk=3
+        // the column data is like this:
+        //  {
+        //     "int": [[x1, x2], [x1, x2, x3], [x1], [x1, x2], [x1, x2, x3], [x1]],
+        //     "emb": [[emb1, emb2], [emb1, emb2, emb3], [emb1], [emb1m emb2], [emb1, emb2, emb3], [emb1]],
+        //  }
+        Map<String, List<List<?>>> columnsData = new HashMap<>();
+        int rowCount = 0;
+        for (FieldData fd : field.getFieldsList()) {
+            List<List<?>> column = new ArrayList<>();
+            if (fd.getType() == DataType.Array) {
+                column = (List<List<?>>) getScalarData(fd.getType(), fd.getScalars(), fd.getValidDataList());
+                columnsData.put(fd.getFieldName(), column);
+                rowCount = column.size();
+            } else if (fd.getType() == DataType.ArrayOfVector) {
+                VectorArray vecArr = fd.getVectors().getVectorArray();
+                for (VectorField vf : vecArr.getDataList()) {
+                    List<?> vector = getVectorData(vecArr.getElementType(), vf);
+                    column.add(vector);
+                }
+                rowCount = column.size();
+                columnsData.put(fd.getFieldName(), column);
+            } else {
+                throw new IllegalResponseException("Unsupported data type returned by StructArrayField");
+            }
+        }
+
+        // convert column data into struct list, eventually, the packData is like this:
+        //   [
+        //      [{x1, emb1}, {x2, emb2}],
+        //      [{x1, emb1}, {x2, emb2}, {x3, emb3}],
+        //      [{x1, emb1}],
+        //      [{x1, emb1}, {x2, emb2}],
+        //      [{x1, emb1}, {x2, emb2}, {x3, emb3}],
+        //      [{x1, emb1}]
+        //   ]
+        for (int i = 0; i < rowCount; i++) {
+            int elementCount = 0;
+            Map<String, List<?>> rowColumn = new HashMap<>();
+            for (String key : columnsData.keySet()) {
+                List<?> val = columnsData.get(key).get(i);
+                rowColumn.put(key, val);
+                elementCount = val.size();
+            }
+
+            List<Map<String, Object>> structs = new ArrayList<>();
+            for (int k = 0; k < elementCount; k++) {
+                Map<String, Object> struct = new HashMap<>();
+                int finalK = k;
+                rowColumn.forEach((key, val)->struct.put(key, val.get(finalK)));
+                structs.add(struct);
+            }
+            packData.add(structs);
+        }
+        return packData;
+    }
+
     public Integer getAsInt(int index, String paramName) throws IllegalResponseException {
         if (isJsonField()) {
             String result = getAsString(index, paramName);

+ 3 - 1
sdk-core/src/main/java/io/milvus/v2/common/DataType.java

@@ -45,7 +45,9 @@ public enum DataType {
     Float16Vector(102),
     BFloat16Vector(103),
     SparseFloatVector(104),
-    Int8Vector(105);
+    Int8Vector(105),
+
+    Struct(201);
 
     private final int code;
     DataType(int code) {

+ 7 - 1
sdk-core/src/main/java/io/milvus/v2/common/IndexParam.java

@@ -52,6 +52,9 @@ public class IndexParam {
 
         // Only for sparse vector with BM25
         BM25,
+
+        // Only for struct vector
+        MAX_SIM,
         ;
     }
 
@@ -94,7 +97,10 @@ public class IndexParam {
         SPARSE_INVERTED_INDEX(300),
         // From Milvus 2.5.4 onward, SPARSE_WAND is being deprecated. Instead, it is recommended to
         // use "inverted_index_algo": "DAAT_WAND" for equivalency while maintaining compatibility.
-        SPARSE_WAND(301)
+        SPARSE_WAND(301),
+
+        // Only for struct vector
+        EMB_LIST_HNSW(401),
         ;
 
         private final String name;

+ 6 - 0
sdk-core/src/main/java/io/milvus/v2/service/collection/CollectionService.java

@@ -133,6 +133,7 @@ public class CollectionService extends BaseService {
             grpcSchemaBuilder.addFunctions(SchemaUtils.convertToGrpcFunction(function)).build();
             outputFields.addAll(function.getOutputFieldNames());
         }
+        // normal fields
         for (CreateCollectionReq.FieldSchema fieldSchema : request.getCollectionSchema().getFieldSchemaList()) {
             FieldSchema grpcFieldSchema = SchemaUtils.convertToGrpcFieldSchema(fieldSchema);
             if (outputFields.contains(fieldSchema.getName())) {
@@ -140,6 +141,11 @@ public class CollectionService extends BaseService {
             }
             grpcSchemaBuilder.addFields(grpcFieldSchema);
         }
+        // struct fields
+        for (CreateCollectionReq.StructFieldSchema fieldSchema : request.getCollectionSchema().getStructFields()) {
+            StructArrayFieldSchema grpcFieldSchema = SchemaUtils.convertToGrpcStructFieldSchema(fieldSchema);
+            grpcSchemaBuilder.addStructArrayFields(grpcFieldSchema);
+        }
 
         //create collection
         CreateCollectionRequest.Builder builder = CreateCollectionRequest.newBuilder()

+ 18 - 0
sdk-core/src/main/java/io/milvus/v2/service/collection/request/AddFieldReq.java

@@ -19,11 +19,17 @@
 
 package io.milvus.v2.service.collection.request;
 
+import io.milvus.param.ParamUtils;
 import io.milvus.v2.common.DataType;
+import io.milvus.v2.exception.ErrorCode;
+import io.milvus.v2.exception.MilvusClientException;
+import io.milvus.v2.utils.SchemaUtils;
 import lombok.Builder;
 import lombok.Data;
 import lombok.experimental.SuperBuilder;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Map;
 
 @Data
@@ -59,6 +65,8 @@ public class AddFieldReq {
     // If a specific field, such as maxLength, has been specified, it will override the corresponding key's value in typeParams.
     private Map<String, String> typeParams;
     private Map<String, Object> multiAnalyzerParams; // for multi‑language analyzers
+    @Builder.Default
+    private List<CreateCollectionReq.FieldSchema> structFields = new ArrayList<>(); // only available when dataType is Array and elementType is Struct
 
     public static abstract class AddFieldReqBuilder<C extends AddFieldReq, B extends AddFieldReq.AddFieldReqBuilder<C, B>> {
         public B defaultValue(Object value) {
@@ -68,5 +76,15 @@ public class AddFieldReq {
             this.enableDefaultValue = true; // automatically set this flag
             return self();
         }
+
+        public B addStructField(AddFieldReq addFieldReq) {
+            if (this.structFields$value == null) {
+                this.structFields$value = new ArrayList<>();
+            }
+            CreateCollectionReq.FieldSchema field = SchemaUtils.convertFieldReqToFieldSchema(addFieldReq);
+            this.structFields$value.add(field);
+            this.structFields$set = true;
+            return self();
+        }
     }
 }

+ 35 - 1
sdk-core/src/main/java/io/milvus/v2/service/collection/request/CreateCollectionReq.java

@@ -20,6 +20,7 @@
 package io.milvus.v2.service.collection.request;
 
 import io.milvus.common.clientenum.FunctionType;
+import io.milvus.exception.ParamException;
 import io.milvus.v2.common.ConsistencyLevel;
 import io.milvus.v2.common.DataType;
 import io.milvus.v2.common.IndexParam;
@@ -134,12 +135,18 @@ public class CreateCollectionReq {
         @Builder.Default
         private List<CreateCollectionReq.FieldSchema> fieldSchemaList = new ArrayList<>();
         @Builder.Default
+        private List<CreateCollectionReq.StructFieldSchema> structFields = new ArrayList<>();
+        @Builder.Default
         private boolean enableDynamicField = false;
         @Builder.Default
         private List<CreateCollectionReq.Function> functionList = new ArrayList<>();
 
         public CollectionSchema addField(AddFieldReq addFieldReq) {
-            fieldSchemaList.add(SchemaUtils.convertFieldReqToFieldSchema(addFieldReq));
+            if (addFieldReq.getDataType() == DataType.Array && addFieldReq.getElementType() == DataType.Struct) {
+                structFields.add(SchemaUtils.convertFieldReqToStructFieldSchema(addFieldReq));
+            } else {
+                fieldSchemaList.add(SchemaUtils.convertFieldReqToFieldSchema(addFieldReq));
+            }
             return this;
         }
 
@@ -218,4 +225,31 @@ public class CreateCollectionReq {
             }
         }
     }
+
+    @Data
+    @SuperBuilder
+    public static class StructFieldSchema {
+        private String name;
+        @Builder.Default
+        private String description = "";
+        @Builder.Default
+        private List<CreateCollectionReq.FieldSchema> fields = new ArrayList<>();
+        private Integer maxCapacity;
+
+        public StructFieldSchema addField(AddFieldReq addFieldReq) {
+            if (addFieldReq.getDataType() == DataType.Array || addFieldReq.getElementType() == DataType.Struct) {
+                throw new ParamException("Struct field schema does not support Array, ArrayOfVector or Struct");
+            }
+            fields.add(SchemaUtils.convertFieldReqToFieldSchema(addFieldReq));
+            return this;
+        }
+
+        public DataType getDataType() {
+            return DataType.Array;
+        }
+
+        public DataType getElementType() {
+            return DataType.Struct;
+        }
+    }
 }

+ 2 - 1
sdk-core/src/main/java/io/milvus/v2/service/vector/request/InsertReq.java

@@ -37,9 +37,10 @@ public class InsertReq {
      * Internal class for insert data.
      * If dataType is Bool/Int8/Int16/Int32/Int64/Float/Double/Varchar, use JsonObject.addProperty(key, value) to input;
      * If dataType is FloatVector, use JsonObject.add(key, gson.toJsonTree(List[Float]) to input;
-     * If dataType is BinaryVector/Float16Vector/BFloat16Vector, use JsonObject.add(key, gson.toJsonTree(byte[])) to input;
+     * If dataType is BinaryVector/Float16Vector/BFloat16Vector/Int8Vector, use JsonObject.add(key, gson.toJsonTree(byte[])) to input;
      * If dataType is SparseFloatVector, use JsonObject.add(key, gson.toJsonTree(SortedMap[Long, Float])) to input;
      * If dataType is Array, use JsonObject.add(key, gson.toJsonTree(List of Boolean/Integer/Short/Long/Float/Double/String)) to input;
+     * If dataType is Array and elementType is Struct, use JsonObject.add(key, JsonArray) to input, ensure the JsonArray is a list of JsonObject;
      * If dataType is JSON, use JsonObject.add(key, JsonElement) to input;
      *
      * Note:

+ 19 - 0
sdk-core/src/main/java/io/milvus/v2/service/vector/request/data/EmbeddedText.java

@@ -1,3 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
 package io.milvus.v2.service.vector.request.data;
 
 import io.milvus.grpc.PlaceholderType;

+ 84 - 0
sdk-core/src/main/java/io/milvus/v2/service/vector/request/data/EmbeddingList.java

@@ -0,0 +1,84 @@
+package io.milvus.v2.service.vector.request.data;
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+
+import io.milvus.grpc.PlaceholderType;
+import io.milvus.v2.exception.ErrorCode;
+import io.milvus.v2.exception.MilvusClientException;
+
+import java.util.ArrayList;
+import java.util.List;
+
+// EmbeddingList is mainly for searching vectors in struct field
+public class EmbeddingList implements BaseVector {
+    private List<BaseVector> data = new ArrayList<>();
+
+    public void add(BaseVector vector) {
+        if (!data.isEmpty() && data.get(0).getPlaceholderType() != vector.getPlaceholderType()) {
+            throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Not allow to add different types of vector");
+        }
+        data.add(vector);
+    }
+
+    @Override
+    public PlaceholderType getPlaceholderType() {
+        if (data.isEmpty()) {
+            return PlaceholderType.None;
+        }
+        PlaceholderType pt = data.get(0).getPlaceholderType();
+        switch (pt) {
+            case FloatVector:
+                return PlaceholderType.EmbListFloatVector;
+            case BinaryVector:
+                return PlaceholderType.EmbListBinaryVector;
+            case Float16Vector:
+                return PlaceholderType.EmbListFloat16Vector;
+            case BFloat16Vector:
+                return PlaceholderType.EmbListBFloat16Vector;
+            case SparseFloatVector:
+                return PlaceholderType.EmbListSparseFloatVector;
+            case Int8Vector:
+                return PlaceholderType.EmbListInt8Vector;
+            default:
+                throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Unsupported vector type: " + pt.name());
+        }
+    }
+
+    @Override
+    public Object getData() {
+        if (data.isEmpty()) {
+            throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "EmbeddingList is empty");
+        }
+
+        // return the vectors as flatten
+        PlaceholderType pt = data.get(0).getPlaceholderType();
+        switch (pt) {
+            case FloatVector:
+                List<Object> floats = new ArrayList<>();
+                for (BaseVector vec : data) {
+                    floats.addAll((List<Object>)vec.getData());
+                }
+                return floats;
+            default:
+                // so far,
+                throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Unsupported vector type: " + pt.name());
+        }
+    }
+}

+ 19 - 0
sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/DecayRanker.java

@@ -1,3 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
 package io.milvus.v2.service.vector.request.ranker;
 
 import io.milvus.common.clientenum.FunctionType;

+ 19 - 0
sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/ModelRanker.java

@@ -1,3 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
 package io.milvus.v2.service.vector.request.ranker;
 
 import com.google.gson.JsonArray;

+ 0 - 1
sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/RRFRanker.java

@@ -25,7 +25,6 @@ import io.milvus.v2.service.collection.request.CreateCollectionReq;
 import lombok.Builder;
 import lombok.experimental.SuperBuilder;
 
-import java.util.HashMap;
 import java.util.Map;
 
 /**

+ 0 - 1
sdk-core/src/main/java/io/milvus/v2/service/vector/request/ranker/WeightedRanker.java

@@ -27,7 +27,6 @@ import lombok.Builder;
 import lombok.experimental.SuperBuilder;
 
 import java.util.ArrayList;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 

+ 25 - 8
sdk-core/src/main/java/io/milvus/v2/utils/ConvertUtils.java

@@ -21,20 +21,15 @@ package io.milvus.v2.utils;
 
 import com.google.gson.reflect.TypeToken;
 import io.milvus.common.utils.JsonUtils;
-import io.milvus.grpc.BatchDescribeCollectionResponse;
-import io.milvus.grpc.DescribeCollectionResponse;
-import io.milvus.grpc.FieldData;
-import io.milvus.grpc.FieldSchema;
-import io.milvus.grpc.IndexDescription;
-import io.milvus.grpc.KeyValuePair;
-import io.milvus.grpc.QueryResults;
-import io.milvus.grpc.SearchResults;
+import io.milvus.grpc.*;
 import io.milvus.param.Constant;
 import io.milvus.param.ParamUtils;
 import io.milvus.response.QueryResultsWrapper;
 import io.milvus.response.SearchResultsWrapper;
 import io.milvus.v2.common.IndexBuildState;
 import io.milvus.v2.common.IndexParam;
+import io.milvus.v2.exception.ErrorCode;
+import io.milvus.v2.exception.MilvusClientException;
 import io.milvus.v2.service.collection.response.DescribeCollectionResp;
 import io.milvus.v2.service.index.response.DescribeIndexResp;
 import io.milvus.v2.service.vector.response.QueryResp;
@@ -47,6 +42,28 @@ import java.util.Map;
 import java.util.stream.Collectors;
 
 public class ConvertUtils {
+    public static DataType toProtoDataType(io.milvus.v2.common.DataType dt) {
+        if (dt == null) {
+            return DataType.None;
+        }
+        try {
+            return DataType.valueOf(dt.name());
+        } catch (Exception e) {
+            throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Failed to convert data type, error: " + e.getMessage());
+        }
+    }
+
+    public static io.milvus.v2.common.DataType toSdkDataType(DataType dt) {
+        if (dt == null) {
+            return io.milvus.v2.common.DataType.None;
+        }
+        try {
+            return io.milvus.v2.common.DataType.valueOf(dt.name());
+        } catch (Exception e) {
+            throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Failed to convert data type, error: " + e.getMessage());
+        }
+    }
+
     public List<QueryResp.QueryResult> getEntities(QueryResults response) {
         List<QueryResp.QueryResult> entities = new ArrayList<>();
         // count(*) ?

+ 256 - 75
sdk-core/src/main/java/io/milvus/v2/utils/DataUtils.java

@@ -30,9 +30,6 @@ import io.milvus.v2.service.collection.response.DescribeCollectionResp;
 import io.milvus.v2.service.vector.request.DeleteReq;
 import io.milvus.v2.service.vector.request.InsertReq;
 import io.milvus.v2.service.vector.request.UpsertReq;
-import lombok.Builder;
-import lombok.Getter;
-import lombok.NonNull;
 import org.apache.commons.lang3.StringUtils;
 
 import java.util.*;
@@ -43,8 +40,7 @@ public class DataUtils {
         private InsertRequest.Builder insertBuilder;
         private UpsertRequest.Builder upsertBuilder;
 
-        public InsertRequest convertGrpcInsertRequest(@NonNull InsertReq requestParam,
-                                                      DescribeCollectionResp descColl) {
+        public InsertRequest convertGrpcInsertRequest(InsertReq requestParam, DescribeCollectionResp descColl) {
             String dbName = requestParam.getDatabaseName();
             String collectionName = requestParam.getCollectionName();
 
@@ -62,8 +58,7 @@ public class DataUtils {
             return insertBuilder.build();
         }
 
-        public UpsertRequest convertGrpcUpsertRequest(@NonNull UpsertReq requestParam,
-                                                      DescribeCollectionResp descColl) {
+        public UpsertRequest convertGrpcUpsertRequest(UpsertReq requestParam, DescribeCollectionResp descColl) {
             String dbName = requestParam.getDatabaseName();
             String collectionName = requestParam.getCollectionName();
 
@@ -82,7 +77,7 @@ public class DataUtils {
             return upsertBuilder.build();
         }
 
-        private void addFieldsData(io.milvus.grpc.FieldData value) {
+        private void addFieldsData(FieldData value) {
             if (insertBuilder != null) {
                 insertBuilder.addFieldsData(value);
             } else if (upsertBuilder != null) {
@@ -151,93 +146,260 @@ public class DataUtils {
                 outputFieldNames.addAll(function.getOutputFieldNames());
             }
 
-            List<CreateCollectionReq.FieldSchema> fieldsList = collectionSchema.getFieldSchemaList();
-            Map<String, InsertDataInfo> nameInsertInfo = new HashMap<>();
-            InsertDataInfo insertDynamicDataInfo = InsertDataInfo.builder().field(
-                            CreateCollectionReq.FieldSchema.builder()
-                                    .name(Constant.DYNAMIC_FIELD_NAME)
-                                    .dataType(io.milvus.v2.common.DataType.JSON)
-                                    .build())
-                    .data(new LinkedList<>()).build();
-            for (JsonObject row : rows) {
-                for (CreateCollectionReq.FieldSchema field : fieldsList) {
-                    String fieldName = field.getName();
-                    InsertDataInfo insertDataInfo = nameInsertInfo.getOrDefault(fieldName, InsertDataInfo.builder()
-                            .field(field).data(new LinkedList<>()).build());
-
-                    // check normalField
-                    JsonElement rowFieldData = row.get(fieldName);
-                    if (rowFieldData == null) {
-                        // if the field is auto-id, no need to provide value
-                        if (field.getAutoID() == Boolean.TRUE) {
-                            continue;
-                        }
-
-                        // if the field is an output field of doc-in-doc-out, no need to provide value
-                        if (outputFieldNames.contains(field.getName())) {
-                            continue;
-                        }
+            List<CreateCollectionReq.FieldSchema> normalFields = collectionSchema.getFieldSchemaList();
+            List<CreateCollectionReq.StructFieldSchema> structFields = collectionSchema.getStructFields();
+            List<String> allFieldNames = new ArrayList<>();
+            normalFields.forEach((schema)-> allFieldNames.add(schema.getName()));
+            structFields.forEach((schema)-> allFieldNames.add(schema.getName()));
 
-                        // if the field doesn't have default value, require user provide the value
-                        // in v2.6.1 support partial update, user can input partial fields
-                        if (!field.getIsNullable() && field.getDefaultValue() == null && !partialUpdate) {
-                            String msg = String.format("The field: %s is not provided.", field.getName());
-                            throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
-                        }
-
-                        rowFieldData = JsonNull.INSTANCE;
-                    }
-
-                    // from v2.4.10, milvus allows upsert for auto-id pk, no need to check for upsert action
-                    if (field.getAutoID() == Boolean.TRUE && insertBuilder != null) {
-                        String msg = String.format("The primary key: %s is auto generated, no need to input.", fieldName);
-                        throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
-                    }
+            // 1. for normal fields, InsertDataInfo is a list of object or list of list, for example:
+            //      Int64Field, InsertDataInfo is a List<Long>
+            //      FloatVectorField, InsertDataInfo is a List<List<Float>>
+            // the normalInsertData typically looks like:
+            //      {
+            //          "id": List<Long>,
+            //          "vector": List<List<Float>>
+            //      }
+            //
+            // 2. for struct fields, InsertDataInfo is list of list or 3-layer list, for example:
+            //      A struct field named "struct1" has a sub-field "sub1" type is Varchar and a sub-field "sub2" type is FloatVector
+            //      for the sub-field "sub1", InsertDataInfo is a List<List<String>>
+            //      for the sub-field "sub2", InsertDataInfo is a List<List<List<Float>>>
+            // the structInsertData stores all sub-fields of all struct fields, typically looks like:
+            //      {
+            //          "sub1 of struct1": List<List<String>>,
+            //          "sub2 of struct1": List<List<List<Float>>>,
+            //          "sub3 of struct2": List<List<Integer>>,
+            //          "sub4 of struct2": List<List<List<Float>>>
+            //       }
+            Map<String, InsertDataInfo> normalInsertData = new HashMap<>();
+            Map<String, InsertDataInfo> structInsertData = new HashMap<>();
+            InsertDataInfo insertDynamicDataInfo = new InsertDataInfo(
+                    CreateCollectionReq.FieldSchema.builder()
+                            .name(Constant.DYNAMIC_FIELD_NAME)
+                            .dataType(io.milvus.v2.common.DataType.JSON)
+                            .build(),
+                    new LinkedList<>());
+            for (JsonObject row : rows) {
+                // check and store value of normal fields into InsertDataInfo
+                for (CreateCollectionReq.FieldSchema field : normalFields) {
+                    processNormalFieldValues(row, field, outputFieldNames, normalInsertData, partialUpdate);
+                }
 
-                    // here we convert the v2 FieldSchema to grpc.FieldSchema then to v1 FieldType
-                    // the reason is the logic in ParamUtils.checkFieldValue is complicated, we don't intend to
-                    // write duplicate code here
-                    FieldSchema grpcField = SchemaUtils.convertToGrpcFieldSchema(field);
-                    Object fieldValue = ParamUtils.checkFieldValue(ParamUtils.ConvertField(grpcField), rowFieldData);
-                    insertDataInfo.getData().add(fieldValue);
-                    nameInsertInfo.put(fieldName, insertDataInfo);
+                // check and store value of struct fields into InsertDataInfo
+                for (CreateCollectionReq.StructFieldSchema structField : structFields) {
+                    processStructFieldValues(row, structField, structInsertData);
                 }
 
-                // deal with dynamicField
+                // store dynamic fields into InsertDataInfo
                 if (collectionSchema.isEnableDynamicField()) {
                     JsonObject dynamicField = new JsonObject();
                     for (String rowFieldName : row.keySet()) {
-                        if (!nameInsertInfo.containsKey(rowFieldName)) {
+                        if (!allFieldNames.contains(rowFieldName)) {
                             dynamicField.add(rowFieldName, row.get(rowFieldName));
                         }
                     }
-                    insertDynamicDataInfo.getData().add(dynamicField);
+                    insertDynamicDataInfo.data.add(dynamicField);
                 }
             }
 
-            for (String fieldNameKey : nameInsertInfo.keySet()) {
-                InsertDataInfo insertDataInfo = nameInsertInfo.get(fieldNameKey);
-                // here we convert the v2 FieldSchema to grpc.FieldSchema then to v1 FieldType
-                // the reason is the logic in ParamUtils.genFieldData is complicated, we don't intend to
-                // write duplicate code here
-                FieldSchema grpcField = SchemaUtils.convertToGrpcFieldSchema(insertDataInfo.getField());
-                this.addFieldsData(ParamUtils.genFieldData(ParamUtils.ConvertField(grpcField), insertDataInfo.getData()));
+            // convert normal fields data from InsertDataInfo into grpc FieldData
+            for (String fieldNameKey : normalInsertData.keySet()) {
+                InsertDataInfo insertDataInfo = normalInsertData.get(fieldNameKey);
+                this.addFieldsData(DataUtils.genFieldData(insertDataInfo.field, insertDataInfo.data, false));
             }
+
+            // convert struct fields data from InsertDataInfo into grpc FieldData
+            for (CreateCollectionReq.StructFieldSchema structField : structFields) {
+                StructArrayField.Builder structBuilder = StructArrayField.newBuilder();
+                for (CreateCollectionReq.FieldSchema field : structField.getFields()) {
+                    InsertDataInfo insertDataInfo = structInsertData.get(field.getName());
+                    FieldData grpcField = DataUtils.genStructSubFieldData(field, insertDataInfo.data);
+                    structBuilder.addFields(grpcField);
+                }
+
+                FieldData.Builder fieldDataBuilder = FieldData.newBuilder();
+                this.addFieldsData(fieldDataBuilder
+                        .setFieldName(structField.getName())
+                        .setType(DataType.ArrayOfStruct)
+                        .setStructArrays(structBuilder.build())
+                        .build());
+            }
+
+            // convert dynamic field data from InsertDataInfo into grpc FieldData
             if (collectionSchema.isEnableDynamicField()) {
-                // here we convert the v2 FieldSchema to grpc.FieldSchema then to v1 FieldType
-                // the reason is the logic in ParamUtils.genFieldData is complicated, we don't intend to
-                // write duplicate code here
-                FieldSchema grpcField = SchemaUtils.convertToGrpcFieldSchema(insertDynamicDataInfo.getField());
-                this.addFieldsData(ParamUtils.genFieldData(ParamUtils.ConvertField(grpcField), insertDynamicDataInfo.getData(), Boolean.TRUE));
+                this.addFieldsData(DataUtils.genFieldData(insertDynamicDataInfo.field, insertDynamicDataInfo.data, true));
+            }
+        }
+
+        private void processNormalFieldValues(JsonObject row, CreateCollectionReq.FieldSchema field,
+                                              List<String> outputFieldNames,
+                                              Map<String, InsertDataInfo> nameInsertInfo, boolean partialUpdate) {
+            String fieldName = field.getName();
+            InsertDataInfo insertDataInfo = nameInsertInfo.getOrDefault(fieldName, new InsertDataInfo(field, new LinkedList<>()));
+
+            JsonElement fieldData = row.get(fieldName);
+            if (fieldData == null) {
+                // if the field is auto-id, no need to provide value
+                if (field.getAutoID() == Boolean.TRUE) {
+                    return;
+                }
+
+                // if the field is an output field of doc-in-doc-out, no need to provide value
+                if (outputFieldNames.contains(fieldName)) {
+                    return;
+                }
+
+                // if the field doesn't have default value, require user provide the value
+                // in v2.6.1 support partial update, user can input partial fields
+                if (!field.getIsNullable() && field.getDefaultValue() == null && !partialUpdate) {
+                    String msg = String.format("The field: %s is not provided.", fieldName);
+                    throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+                }
+
+                fieldData = JsonNull.INSTANCE;
+            }
+
+            // from v2.4.10, milvus allows upsert for auto-id pk, no need to check for upsert action
+            if (field.getAutoID() == Boolean.TRUE && insertBuilder != null) {
+                String msg = String.format("The primary key: %s is auto generated, no need to input.", fieldName);
+                throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+            }
+
+            // store the value into InsertDataInfo
+            Object fieldValue = DataUtils.checkFieldValue(field, fieldData);
+            insertDataInfo.data.add(fieldValue);
+            nameInsertInfo.put(fieldName, insertDataInfo);
+        }
+
+        private void processStructFieldValues(JsonObject row, CreateCollectionReq.StructFieldSchema structField,
+                                              Map<String, InsertDataInfo> nameInsertInfo) {
+            String structName = structField.getName();
+            JsonElement rowFieldData = row.get(structName);
+            if (rowFieldData == null) {
+                String msg = String.format("The struct field: %s is not provided.", structName);
+                throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+            }
+            if (!rowFieldData.isJsonArray()) {
+                String msg = String.format("The value of struct field: %s is not a JSON array.", structName);
+                throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+            }
+
+            for (CreateCollectionReq.FieldSchema field : structField.getFields()) {
+                InsertDataInfo insertDataInfo = nameInsertInfo.getOrDefault(field.getName(), new InsertDataInfo(field, new LinkedList<>()));
+                nameInsertInfo.put(field.getName(), insertDataInfo);
+            }
+
+            JsonArray structs = rowFieldData.getAsJsonArray();
+            for (CreateCollectionReq.FieldSchema field : structField.getFields()) {
+                String subFieldName = field.getName();
+                InsertDataInfo insertDataInfo = nameInsertInfo.get(subFieldName);
+                List<Object> columnData = new ArrayList<>();
+                structs.forEach((element)->{
+                    if (!element.isJsonObject()) {
+                        String msg = String.format("The element of struct field: %s is not a JSON dict.", structName);
+                        throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+                    }
+
+                    JsonObject struct = element.getAsJsonObject();
+                    JsonElement fieldData = struct.get(subFieldName);
+                    if (fieldData == null) {
+                        String msg = String.format("The %s of struct field: %s is not provided.", subFieldName, structName);
+                        throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+                    }
+
+                    Object fieldValue = DataUtils.checkFieldValue(field, fieldData);
+                    columnData.add(fieldValue);
+                });
+                insertDataInfo.data.add(columnData);
             }
         }
     }
 
-    @Builder
-    @Getter
     public static class InsertDataInfo {
-        private final CreateCollectionReq.FieldSchema field;
-        private final LinkedList<Object> data;
+        public CreateCollectionReq.FieldSchema field;
+        public LinkedList<Object> data;
+
+        public InsertDataInfo(CreateCollectionReq.FieldSchema field, LinkedList<Object> data) {
+            this.field = field;
+            this.data = data;
+        }
+    }
+
+    private static FieldData genStructSubFieldData(CreateCollectionReq.FieldSchema fieldSchema, List<?> objects) {
+        if (objects == null) {
+            throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Cannot generate FieldData from null object");
+        }
+        DataType dataType = ConvertUtils.toProtoDataType(fieldSchema.getDataType());
+        String fieldName = fieldSchema.getName();
+        FieldData.Builder builder = FieldData.newBuilder().setFieldName(fieldName);
+
+        if (ParamUtils.isVectorDataType(dataType)) {
+            VectorArray vectorArr = genVectorArray(dataType, objects);
+            if (vectorArr.getDim() > 0 && vectorArr.getDim() != fieldSchema.getDimension()) {
+                String msg = String.format("Dimension mismatch for field %s, expected: %d, actual: %d",
+                        fieldName, fieldSchema.getDimension(), vectorArr.getDim());
+                throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+            }
+            return builder.setType(DataType.ArrayOfVector)
+                    .setVectors(VectorField.newBuilder()
+                            .setVectorArray(vectorArr)
+                            .setDim(fieldSchema.getDimension())
+                            .build())
+                    .build();
+        } else {
+            if (fieldSchema.getIsNullable() || fieldSchema.getDefaultValue() != null) {
+                List<Object> tempObjects = new ArrayList<>();
+                for (Object obj : objects) {
+                    builder.addValidData(obj != null);
+                    if (obj != null) {
+                        tempObjects.add(obj);
+                    }
+                }
+                objects = tempObjects;
+            }
+
+            ScalarField scalarField = ParamUtils.genScalarField(DataType.Array, dataType, objects);
+            return builder.setType(DataType.Array).setScalars(scalarField).build();
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    public static VectorArray genVectorArray(DataType dataType, List<?> objects) {
+        VectorArray.Builder builder = VectorArray.newBuilder().setElementType(dataType);
+        if (dataType == DataType.FloatVector) {
+            // each object is List<List<Float>>
+            for (Object object : objects) {
+                if (object instanceof List) {
+                    List<?> listOfList = (List<?>) object;
+                    if (listOfList.isEmpty()) {
+                        // struct field value is empty, fill the VectorArray with zero-dim vectors?
+                        builder.addData(VectorField.newBuilder().build());
+                        continue;
+                    }
+
+                    VectorField vf = ParamUtils.genVectorField(dataType, listOfList);
+                    if (vf.getDim() == 0) {
+                        throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "Vector cannot be empty list");
+                    }
+                    if (builder.getDataCount() == 0) {
+                        builder.setDim(vf.getDim());
+                    } else if (builder.getDim() != vf.getDim()) {
+                        String msg = String.format("Dimension mismatch for vector field, the first dimension: %d, mismatched: %d",
+                                builder.getDim(), vf.getDim());
+                        throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
+                    }
+                    builder.addData(vf);
+                } else {
+                    throw new MilvusClientException(ErrorCode.INVALID_PARAMS, "The type of FloatVector must be List<>");
+                }
+            }
+
+            return builder.build();
+        }
+        // so far, struct field only supports FloatVector
+        String msg = String.format("Illegal vector dataType %s for struct field", dataType.name());
+        throw new MilvusClientException(ErrorCode.INVALID_PARAMS, msg);
     }
 
     public DeleteRequest ConvertToGrpcDeleteRequest(DeleteReq request) {
@@ -257,4 +419,23 @@ public class DataUtils {
         }
         return builder.build();
     }
+
+    private static FieldData genFieldData(CreateCollectionReq.FieldSchema field, List<?> objects, boolean isDynamic) {
+        String fieldName = field.getName();
+        DataType dataType = ConvertUtils.toProtoDataType(field.getDataType());
+        DataType elementType = ConvertUtils.toProtoDataType(field.getElementType());
+        boolean isNullable = field.getIsNullable();
+        Object defaultVal = field.getDefaultValue();
+        return ParamUtils.genFieldData(fieldName, dataType, elementType, isNullable, defaultVal, objects, isDynamic);
+    }
+
+    public static Object checkFieldValue(CreateCollectionReq.FieldSchema field, JsonElement fieldData) {
+        DataType dataType = ConvertUtils.toProtoDataType(field.getDataType());
+        DataType elementType = ConvertUtils.toProtoDataType(field.getElementType());
+        int dim = field.getDimension() == null ? 0 : field.getDimension();
+        int maxLength = field.getMaxLength() == null ? 0 : field.getMaxLength();
+        int maxCapacity = field.getMaxCapacity() == null ? 0 : field.getMaxCapacity();
+        return ParamUtils.checkFieldValue(field.getName(), dataType, elementType, dim,
+                maxLength, maxCapacity, field.getIsNullable(), field.getDefaultValue(), fieldData);
+    }
 }

+ 131 - 9
sdk-core/src/main/java/io/milvus/v2/utils/SchemaUtils.java

@@ -21,6 +21,7 @@ package io.milvus.v2.utils;
 
 import com.google.gson.reflect.TypeToken;
 import io.milvus.common.utils.JsonUtils;
+import io.milvus.exception.ParamException;
 import io.milvus.grpc.CollectionSchema;
 import io.milvus.grpc.DataType;
 import io.milvus.grpc.FieldSchema;
@@ -28,6 +29,7 @@ import io.milvus.grpc.FunctionSchema;
 import io.milvus.grpc.FunctionType;
 import io.milvus.grpc.KeyValuePair;
 import io.milvus.grpc.ValueField;
+import io.milvus.grpc.StructArrayFieldSchema;
 import io.milvus.param.ParamUtils;
 import io.milvus.v2.exception.ErrorCode;
 import io.milvus.v2.exception.MilvusClientException;
@@ -38,12 +40,10 @@ import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 import java.util.stream.Collectors;
 
+import static io.milvus.param.Constant.*;
 import static io.milvus.param.ParamUtils.AssembleKvPair;
 
 public class SchemaUtils {
@@ -55,6 +55,7 @@ public class SchemaUtils {
             throw new MilvusClientException(ErrorCode.INVALID_PARAMS, title + " cannot be null or empty");
         }
     }
+
     public static FieldSchema convertToGrpcFieldSchema(CreateCollectionReq.FieldSchema fieldSchema) {
         checkNullEmptyString(fieldSchema.getName(), "Field name");
 
@@ -147,16 +148,53 @@ public class SchemaUtils {
         return builder.build();
     }
 
+    public static StructArrayFieldSchema convertToGrpcStructFieldSchema(CreateCollectionReq.StructFieldSchema structSchema) {
+        checkNullEmptyString(structSchema.getName(), "Field name");
+        StructArrayFieldSchema.Builder builder = StructArrayFieldSchema.newBuilder()
+                .setName(structSchema.getName())
+                .setDescription(structSchema.getDescription());
+
+        for (CreateCollectionReq.FieldSchema field : structSchema.getFields()) {
+            DataType actualType = DataType.Array;
+            DataType elementType = DataType.valueOf(field.getDataType().name());
+            if (ParamUtils.isVectorDataType(elementType)) {
+                actualType = DataType.ArrayOfVector;
+            }
+            FieldSchema fieldSchema = convertToGrpcFieldSchema(field);
+            // reset data type and capacity
+            fieldSchema = fieldSchema.toBuilder()
+                    .setDataType(actualType)
+                    .setElementType(elementType)
+                    .addTypeParams(KeyValuePair.newBuilder()
+                            .setKey(ARRAY_MAX_CAPACITY)
+                            .setValue(String.valueOf(structSchema.getMaxCapacity()))
+                            .build())
+                    .build();
+            builder.addFields(fieldSchema);
+        }
+        return builder.build();
+    }
+
     public static CreateCollectionReq.CollectionSchema convertFromGrpcCollectionSchema(CollectionSchema schema) {
         CreateCollectionReq.CollectionSchema collectionSchema = CreateCollectionReq.CollectionSchema.builder()
                 .enableDynamicField(schema.getEnableDynamicField())
                 .build();
+
+        // normal fields
         List<CreateCollectionReq.FieldSchema> fieldSchemas = new ArrayList<>();
         for (FieldSchema fieldSchema : schema.getFieldsList()) {
             fieldSchemas.add(convertFromGrpcFieldSchema(fieldSchema));
         }
         collectionSchema.setFieldSchemaList(fieldSchemas);
 
+        // struct fields
+        List<CreateCollectionReq.StructFieldSchema> structSchemas = new ArrayList<>();
+        for (StructArrayFieldSchema fieldSchema : schema.getStructArrayFieldsList()) {
+            structSchemas.add(convertFromGrpcStructFieldSchema(fieldSchema));
+        }
+        collectionSchema.setStructFields(structSchemas);
+
+        // functions
         List<CreateCollectionReq.Function> functions = new ArrayList<>();
         for (FunctionSchema functionSchema : schema.getFunctionsList()) {
             functions.add(convertFromGrpcFunction(functionSchema));
@@ -167,15 +205,27 @@ public class SchemaUtils {
     }
 
     public static CreateCollectionReq.FieldSchema convertFromGrpcFieldSchema(FieldSchema fieldSchema) {
+        // if the fieldSchema belongs to a struct field, its type could be ArrayOfVector/ArrayOfStruct
+        // in fact, its actual type is elementType
+        DataType dataType = fieldSchema.getDataType();
+        DataType elementType = fieldSchema.getElementType();
+        io.milvus.v2.common.DataType actualType;
+        io.milvus.v2.common.DataType actualElementType = io.milvus.v2.common.DataType.None;
+        if (dataType == DataType.ArrayOfVector || dataType == DataType.ArrayOfStruct) {
+            actualType = io.milvus.v2.common.DataType.valueOf(elementType.name());
+        } else {
+            actualType = io.milvus.v2.common.DataType.valueOf(dataType.name());
+            actualElementType = io.milvus.v2.common.DataType.valueOf(elementType.name());
+        }
         CreateCollectionReq.FieldSchema schema = CreateCollectionReq.FieldSchema.builder()
                 .name(fieldSchema.getName())
                 .description(fieldSchema.getDescription())
-                .dataType(io.milvus.v2.common.DataType.valueOf(fieldSchema.getDataType().name()))
+                .dataType(actualType)
                 .isPrimaryKey(fieldSchema.getIsPrimaryKey())
                 .isPartitionKey(fieldSchema.getIsPartitionKey())
                 .isClusteringKey(fieldSchema.getIsClusteringKey())
                 .autoID(fieldSchema.getAutoID())
-                .elementType(io.milvus.v2.common.DataType.valueOf(fieldSchema.getElementType().name()))
+                .elementType(actualElementType)
                 .isNullable(fieldSchema.getNullable())
                 .defaultValue(ParamUtils.valueFieldToObject(fieldSchema.getDefaultValue(), fieldSchema.getDataType()))
                 .build();
@@ -183,11 +233,11 @@ public class SchemaUtils {
         Map<String, String> typeParams = new HashMap<>();
         for (KeyValuePair keyValuePair : fieldSchema.getTypeParamsList()) {
             try {
-                if(keyValuePair.getKey().equals("dim")){
+                if(keyValuePair.getKey().equals(VECTOR_DIM)){
                     schema.setDimension(Integer.parseInt(keyValuePair.getValue()));
-                } else if(keyValuePair.getKey().equals("max_length")){
+                } else if(keyValuePair.getKey().equals(VARCHAR_MAX_LENGTH)){
                     schema.setMaxLength(Integer.parseInt(keyValuePair.getValue()));
-                } else if(keyValuePair.getKey().equals("max_capacity")){
+                } else if(keyValuePair.getKey().equals(ARRAY_MAX_CAPACITY)){
                     schema.setMaxCapacity(Integer.parseInt(keyValuePair.getValue()));
                 } else if(keyValuePair.getKey().equals("enable_analyzer")){
                     schema.setEnableAnalyzer(Boolean.parseBoolean(keyValuePair.getValue()));
@@ -214,6 +264,30 @@ public class SchemaUtils {
         return schema;
     }
 
+    public static CreateCollectionReq.StructFieldSchema convertFromGrpcStructFieldSchema(StructArrayFieldSchema structSchema) {
+        CreateCollectionReq.StructFieldSchema.StructFieldSchemaBuilder builder =
+                CreateCollectionReq.StructFieldSchema.builder()
+                        .name(structSchema.getName())
+                        .description(structSchema.getDescription());
+        List<CreateCollectionReq.FieldSchema> fields = new ArrayList<>();
+        for (FieldSchema fieldSchema : structSchema.getFieldsList()) {
+            CreateCollectionReq.FieldSchema field = convertFromGrpcFieldSchema(fieldSchema);
+            builder.maxCapacity(field.getMaxCapacity());
+            // each rpc proto struct's sub-field schema, the data type is Array or ArrayOfVector, the typeParams
+            // contains a "max_capacity" value
+            // reset data type to element type, remove the "max_capacity" from typeParams
+            field.setDataType(ConvertUtils.toSdkDataType(fieldSchema.getElementType()));
+            field.setElementType(io.milvus.v2.common.DataType.None);
+            Map<String, String> params = field.getTypeParams();
+            params.remove(ARRAY_MAX_CAPACITY);
+            field.setTypeParams(params);
+            field.setMaxCapacity(0);
+            fields.add(field);
+        }
+        builder.fields(fields);
+        return builder.build();
+    }
+
     public static CreateCollectionReq.Function convertFromGrpcFunction(FunctionSchema functionSchema) {
         CreateCollectionReq.Function.FunctionBuilder builder = CreateCollectionReq.Function.builder()
                 .name(functionSchema.getName())
@@ -267,4 +341,52 @@ public class SchemaUtils {
 
         return fieldSchema;
     }
+
+    public static CreateCollectionReq.StructFieldSchema convertFieldReqToStructFieldSchema(AddFieldReq addFieldReq) {
+        List<CreateCollectionReq.FieldSchema> fields = addFieldReq.getStructFields();
+        if (fields.isEmpty()) {
+            throw new ParamException("Struct field must have at least one field");
+        }
+        String structName = addFieldReq.getFieldName();
+        if (addFieldReq.getMaxCapacity() == null) {
+            String msg = String.format("maxCapacity not set for struct field: '%s'", structName);
+            throw new ParamException(msg);
+        }
+
+        Set<String> uniqueNames = new HashSet<>();
+        for (CreateCollectionReq.FieldSchema field : fields) {
+            String fieldName = field.getName();
+            uniqueNames.add(fieldName);
+            if (field.getIsPrimaryKey()) {
+                String msg = String.format("Field '%s' in struct '%s' cannot be primary key", fieldName, structName);
+                throw new ParamException(msg);
+            } else if (field.getIsPartitionKey()) {
+                String msg = String.format("Field '%s' in struct '%s' cannot be partition key", fieldName, structName);
+                throw new ParamException(msg);
+            } else if (field.getIsClusteringKey()) {
+                String msg = String.format("Field '%s' in struct '%s' cannot be clustering key", fieldName, structName);
+                throw new ParamException(msg);
+            } else if (field.getAutoID()) {
+                String msg = String.format("Field '%s' in struct '%s' cannot be auto-id", fieldName, structName);
+                throw new ParamException(msg);
+            } else if (field.getIsNullable()) {
+                String msg = String.format("Field '%s' in struct '%s' cannot be nullable", fieldName, structName);
+                throw new ParamException(msg);
+            } else if (field.getDefaultValue() != null) {
+                String msg = String.format("Field '%s' in struct '%s' cannot have default value", fieldName, structName);
+                throw new ParamException(msg);
+            }
+        }
+        if (uniqueNames.size() != fields.size()) {
+            String msg = String.format("Duplicate field names in struct '%s'", structName);
+            throw new ParamException(msg);
+        }
+
+        return CreateCollectionReq.StructFieldSchema.builder()
+                .name(addFieldReq.getFieldName())
+                .description(addFieldReq.getDescription())
+                .fields(fields)
+                .maxCapacity(addFieldReq.getMaxCapacity())
+                .build();
+    }
 }

+ 1 - 1
sdk-core/src/main/milvus-proto

@@ -1 +1 @@
-Subproject commit 7216d96dbfb58d22dd22e978eef227d431230088
+Subproject commit f0057758f33685962ac3668dfd5ccadacfadb4c3

+ 2 - 2
sdk-core/src/test/java/io/milvus/TestUtils.java

@@ -11,7 +11,7 @@ public class TestUtils {
     private int dimension = 256;
     private static final Random RANDOM = new Random();
 
-    public static final String MilvusDockerImageID = "milvusdb/milvus:v2.6.1";
+    public static final String MilvusDockerImageID = "milvusdb/milvus:master-20250922-200ee4cb-amd64";
 
     public TestUtils(int dimension) {
         this.dimension = dimension;
@@ -89,7 +89,7 @@ public class TestUtils {
     public SortedMap<Long, Float> generateSparseVector() {
         SortedMap<Long, Float> sparse = new TreeMap<>();
         int dim = RANDOM.nextInt(10) + 10;
-        for (int i = 0; i < dim; ++i) {
+        while (sparse.size() < dim) {
             sparse.put((long) RANDOM.nextInt(1000000), RANDOM.nextFloat());
         }
         return sparse;

+ 201 - 0
sdk-core/src/test/java/io/milvus/v2/client/MilvusClientV2DockerTest.java

@@ -1019,6 +1019,207 @@ class MilvusClientV2DockerTest {
         }
     }
 
+    @Test
+    void testStruct() {
+        String randomCollectionName = generator.generate(10);
+        String pkField = "key";
+        String normalVectorField = "vector";
+        String normalScalarField = "text";
+        String structField = "clips";
+        String structScalarField = "desc";
+        String structVectorField = "clip";
+        int structCapacity = 300;
+        int varcharLength = 100;
+        CreateCollectionReq.CollectionSchema collectionSchema = CreateCollectionReq.CollectionSchema.builder()
+                .build();
+        collectionSchema.addField(AddFieldReq.builder()
+                .fieldName(pkField)
+                .dataType(DataType.Int64)
+                .isPrimaryKey(Boolean.TRUE)
+                .build());
+        collectionSchema.addField(AddFieldReq.builder()
+                .fieldName(normalVectorField)
+                .dataType(DataType.FloatVector)
+                .dimension(DIMENSION)
+                .build());
+        collectionSchema.addField(AddFieldReq.builder()
+                .fieldName(normalScalarField)
+                .dataType(DataType.VarChar)
+                .maxLength(varcharLength)
+                .build());
+        collectionSchema.addField(AddFieldReq.builder()
+                .fieldName(structField)
+                .description("dummy")
+                .dataType(DataType.Array)
+                .elementType(DataType.Struct)
+                .maxCapacity(structCapacity)
+                .addStructField(AddFieldReq.builder()
+                        .fieldName(structScalarField)
+                        .description("dummy")
+                        .dataType(DataType.VarChar)
+                        .maxLength(varcharLength)
+                        .build())
+                .addStructField(AddFieldReq.builder()
+                        .fieldName(structVectorField)
+                        .description("dummy")
+                        .dataType(DataType.FloatVector)
+                        .dimension(DIMENSION)
+                        .build())
+                .build());
+
+        client.dropCollection(DropCollectionReq.builder()
+                .collectionName(randomCollectionName)
+                .build());
+
+        CreateCollectionReq requestCreate = CreateCollectionReq.builder()
+                .collectionName(randomCollectionName)
+                .collectionSchema(collectionSchema)
+                .build();
+        client.createCollection(requestCreate);
+
+        List<IndexParam> indexParams = new ArrayList<>();
+        indexParams.add(IndexParam.builder()
+                .fieldName(normalVectorField)
+                .indexType(IndexParam.IndexType.HNSW)
+                .metricType(IndexParam.MetricType.COSINE)
+                .build());
+        indexParams.add(IndexParam.builder()
+                .fieldName(structVectorField)
+                .indexType(IndexParam.IndexType.EMB_LIST_HNSW)
+                .metricType(IndexParam.MetricType.MAX_SIM)
+                .build());
+        client.createIndex(CreateIndexReq.builder()
+                .collectionName(randomCollectionName)
+                .indexParams(indexParams)
+                .build());
+        client.loadCollection(LoadCollectionReq.builder()
+                .collectionName(randomCollectionName)
+                .build());
+
+        // describe
+        DescribeCollectionResp descResp = client.describeCollection(DescribeCollectionReq.builder()
+                .collectionName(randomCollectionName)
+                .build());
+        CreateCollectionReq.CollectionSchema descSchema = descResp.getCollectionSchema();
+        Assertions.assertEquals(1, descSchema.getStructFields().size());
+        CreateCollectionReq.StructFieldSchema structSchema = descSchema.getStructFields().get(0);
+        Assertions.assertEquals(structField, structSchema.getName());
+        Assertions.assertEquals("dummy", structSchema.getDescription());
+        Assertions.assertEquals(DataType.Array, structSchema.getDataType());
+        Assertions.assertEquals(DataType.Struct, structSchema.getElementType());
+        Assertions.assertEquals(structCapacity, structSchema.getMaxCapacity());
+        Assertions.assertEquals(2, structSchema.getFields().size());
+
+        CreateCollectionReq.FieldSchema field1 = structSchema.getFields().get(0);
+        Assertions.assertEquals(structScalarField, field1.getName());
+        Assertions.assertEquals("dummy", field1.getDescription());
+        Assertions.assertEquals(DataType.VarChar, field1.getDataType());
+        Assertions.assertEquals(varcharLength, field1.getMaxLength());
+
+        CreateCollectionReq.FieldSchema field2 = structSchema.getFields().get(1);
+        Assertions.assertEquals(structVectorField, field2.getName());
+        Assertions.assertEquals("dummy", field2.getDescription());
+        Assertions.assertEquals(DataType.FloatVector, field2.getDataType());
+        Assertions.assertEquals(DIMENSION, field2.getDimension());
+
+        DescribeIndexResp indexDesc = client.describeIndex(DescribeIndexReq.builder()
+                .collectionName(randomCollectionName)
+                .fieldName(structVectorField)
+                .build());
+        Assertions.assertEquals(1, indexDesc.getIndexDescriptions().size());
+        DescribeIndexResp.IndexDesc desc = indexDesc.getIndexDescriptions().get(0);
+        Assertions.assertEquals(IndexParam.IndexType.EMB_LIST_HNSW, desc.getIndexType());
+        Assertions.assertEquals(IndexParam.MetricType.MAX_SIM, desc.getMetricType());
+
+        // insert
+        Random RANDOM = new Random();
+        List<JsonObject> rows = new ArrayList<>();
+        int count = 20;
+        for (int i = 0; i < count; i++) {
+            JsonObject row = new JsonObject();
+            row.addProperty(pkField, i);
+            row.addProperty(normalScalarField, "text_" + i);
+            row.add(normalVectorField, JsonUtils.toJsonTree(utils.generateFloatVector()));
+            JsonArray structArr = new JsonArray();
+            for (int k = 0; k < 5; k++) {
+                JsonObject struct = new JsonObject();
+                struct.addProperty(structScalarField, "No." + k);
+                struct.add(structVectorField, JsonUtils.toJsonTree(utils.generateFloatVector()));
+                structArr.add(struct);
+            }
+            row.add(structField, structArr);
+            rows.add(row);
+        }
+
+        InsertResp insertResp = client.insert(InsertReq.builder()
+                .collectionName(randomCollectionName)
+                .data(rows)
+                .build());
+        Assertions.assertEquals(count, insertResp.getInsertCnt());
+
+        // upsert
+        JsonObject row = new JsonObject();
+        row.addProperty(pkField, 6);
+        row.addProperty(normalScalarField, "update_text");
+        row.add(normalVectorField, JsonUtils.toJsonTree(utils.generateFloatVector()));
+        JsonArray structArr = new JsonArray();
+        for (int k = 0; k < 3; k++) {
+            JsonObject struct = new JsonObject();
+            struct.addProperty(structScalarField, "updated_No." + k);
+            struct.add(structVectorField, JsonUtils.toJsonTree(utils.generateFloatVector()));
+            structArr.add(struct);
+        }
+        row.add(structField, structArr);
+
+        UpsertResp upsertResp = client.upsert(UpsertReq.builder()
+                .collectionName(randomCollectionName)
+                .data(Collections.singletonList(row))
+                .build());
+        Assertions.assertEquals(1, upsertResp.getUpsertCnt());
+
+        // query
+        QueryResp queryResp = client.query(QueryReq.builder()
+                .collectionName(randomCollectionName)
+                .filter(String.format("%s == 6 or %s == 9", pkField, pkField))
+                .limit(3)
+                .consistencyLevel(ConsistencyLevel.STRONG)
+                .outputFields(Collections.singletonList("*"))
+                .build());
+        List<QueryResp.QueryResult> queryResults = queryResp.getQueryResults();
+        Assertions.assertEquals(2, queryResults.size());
+        Assertions.assertTrue(queryResults.get(0).getEntity().containsKey(structField));
+        Assertions.assertTrue(queryResults.get(1).getEntity().containsKey(structField));
+
+        // search
+        List<Map<String, Object>> structs0 = (List<Map<String, Object>>)queryResults.get(0).getEntity().get(structField);
+        EmbeddingList embList0 = new EmbeddingList();
+        for (Map<String, Object> struct : structs0) {
+            embList0.add(new FloatVec((List<Float>)struct.get(structVectorField)));
+        }
+
+        List<Map<String, Object>> structs1 = (List<Map<String, Object>>)queryResults.get(1).getEntity().get(structField);
+        EmbeddingList embList1 = new EmbeddingList();
+        for (Map<String, Object> struct : structs1) {
+            embList1.add(new FloatVec((List<Float>)struct.get(structVectorField)));
+        }
+
+        int topK = 5;
+        SearchResp searchResp = client.search(SearchReq.builder()
+                .collectionName(randomCollectionName)
+                .annsField(structVectorField)
+                .data(Arrays.asList(embList0, embList1))
+                .limit(topK)
+                .outputFields(Collections.singletonList(structScalarField))
+                .build());
+        List<List<SearchResp.SearchResult>> searchResults = searchResp.getSearchResults();
+        Assertions.assertEquals(2, searchResults.size());
+        for (List<SearchResp.SearchResult> oneResults : searchResults) {
+            Assertions.assertEquals(topK, oneResults.size());
+        }
+        Assertions.assertEquals(6L, (long)searchResults.get(0).get(0).getId());
+        Assertions.assertEquals(9L, (long)searchResults.get(1).get(0).getId());
+    }
+
     @Test
     void testHybridSearch() {
         String randomCollectionName = generator.generate(10);