Milvus快速入门

Milvus快速入门

文档创建日期:2024-06-06

Milvus版本:v2.4.3

什么是Milvus

向量作为神经网络模型的输出数据格式,可以有效地编码信息,在知识库、语义搜索、检索增强生成(RAG)等人工智能应用中发挥着关键作用。

Milvus是一个开源的向量数据库,适合各种规模的AI应用程序。作为一个专门为处理输入向量查询而设计的数据库,它能够为万亿规模的向量建立索引。与现有的主要遵循预定义模式处理结构化数据的关系数据库不同,Milvus从底向上设计,用于处理从非结构化数据转换而来的嵌入向量。

随着互联网的发展和发展,非结构化数据变得越来越普遍,包括电子邮件、论文、物联网传感器数据、照片等。为了让计算机理解和处理非结构化数据,使用嵌入技术(embedding techniques)将其转换为向量。Milvus存储和索引这些向量。Milvus通过计算两个向量的相似度距离来分析它们之间的相关性。如果两个嵌入向量非常相似,则意味着原始数据源也很相似。

基本概念

非结构化数据(Unstructured data)

非结构化数据(包括图像、视频、音频和自然语言)是指不遵循预定义schema组织的信息。这种数据类型约占世界数据的80%,可以使用各种人工智能(AI)和机器学习(ML)模型将其转换为向量。

嵌入向量(Embedding vectors)

嵌入向量是非结构化数据的特征抽象,如电子邮件、物联网传感器数据、照片等。从数学上讲,嵌入向量是一个浮点数或二进制数组。嵌入技术用于将非结构化数据转换为嵌入向量。

向量相似性搜索(Vector similarity search)

向量相似性搜索是将一个向量与数据库进行比较,以找到与查询向量最相似的向量的过程。采用近似最近邻(ANN)搜索算法加速搜索过程。如果两个嵌入向量非常相似,则意味着原始数据源也很相似。

集合(Collection)

在Milvus中,集合等同于关系数据库管理系统(RDBMS)中的表。集合是用于存储和管理实体的主要逻辑对象。

实体(Entity)

实体由一组表示真实世界对象的字段组成。Milvus中的每个实体都由一个唯一的主键表示。

实体可以自定义主键。如果不手动配置,Milvus将自动为实体分配主键。如果选择自定义主键,请注意Milvus目前不支持主键重复删除。因此,同一个集合中可能有重复的主键。

字段(Field)

Milvus集合中的字段相当于RDBMS中表的一列。字段可以是用于结构化数据(例如数字、字符串)的标量字段,也可以是用于嵌入向量的向量字段。

段(Segment)

段是一个自动创建的数据文件,用于存储插入的数据。一个集合可以包含多个段,每个段可以容纳多个实体。在向量相似性搜索过程中,Milvus检查每个片段以编译搜索结果。

有两种类型的片段:增长和密封。一个不断增长的段会持续收集新数据,直到达到特定的阈值或时间限制,之后就会被密封。一旦密封,段就不再接受新数据,并转移到对象存储。与此同时,传入的数据被路由到一个新的增长分段。从增长段到密封段的转换是通过达到预定义的实体限制或超过增长状态中允许的最大持续时间触发的。

分区(Partition)

一个集合可以包含多个分区。Milvus支持在物理存储上将收集数据划分为多个部分,每个分区可以包含多个段。

通过docker安装

本文仅展示单体版本的Milvus安装,不涉及集群相关的安装。

docker-compose.yml

官方文件下载地址:
https://github.com/milvus-io/milvus/blob/master/deployments/docker/standalone/docker-compose.yml

version: '3.5'

services:
  etcd:
    container_name: milvus-etcd
    image: quay.io/coreos/etcd:v3.5.5
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
      interval: 30s
      timeout: 20s
      retries: 3

  minio:
    container_name: milvus-minio
    image: minio/minio:RELEASE.2023-03-20T20-16-18Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    container_name: milvus-standalone
    image: milvusdb/milvus:v2.4.3
    command: ["milvus", "run", "standalone"]
    security_opt:
    - seccomp:unconfined
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
      interval: 30s
      start_period: 90s
      timeout: 20s
      retries: 3
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - "etcd"
      - "minio"
  attu:
    container_name: milvus-attu
    image: zilliz/attu:v2.4
    restart: always
    environment:
      - MILVUS_URL=127.0.0.1:19530
    ports:
      - "8000:3000"
    depends_on:
      - "milvus"
networks:
  default:
    name: milvus

上面的docker-compose文件中Attu是一个一体化的Milvus管理工具,相当于传统关系数据库的Navicat,它不是必须的,但使用Attu,可以显著降低管理milvus的成本。

启动命令

docker compose up -d

在Java中使用

全局配置

添加依赖

        <dependency>
            <groupId>io.milvus</groupId>
            <artifactId>milvus-sdk-java</artifactId>
            <version>2.4.0</version>
        </dependency>

版本需要和milvus对应,参考地址:https://github.com/milvus-io/milvus-sdk-java 。

如果添加依赖之后每次运行都输出一些与日志相关的报错信息,可以通过排除sdk中的日志依赖解决:

        <dependency>
            <groupId>io.milvus</groupId>
            <artifactId>milvus-sdk-java</artifactId>
            <version>2.4.0</version>
            <exclusions>
                <exclusion>
                    <groupId>ch.qos.logback</groupId>
                    <artifactId>logback-classic</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.apache.logging.log4j</groupId>
                    <artifactId>log4j-slf4j-impl</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-reload4j</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

配置类说明

在后面的代码中可能涉及到一些常量,例如MILVUS_ENDPOINT​等,所有的配置都在MilvusConfig​中定义。

public interface MilvusConfig {
    String MILVUS_ENDPOINT = "http://nqs-dev3:19530";
    String MY_DATABASE = "my_database";
    String MY_COLLECTION = "my_collection";
}

管理数据库

与传统的数据库引擎类似,可以在Milvus中创建数据库,并为某些用户分配权限来管理它们。这样,这些用户就有权管理数据库中的集合。Milvus集群最多支持64个数据库。

创建数据库

        // 连接到默认数据库
        ConnectParam connectParam = ConnectParam.newBuilder()
                .withUri(MILVUS_ENDPOINT)
                .build();
        MilvusServiceClient client = new MilvusServiceClient(connectParam);
        // 创建一个名为my_database的新数据库
        CreateDatabaseParam createDatabaseParam = CreateDatabaseParam.newBuilder()
                .withDatabaseName("my_database")
                .build();
        R<RpcStatus> response = client.createDatabase(createDatabaseParam);

连接指定的数据库

设置连接到Milvus集群时使用的数据库,如下所示:

        ConnectParam connectParam = ConnectParam.newBuilder()
            .withUri(MILVUS_ENDPOINT)
            // 如果不指定数据库名默认连接‘default’
            .withDatabaseName("my_database")
            .build();
        MilvusServiceClient client = new MilvusServiceClient(connectParam);

Milvus集群附带一个名为default​的默认数据库。除非另有说明,否则在默认数据库中创建集合。

获取数据库列表

要找到Milvus集群中所有现有的数据库,可以使用listDatabases()​方法:

        ConnectParam connectParam = ConnectParam.newBuilder()
                .withUri(MILVUS_ENDPOINT)
                .build();
        MilvusServiceClient client = new MilvusServiceClient(connectParam);
        R<ListDatabasesResponse> response = client.listDatabases();
        ListDatabasesResponse data = response.getData();
        // 所有数据库名称列表
        ProtocolStringList dbNamesList = data.getDbNamesList();

删除数据库

要删除一个数据库,必须先删除它的所有集合。

要删除数据库,使用dropDatabase()​方法:

        ConnectParam connectParam = ConnectParam.newBuilder()
                .withUri(MILVUS_ENDPOINT)
                .build();
        MilvusServiceClient client = new MilvusServiceClient(connectParam);
        DropDatabaseParam dropDatabaseParam = DropDatabaseParam.newBuilder()
                .withDatabaseName(MY_DATABASE)
                .build();
        R<RpcStatus> response = client.dropDatabase(dropDatabaseParam);

管理集合

在Milvus中,将向量嵌入存储在集合中。集合中的所有向量嵌入共享相同的维度和距离度量来度量相似性。

Milvus集合支持动态字段(即在模式中没有预定义的字段)和主键的自动递增。

为了适应不同的偏好,Milvus提供了两种创建集合的方法。一个提供快速设置,而另一个允许详细定制集合模式和索引参数。

此外,还可以在必要时查看、加载、释放和删除集合。

创建集合

创建集合的方式有以下两种:

  • 快速设置:通过简单地给它一个名称并指定要存储在此集合中的向量嵌入的维数来创建集合。
  • 自定义设置:自己确定集合的模式和索引参数,而不是让Milvus决定集合大多数内容。自定义设置创建集合的更多信息参考官方文档。

通过MilvusClientV2​类的createCollection()​方法快速创建集合:

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        // 以快速设置模式创建集合
        CreateCollectionReq quickSetupReq = CreateCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .dimension(5)
                .build();
        client.createCollection(quickSetupReq);
        GetLoadStateReq quickSetupLoadStateReq = GetLoadStateReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        Boolean res = client.getLoadState(quickSetupLoadStateReq);
        log.info("res = {}", res); // true

上面代码创建的集合只包含两个字段:id(作为主键)​和vector(作为vector字段)​,默认启用auto_id​和enable_dynamic_field​设置。

  • auto_id​:启用此设置可确保主键自动递增。在数据插入期间不需要手动设置主键。
  • enable_dynamic_field​:启用后,插入的数据中的id​和vector​之外的所有字段都被视为动态字段。这些附加字段以键值对的形式保存在一个名为$meta​的特殊字段中。这个特性允许在数据插入时包含额外的字段。

查看集合列表

要查询当前数据库中的所有集合名称,可以使用listCollections()​。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        ListCollectionsResp listCollectionsResp = client.listCollections();
        List<String> collectionNames = listCollectionsResp.getCollectionNames();
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(collectionNames));

查看集合详细信息

要查看现有集合的详细信息,可以使用describeCollection()​。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        DescribeCollectionReq describeCollectionReq = DescribeCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        DescribeCollectionResp describeCollectionRes = client.describeCollection(describeCollectionReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(describeCollectionRes));

加载和释放集合

在集合的加载过程中,Milvus将集合的索引文件加载到内存中。相反,在释放集合时,Milvus从内存中卸载索引文件。在集合中执行搜索之前,请确保已加载集合。

加载集合

要加载集合,可以使用loadCollection()​方法,并指定集合名称。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        LoadCollectionReq loadCollectionReq = LoadCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        client.loadCollection(loadCollectionReq);
        GetLoadStateReq loadStateReq = GetLoadStateReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        Boolean res = client.getLoadState(loadStateReq);
        log.info("res = {}", res); // true
释放集合

要释放集合,可以使用releaseCollection()​方法,指定集合名称。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        ReleaseCollectionReq releaseCollectionReq = ReleaseCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        client.releaseCollection(releaseCollectionReq);
        GetLoadStateReq loadStateReq = GetLoadStateReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        Boolean res = client.getLoadState(loadStateReq);
        log.info("res = {}", res); // false

删除集合

要删除集合,可以使用dropCollection()​方法,并指定集合名称。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        DropCollectionReq dropCollectionReq = DropCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        client.dropCollection(dropCollectionReq);

实体管理

在Milvus集合的上下文中,实体是集合中的一个单一的、可识别的实例。它代表特定类别的一个独特成员,可能是图书馆中的一本书,基因组中的一个基因,或任何其他可识别的实体。这里实体的概念和传统的关系数据库中的实体概念是类似的。

实体管理小节说明:

  • 要操作实体,必须要先创建一个集合,相当于传统关系型数据库的插入数据之前必须要先创建数据库并创建一张数据表。
  • 增删改查操作的参数均可以通过partitionName()​方法指定要操作哪个分区里面的操作。

新增实体

将实体插入到集合中,提供的数据应该包含目标集合中所有模式定义的字段。此外,如果启用了dynamic​字段,可以包含非模式定义的字段。非模式定义的字段将以键值对的形式保存在一个名为$meta​的保留JSON字段中。

要在集合中插入实体,可以使用insert()​方法。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<JSONObject> data = Arrays.asList(
                new JSONObject(Map.of("id", 0L, "vector", Arrays.asList(0.3580376395471989f, -0.6023495712049978f, 0.18414012509913835f, -0.26286205330961354f, 0.9029438446296592f), "color", "pink_8682")),
                new JSONObject(Map.of("id", 1L, "vector", Arrays.asList(0.19886812562848388f, 0.06023560599112088f, 0.6976963061752597f, 0.2614474506242501f, 0.838729485096104f), "color", "red_7025")),
                new JSONObject(Map.of("id", 2L, "vector", Arrays.asList(0.43742130801983836f, -0.5597502546264526f, 0.6457887650909682f, 0.7894058910881185f, 0.20785793220625592f), "color", "orange_6781")),
                new JSONObject(Map.of("id", 3L, "vector", Arrays.asList(0.3172005263489739f, 0.9719044792798428f, -0.36981146090600725f, -0.4860894583077995f, 0.95791889146345f), "color", "pink_9298")),
                new JSONObject(Map.of("id", 4L, "vector", Arrays.asList(0.4452349528804562f, -0.8757026943054742f, 0.8220779437047674f, 0.46406290649483184f, 0.30337481143159106f), "color", "red_4794")),
                new JSONObject(Map.of("id", 5L, "vector", Arrays.asList(0.985825131989184f, -0.8144651566660419f, 0.6299267002202009f, 0.1206906911183383f, -0.1446277761879955f), "color", "yellow_4222")),
                new JSONObject(Map.of("id", 6L, "vector", Arrays.asList(0.8371977790571115f, -0.015764369584852833f, -0.31062937026679327f, -0.562666951622192f, -0.8984947637863987f), "color", "red_9392")),
                new JSONObject(Map.of("id", 7L, "vector", Arrays.asList(-0.33445148015177995f, -0.2567135004164067f, 0.8987539745369246f, 0.9402995886420709f, 0.5378064918413052f), "color", "grey_8510")),
                new JSONObject(Map.of("id", 8L, "vector", Arrays.asList(0.39524717779832685f, 0.4000257286739164f, -0.5890507376891594f, -0.8650502298996872f, -0.6140360785406336f), "color", "white_9381")),
                new JSONObject(Map.of("id", 9L, "vector", Arrays.asList(0.5718280481994695f, 0.24070317428066512f, -0.3737913482606834f, -0.06726932177492717f, -0.6980531615588608f), "color", "purple_4976"))
        );
        InsertReq insertReq = InsertReq.builder()
                .collectionName(MY_COLLECTION)
                .data(data)
                .build();
        InsertResp insertResp = client.insert(insertReq);
        log.info(JSONUtil.toJsonPrettyStrUseDefaultConfig(insertResp));

更新实体

Upserting数据是更新和插入操作的组合。在Milvus中,upsert​操作执行数据级别的操作,根据实体的主键是否已经存在于集合中来插入或更新实体。具体地说:

  • 如果集合中已经存在实体的主键,则现有实体将被覆盖。
  • 如果集合中不存在主键,则会插入一个新的实体。

要更新实体,使用upsert()​方法。

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<JSONObject> data = Arrays.asList(
                        new JSONObject(Map.of("id", 0L, "vector", Arrays.asList(-0.619954382375778f, 0.4479436794798608f, -0.17493894838751745f, -0.4248030059917294f, -0.8648452746018911f), "color", "black_9898")),
                        new JSONObject(Map.of("id", 1L, "vector", Arrays.asList(0.4762662251462588f, -0.6942502138717026f, -0.4490002642657902f, -0.628696575798281f, 0.9660395877041965f), "color", "red_7319")),
                        new JSONObject(Map.of("id", 2L, "vector", Arrays.asList(-0.8864122635045097f, 0.9260170474445351f, 0.801326976181461f, 0.6383943392381306f, 0.7563037341572827f), "color", "white_6465")),
                        new JSONObject(Map.of("id", 3L, "vector", Arrays.asList(0.14594326235891586f, -0.3775407299900644f, -0.3765479013078812f, 0.20612075380355122f, 0.4902678929632145f), "color", "orange_7580")),
                        new JSONObject(Map.of("id", 4L, "vector", Arrays.asList(0.4548498669607359f, -0.887610217681605f, 0.5655081329910452f, 0.19220509387904117f, 0.016513983433433577f), "color", "red_3314")),
                        new JSONObject(Map.of("id", 5L, "vector", Arrays.asList(0.11755001847051827f, -0.7295149788999611f, 0.2608115847524266f, -0.1719167007897875f, 0.7417611743754855f), "color", "black_9955")),
                        new JSONObject(Map.of("id", 6L, "vector", Arrays.asList(0.9363032158314308f, 0.030699901477745373f, 0.8365910312319647f, 0.7823840208444011f, 0.2625222076909237f), "color", "yellow_2461")),
                        new JSONObject(Map.of("id", 7L, "vector", Arrays.asList(0.0754823906014721f, -0.6390658668265143f, 0.5610517334334937f, -0.8986261118798251f, 0.9372056764266794f), "color", "white_5015")),
                        new JSONObject(Map.of("id", 8L, "vector", Arrays.asList(-0.3038434006935904f, 0.1279149203380523f, 0.503958664270957f, -0.2622661156746988f, 0.7407627307791929f), "color", "purple_6414")),
                        new JSONObject(Map.of("id", 9L, "vector", Arrays.asList(-0.7125086947677588f, -0.8050968321012257f, -0.32608864121785786f, 0.3255654958645424f, 0.26227968923834233f), "color", "brown_7231"))
                );
        UpsertReq upsertReq = UpsertReq.builder()
                .collectionName(MY_COLLECTION)
                .data(data)
                .build();
        UpsertResp upsertResp = client.upsert(upsertReq);
        log.info(JSONUtil.toJsonPrettyStrUseDefaultConfig(upsertResp));

删除实体

如果不再需要某个实体,可以使用delete()​将其从集合中删除。

Milvus提供了两种方法来识别要删除的实体。

使用过滤器删除实体
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        DeleteReq deleteReq = DeleteReq.builder()
                .collectionName(MY_COLLECTION)
                .filter("id in [4, 5, 6]")
                .build();
        DeleteResp deleteResp = client.delete(deleteReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(deleteResp));
根据id删除实体
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        DeleteReq deleteReq = DeleteReq.builder()
                .collectionName(MY_COLLECTION)
                .ids(Arrays.asList(7L, 8L))
                .build();
        DeleteResp deleteResp = client.delete(deleteReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(deleteResp));

基本搜索

如果你的集合只有一个向量字段,使用search()​方法来找到最相似的实体。此方法将查询向量与集合中现有的向量进行比较,并返回最接近的匹配的id以及它们之间的距离。

Milvus有多种搜索类型来满足不同的需求:

  • 基本搜索:包括单向量搜索、批向量搜索、分区搜索和指定输出字段的搜索。
  • 过滤搜索:应用基于标量字段的过滤条件来优化搜索结果。
  • 范围搜索:查找与查询向量在特定距离范围内的向量。
  • 分组搜索:根据特定的字段对搜索结果进行分组,以确保结果的多样性。

本文仅介绍关于基础搜索的相关内容,其他查询相关请查看官方文档。

准备数据

准备一些数据,方便后面的查询示例使用。

    @Test
    public void prepareData() {
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);

        // 删除原有的集合
        DropCollectionReq dropCollectionRequest= DropCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        client.dropCollection(dropCollectionRequest);

        // 创建集合
        CreateCollectionReq quickSetupReq = CreateCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .dimension(5)
                .metricType("IP")
                .build();
        client.createCollection(quickSetupReq);
        GetLoadStateReq loadStateReq = GetLoadStateReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        boolean loadState = client.getLoadState(loadStateReq);
        log.info("{} loadState = {}", MY_COLLECTION, loadState);

        // 将随机生成的向量插入集合中
        List<String> colors = Arrays.asList("green", "blue", "yellow", "red", "black", "white", "purple", "pink", "orange", "brown", "grey");
        List<JSONObject> data = IntStream.range(0, 1000).mapToObj(i -> {
            String current_color = colors.get(rand.nextInt(colors.size() - 1));
            JSONObject row = new JSONObject();
            row.put("id", (long) i);
            row.put("vector", Arrays.asList(rand.nextFloat(), rand.nextFloat(), rand.nextFloat(), rand.nextFloat(), rand.nextFloat()));
            row.put("color_tag", current_color + "_" + (rand.nextInt(8999) + 1000));
            return row;
        }).collect(Collectors.toList());
        InsertReq insertReq = InsertReq.builder()
                .collectionName(MY_COLLECTION)
                .data(data)
                .build();
        InsertResp insertResp = client.insert(insertReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(insertResp));

        // 创建分区
        createPartition(client, "red");
        createPartition(client, "blue");

        // 插入数据到分区
        data = IntStream.range(1000, 1500).mapToObj(i -> createJsonObject(i, "read")).collect(Collectors.toList());
        insertReq = InsertReq.builder()
                .collectionName(MY_COLLECTION)
                .data(data)
                .partitionName("red")
                .build();
        insertResp = client.insert(insertReq);
        log.info("red {}", JSONUtil.toJsonPrettyStrUseDefaultConfig(insertResp));
        data = IntStream.range(1500, 2000).mapToObj(i -> createJsonObject(i, "blue")).collect(Collectors.toList());
        insertReq = InsertReq.builder()
                .collectionName(MY_COLLECTION)
                .data(data)
                .partitionName("blue")
                .build();
        insertResp = client.insert(insertReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(insertResp));
    }

    private static void createPartition(MilvusClientV2 client, String partitionName) {
        CreatePartitionReq partitionReq = CreatePartitionReq.builder()
                .collectionName(MY_COLLECTION)
                .partitionName(partitionName)
                .build();
        client.createPartition(partitionReq);
    }

    @NotNull
    private static JSONObject createJsonObject(long id, String current_color) {
        JSONObject row = new JSONObject();
        row.put("id", id);
        row.put("vector", Arrays.asList(rand.nextFloat(), rand.nextFloat(), rand.nextFloat(), rand.nextFloat(), rand.nextFloat()));
        row.put("color", current_color);
        row.put("color_tag", current_color + "_" + (rand.nextInt(8999) + 1000));
        return row;
    }

在发送查询请求时,可以提供一个或多个向量值表示查询嵌入,并提供一个表示返回结果数量的topK​。

根据数据和查询向量,可能会得到少于topK​的结果。当topK​大于查询中可能匹配向量的数量时,就会发生这种情况。

单向量搜索

单向量搜索是Milvus中搜索操作的最简单形式,旨在找到与给定查询向量最相似的向量。

要执行单向量搜索,需要指定目标集合名称、查询向量和所需的结果数量(limit)。该操作返回一个结果集,其中包含最相似的向量、它们的id​以及与查询向量的距离。

下面是一个搜索与查询向量最相似的前5个实体的示例:

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<List<Float>> query_vectors = Arrays.asList(Arrays.asList(0.3580376395471989f, -0.6023495712049978f, 0.18414012509913835f, -0.26286205330961354f, 0.9029438446296592f));
        SearchReq searchReq = SearchReq.builder()
                .collectionName(MY_COLLECTION)
                .data(query_vectors)
                .topK(5)
                .build();
        SearchResp searchResp = client.search(searchReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(searchResp));

输出显示了与查询向量最近的5个邻居,包括它们的唯一id和计算的距离:

{
  "searchResults": [
    [
      {
        "entity": {
        },
        "distance": 1.249139,
        "id": 1210
      },
      {
        "entity": {
        },
        "distance": 1.2234075,
        "id": 754
      },
      {
        "entity": {
        },
        "distance": 1.2215898,
        "id": 459
      },
      {
        "entity": {
        },
        "distance": 1.1920079,
        "id": 1878
      },
      {
        "entity": {
        },
        "distance": 1.1418623,
        "id": 1694
      }
    ]
  ]
}
批向量搜索

批量向量搜索扩展了单向量搜索的概念,允许在单个请求中搜索多个查询向量。这种类型的搜索非常适合于需要为一组查询向量找到相似向量的场景,大大减少了所需的时间和计算资源。

在批量向量搜索中,可以在data​字段中包含多个查询向量。系统并行处理这些向量,为每个查询向量返回一个单独的结果集,每个集合包含在集合中找到的最接近的匹配项。

下面是一个从两个查询向量中搜索两个不同的最相似实体集合的例子:

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<List<Float>> queryVectors = Arrays.asList(
                Arrays.asList(0.3580376395471989f, -0.6023495712049978f, 0.18414012509913835f, -0.26286205330961354f, 0.9029438446296592f),
                Arrays.asList(0.19886812562848388f, 0.06023560599112088f, 0.6976963061752597f, 0.2614474506242501f, 0.838729485096104f)
        );
        SearchReq searchReq = SearchReq.builder()
                .collectionName(MY_COLLECTION)
                .data(queryVectors)
                .topK(2)
                .build();
        SearchResp searchResp = client.search(searchReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(searchResp));

结果包括两组最近邻,每个查询向量一组:

{
  "searchResults": [
    [
      {
        "entity": {
        },
        "distance": 1.249139,
        "id": 1210
      },
      {
        "entity": {
        },
        "distance": 1.2234075,
        "id": 754
      }
    ],
    [
      {
        "entity": {
        },
        "distance": 1.8722607,
        "id": 1334
      },
      {
        "entity": {
        },
        "distance": 1.8432289,
        "id": 202
      }
    ]
  ]
}
分区搜索

分区搜索将搜索范围缩小到集合的特定子集或分区。对于有组织的数据集特别有用,其中数据被分割成逻辑或类别划分,通过减少需要扫描的数据量来加快搜索操作。

要进行分区搜索,只需在搜索请求的partition_names​中包含目标分区的名称。指定搜索操作只考虑指定分区内的向量。

下面是一个搜索red​分区实体的例子:

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<List<Float>> queryVectors = Arrays.asList(Arrays.asList(0.3580376395471989f, -0.6023495712049978f, 0.18414012509913835f, -0.26286205330961354f, 0.9029438446296592f));
        SearchReq searchReq = SearchReq.builder()
                .collectionName(MY_COLLECTION)
                .data(queryVectors)
                .partitionNames(Arrays.asList("red"))
                .topK(3)
                .build();
        SearchResp searchResp = client.search(searchReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(searchResp));

red​分区的数据与blue​分区的数据不同,搜索结果将被限制在指定的分区内:

{
  "searchResults": [
    [
      {
        "entity": {
        },
        "distance": 1.249139,
        "id": 1210
      },
      {
        "entity": {
        },
        "distance": 1.0770562,
        "id": 1390
      },
      {
        "entity": {
        },
        "distance": 1.0544909,
        "id": 1449
      }
    ]
  ]
}
选择输出字段

查询时允许指定匹配向量的哪些属性或字段应该包含在搜索结果中。可以在请求中指定outputFields​,以返回带有特定字段的结果。

下面是选择返回color​和color_tag​属性值的示例:

        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<List<Float>> queryVectors = Arrays.asList(Arrays.asList(0.3580376395471989f, -0.6023495712049978f, 0.18414012509913835f, -0.26286205330961354f, 0.9029438446296592f));
        SearchReq searchReq = SearchReq.builder()
                .collectionName(MY_COLLECTION)
                .data(queryVectors)
                .outputFields(Arrays.asList("color", "color_tag"))
                .topK(3)
                .build();
        SearchResp searchResp = client.search(searchReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(searchResp));

搜索结果将包括指定的color​和color_tag​字段:

{
  "searchResults": [
    [
      {
        "entity": {
          "color": "read",
          "color_tag": "read_4508"
        },
        "distance": 1.249139,
        "id": 1210
      },
      {
        "entity": {
          "color_tag": "brown_7159"
        },
        "distance": 1.2234075,
        "id": 754
      },
      {
        "entity": {
          "color_tag": "pink_7698"
        },
        "distance": 1.2215898,
        "id": 459
      }
    ]
  ]
}

向量模型

本文选用阿里通义的通用文本向量模型,它是通义实验室基于LLM底座的多语言文本统一向量模型,面向全球多个主流语种,提供高水准的向量服务,帮助开发者将文本数据快速转换为高质量的向量数据。

使用通义的text-embedding​模型生成的向量维度是1536​维。

前提条件

  • 已开通服务并获得API-KEY:开通DashScope并创建API-KEY。

    https://help.aliyun.com/zh/dashscope/developer-reference/activate-dashscope-and-create-an-api-key

安装DashScope SDK

截止当前(2024-06-07),最新本版本为2.14.7​,为了尽量保持SDK和服务同步,尽量使用最新版本的SDK。

查看版本信息:https://mvnrepository.com/artifact/com.alibaba/dashscope-sdk-java

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>dashscope-sdk-java</artifactId>
            <version>2.14.7</version>
        </dependency>

这里能在maven仓库看到2.14.7​ 版本,但是我怎么拉都拉不下来,所以我换成了2.14.0​ 了。

如果在启动的时候有日志相关的报错信息,可以通过以下方式解决:

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>dashscope-sdk-java</artifactId>
            <version>2.14.7</version>
            <exclusions>
                <exclusion>
                    <groupId>ch.qos.logback</groupId>
                    <artifactId>logback-classic</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.slf4j</groupId>
                    <artifactId>slf4j-simple</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

Embedding模型api

Embedding模型的api的详细参数说明参见官方文档:
https://help.aliyun.com/zh/dashscope/developer-reference/text-embedding-api-details

同步调用

        List<String> texts = Arrays.asList("人生像攀登一座山,而找寻出路,却是一种学习的过程,我们应当在这个过程中,学习稳定、冷静,学习如何从慌乱中找到生机。");
        TextEmbeddingParam param = TextEmbeddingParam
                .builder()
                .apiKey(API_KEY)
                .model(TextEmbedding.Models.TEXT_EMBEDDING_V2)
                .texts(texts)
                .build();
        TextEmbedding textEmbedding = new TextEmbedding();
        TextEmbeddingResult result = textEmbedding.call(param);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(result));

调用的输出结果下:

{
  "requestId": "000b4bbb-e25e-99d3-b5f6-bf6bb715b15c",
  "output": {
    "embeddings": [
      {
        "textIndex": 0,
        "embedding": [
          0.0023970590920287245,
          ...
        ]
      }
    ]
  },
  "usage": {
    "totalTokens": 19
  }
}

异步调用

        List<String> texts = Arrays.asList("人生像攀登一座山,而找寻出路,却是一种学习的过程,我们应当在这个过程中,学习稳定、冷静,学习如何从慌乱中找到生机。");
        TextEmbeddingParam param = TextEmbeddingParam
                .builder()
                .apiKey(API_KEY)
                .model(TextEmbedding.Models.TEXT_EMBEDDING_V2)
                .texts(texts)
                .build();
        TextEmbedding textEmbedding = new TextEmbedding();
        Semaphore sem = new Semaphore(0);
        textEmbedding.call(param, new ResultCallback<>() {
            @Override
            public void onEvent(TextEmbeddingResult result) {
                log.info("dimension = {}", result.getOutput().getEmbeddings().get(0).getEmbedding().size());
                log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(result));
            }

            @Override
            public void onComplete() {
                sem.release();
            }

            @Override
            public void onError(Exception err) {
                log.info("", err);
                sem.release();
            }

        });
        sem.acquire();

异步调用的输出结果和同步调用的结果结构相同,不再展示。

综合案例

结合Embedding和Milvus一起使用的案例。

准备数据

准备数据用用于后续的查询。

    private final static String TEXT_1 = "人生像攀登一座山,而找寻出路,却是一种学习的过程,我们应当在这个过程中,学习稳定、冷静,学习如何从慌乱中找到生机。";
    private final static String TEXT_2 = "人生就像登山,探索路径是一种不断学习的过程,我们在这个过程中需要学会保持内心的平静和冷静,以及如何在混乱中寻找生存之道。";
    private final static String TEXT_3 = "在人生的旅途中,就像攀登一座山峰,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学习保持稳定和冷静,以及在混乱中寻找生存的技巧。";
    private final static String TEXT_4 = "人生如山行,寻找前进的道路是一个持续学习的过程。在这个过程中,我们需要学会如何保持内心的稳定和冷静,以及在混乱中找到生存的方法。";
    private final static String TEXT_5 = "人生就像在攀登一座山峰,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学习如何保持内心的稳定和冷静,以及在混乱中找到生存的技巧。";
    private final static String TEXT_6 = "人生就像登山,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学会如何保持内心的稳定和冷静,以及在混乱中找到生存的方法。";
    private final static List<String> TEXTS = List.of(TEXT_1, TEXT_2, TEXT_3, TEXT_4, TEXT_5, TEXT_6);

    @SneakyThrows
    @Test
    public void syncCall() {
        TextEmbeddingParam param = TextEmbeddingParam
                .builder()
                .apiKey(API_KEY)
                .model(TextEmbedding.Models.TEXT_EMBEDDING_V2)
                .texts(List.of(TEXT_1))
                .build();
        TextEmbedding textEmbedding = new TextEmbedding();
        TextEmbeddingResult result = textEmbedding.call(param);
        List<Double> embedding = result.getOutput().getEmbeddings().get(0).getEmbedding();
        log.info("dimension = {}", embedding.size());
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(result));
    }

    @Test
    public void prepareData() {
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        // 删除原有的集合
        DropCollectionReq dropCollectionRequest = DropCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        client.dropCollection(dropCollectionRequest);
        // 创建集合
        CreateCollectionReq quickSetupReq = CreateCollectionReq.builder()
                .collectionName(MY_COLLECTION)
                .dimension(1536)
                .metricType("IP")
                .build();
        client.createCollection(quickSetupReq);
        GetLoadStateReq loadStateReq = GetLoadStateReq.builder()
                .collectionName(MY_COLLECTION)
                .build();
        boolean loadState = client.getLoadState(loadStateReq);
        log.info("{} loadState = {}", MY_COLLECTION, loadState);

        List<JSONObject> data = new ArrayList<>();
        for (int i = 0; i < TEXTS.size(); i++) {
            String text = TEXTS.get(i);
            // Milvus默认是float
            List<Float> embedding = getEmbedding(List.of(text));
            JSONObject jsonObject = new JSONObject();
            jsonObject.put("id", (long) i); // Milvus默认是long
            jsonObject.put("vector", embedding);
            jsonObject.put("text", text);
            data.add(jsonObject);
        }
        InsertReq insertReq = InsertReq.builder()
                .collectionName(MY_COLLECTION)
                .data(data)
                .build();
        InsertResp insertResp = client.insert(insertReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(insertResp));
    }

确认数据库中是否已经存在上面插入的数据:

    @Test
    public void query() {
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        QueryReq queryReq = QueryReq.builder()
                .collectionName(MY_COLLECTION)
                .filter("id >= 0")
                .outputFields(Arrays.asList("text"))
                .limit(9)
                .build();
        QueryResp queryResp = client.query(queryReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(queryResp));
    }

输出结果显示数据库中存在6条数据:

{
  "queryResults": [
    {
      "entity": {
        "text": "人生像攀登一座山,而找寻出路,却是一种学习的过程,我们应当在这个过程中,学习稳定、冷静,学习如何从慌乱中找到生机。",
        "id": 0
      }
    },
    {
      "entity": {
        "text": "人生就像登山,探索路径是一种不断学习的过程,我们在这个过程中需要学会保持内心的平静和冷静,以及如何在混乱中寻找生存之道。",
        "id": 1
      }
    },
    {
      "entity": {
        "text": "在人生的旅途中,就像攀登一座山峰,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学习保持稳定和冷静,以及在混乱中寻找生存的技巧。",
        "id": 2
      }
    },
    {
      "entity": {
        "text": "人生如山行,寻找前进的道路是一个持续学习的过程。在这个过程中,我们需要学会如何保持内心的稳定和冷静,以及在混乱中找到生存的方法。",
        "id": 3
      }
    },
    {
      "entity": {
        "text": "人生就像在攀登一座山峰,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学习如何保持内心的稳定和冷静,以及在混乱中找到生存的技巧。",
        "id": 4
      }
    },
    {
      "entity": {
        "text": "人生就像登山,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学会如何保持内心的稳定和冷静,以及在混乱中找到生存的方法。",
        "id": 5
      }
    }
  ]
}

向量查询

查询存在的数据

使用人活着就要不断学习。​查询,这是对上面准备的数据的一种更简洁的表达,虽然文字是完全不一样的,但是表达的意思大致相同。

    @Test
    public void search() {
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<List<Float>> queryVectors = List.of(getEmbedding(List.of("人活着就要不断学习。")));
        SearchReq searchReq = SearchReq.builder()
                .collectionName(MY_COLLECTION)
                .data(queryVectors)
                .outputFields(Arrays.asList("text"))
                .searchParams(Map.of("radius", 0.5))
                .topK(5)
                .build();
        SearchResp searchResp = client.search(searchReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(searchResp));
    }
  • radius​是Milvus向量搜索API中的一个参数,用于指定查询向量与候选向量之间相似度的最小值。

结果输出:

{
  "searchResults": [
    [
      {
        "entity": {
          "text": "人生就像在攀登一座山峰,寻找前进的道路是一个不断学习的过程。在这个过程中,我们需要学习如何保持内心的稳定和冷静,以及在混乱中找到生存的技巧。"
        },
        "distance": 0.51644874,
        "id": 4
      },
      {
        "entity": {
          "text": "人生像攀登一座山,而找寻出路,却是一种学习的过程,我们应当在这个过程中,学习稳定、冷静,学习如何从慌乱中找到生机。"
        },
        "distance": 0.5073142,
        "id": 0
      }
    ]
  ]
}

查询不存在的数据

使用人活着就要不学习。​查询,这是对上面准备的数据的一种相反的表达,虽然文字只少了一个​字,但是表达的意思完全不同。

    @Test
    public void search() {
        ConnectConfig connectConfig = ConnectConfig.builder()
                .uri(MILVUS_ENDPOINT)
                .dbName(MY_DATABASE)
                .build();
        MilvusClientV2 client = new MilvusClientV2(connectConfig);
        List<List<Float>> queryVectors = List.of(getEmbedding(List.of("人活着就要不学习。")));
        SearchReq searchReq = SearchReq.builder()
                .collectionName(MY_COLLECTION)
                .data(queryVectors)
                .outputFields(Arrays.asList("text"))
                .searchParams(Map.of("radius", 0.5))
                .topK(5)
                .build();
        SearchResp searchResp = client.search(searchReq);
        log.info("{}", JSONUtil.toJsonPrettyStrUseDefaultConfig(searchResp));
    }
}

由于这次搜索的文字的含义跟数据库中的相似度不够接近,所以查询不到任何数据。

结果输出:

{
  "searchResults": [
    [
    ]
  ]
}

参考资料

  1. Milvus Team. (n.d.). Milvus documentation. Milvus.io. Retrieved June 6, 2024, from https://milvus.io/docs
  2. Aliyun. (n.d.). Text Embedding API Details. Alibaba Cloud Help. Retrieved June 6, 2024, from https://help.aliyun.com/zh/dashscope/developer-reference/text-embedding-api-details

  • 15
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Milvus 是一个开源的向量相似度搜索引擎,而Spring Boot 是一个用于构建基于 Java 的独立、生产级的应用程序的框架。 Milvus Spring Boot 是将 Milvus 与 Spring Boot 框架结合使用的一种方式。借助 Spring Boot,我们可以更方便地构建基于 Milvus 的应用程序。 首先,我们可以使用 Spring Boot 的依赖管理功能,将 MilvusJava 客户端库添加到项目。这样,我们就可以在我们的应用程序直接使用 Milvus 的功能,如向量的插入、查询和删除等。 其次,Spring Boot 提供了强大的配置管理功能,我们可以轻松地将 Milvus 的连接配置信息添加到应用程序的配置文件,例如指定 Milvus 的 IP 地址、端口号和连接池大小等。这样,我们就可以灵活地管理 Milvus 与其他组件的连接。 另外,Spring Boot 还提供了便捷的 RESTful API 开发功能。我们可以利用这一特性,将 Milvus 的搜索引擎功能以接口的形式暴露给客户端,使得客户端可以通过 HTTP 请求来进行向量的检索。这样,我们可以轻松地建立一个灵活、高性能的分布式向量搜索系统。 总的来说,Milvus Spring Boot 结合了 Milvus 的强大功能和 Spring Boot 的便捷开发特性,使得我们可以更快速、灵活地搭建起一个高性能的向量搜索应用程序。它在大数据、人工智能等领域有广泛的应用前景,可以应对各种复杂的向量查询需求。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值