初学向量数据据库Milvus

7 阅读5分钟

最近做项目用到了RAG,本来学习SpringAi的时候学习向量数据库使用的是Redis Stack,学的迷迷糊糊的,最近想尝试写一些博客,打算再次学习一下向量数据库,然后了解到了Milvus,它作为原生分布式向量数据库,在海量数据管理、索引丰富度上更专业,因此趁此学习一下。

Milvus是一个专门用来存储、管理和快速检索向量的数据库,它是大模型RAG项目中不可或缺的“外挂记忆库”。

1.尝试初次连接Milvus

public class MilvusTest {
    public static void main(String[] args) {
        System.out.println("开始配置Milvus");
        MilvusServiceClient client = new MilvusServiceClient(ConnectParam.newBuilder().withHost("localhost").withPort(19530).build());
        System.out.println("已连接上向量数据库Milvus");
        client.close();
        System.out.println("连接已安全关闭");
    }
}

第一次连接的时候发生了报错,Gemini告诉我说是内存不足,后台占用的内存太多了,Milvus默认占用的内存也不少,所以直接崩掉了。因此如果在本地跑 Docker 测试,建议给容器分配足够的内存,或者对于轻量级测试,可以考虑使用官方的 Milvus Lite 版本来降低本地机器的资源消耗。

2.建表

在Milvus中表被称为Collection(集合)

在MySQL里面建表,我们要写 CREATE TABLE,定义id(主键)、content(内容)。

在Milvus中一样,但是必须多加一个特殊字段vector,用来专门存放那串代表语义的“浮点数数组”,我们可以把它想象成给每一段文字配备了一个极其精确的“数字条形码”。

核心原理

建立向量表的三个核心要素:

  1. Schema(表结构):规定有哪些列
  2. Dimension(维度):向量字段必须指定长度。比如你的Ai模型给的字符串是768个数字组成的,那这个字段的维度就是768。这是向量数据库独有的概念
  3. Primary Key(主键):跟MySQL一样,必须得有一个唯一标识数据的列。

怎么用

我们在这里不写SQL语句,直接使用Spring的Builder模式来直接构建对象,我们先定义每一个字段,然后一并发送给Milvus去创建。最常见的RAG表结构包括三列:id、content、vector。

创建第一个表

public class MilvusTest {
    public static void main(String[] args) {
        MilvusServiceClient client = new MilvusServiceClient(ConnectParam.newBuilder().withHost("localhost").withPort(19530).build());
        String collectionName = "rag_demo";
        System.out.println("开始建表...");
        //定义字段1:主键id(Long类型,开启自动生成)
        FieldType idField = FieldType.newBuilder()
                .withName("id")
                .withDataType(DataType.Int64)
                .withPrimaryKey(true)
                .withAutoID(true)
                .build();
        //定义字段2:文本内容(String类型,设置最大长度)
        FieldType contentField = FieldType.newBuilder()
                .withName("content")
                .withDataType(DataType.VarChar)
                .withMaxLength(2000)
                .build();

        //定义字段3:向量字段(核心)
        FieldType vectorField = FieldType.newBuilder()
                .withName("vector")
                .withDataType(DataType.FloatVector)
                .withDimension(768) // 维度
                .build();

        CreateCollectionParam createParam = CreateCollectionParam.newBuilder()
                .withCollectionName(collectionName)
                .withDescription("我的第一个RAG向量表")
                .addFieldType(idField)
                .addFieldType(contentField)
                .addFieldType(vectorField)
                .build();
        client.createCollection(createParam);
        System.out.println("创建成功。表名:"+collectionName);
        client.close();
        System.out.println("连接已安全关闭");
    }
}

3.插入与检索

此处先不谈复杂的业务,仅使用简单的例子来帮助理解。

一句话总结

插入就是把"文字"和"数字数组(向量)"按列塞进表里;检索就是拿着提问的向量去算距离,找出最相近的原文。

通俗解释

现在手中有三个带着数字标号的盲盒:

  • 盲盒1:“苹果很甜”
  • 盲盒2:“小狗很可爱”
  • 盲盒3:“今天天气很好”

我现在说:“我想吃苹果”。系统把这句话变成向量,对比发现和盲盒1的向量最接近,就把“苹果很甜”返回给你。这就是向量检索。

核心原理

底层就是计算空间距离。就像在平面(二维空间)里计算两点之间的距离一样,只不过在这里有多个维度。距离越短(欧氏距离),代表两段话的语义越相似

在Milvus的Java SDK中,数据是按列组装的。不是一条一条的插,而是把所有的content组成一个List,把所有的Vector组成一个List,最后一起打包发送。我觉得这里是一个容易踩坑的地方,在之前学的关系型数据库ORM框架,比如Mybatis每次都是插入一个Entity对象,我们习惯了这样做,在此处是不一样的

MilvusServiceClient client = new MilvusServiceClient(ConnectParam.newBuilder().withHost("localhost").withPort(19530).build());
String collectionName = "rag_demo";
System.out.println("开始插入测试数据...");
List<String> contents = Arrays.asList(
        "苹果是一种红色的水果,吃起来很甜。",
        "金毛犬是人类的好朋友,非常忠诚。",
        "今天天气万里无云,非常适合出门爬山。"
);

// 模拟生成 3 个 768 维的向量
List<List<Float>> vectors = new ArrayList<>();
Random random = new Random();
for (int i = 0; i < 3; i++) {
    List<Float> vector = new ArrayList<>();
    for (int j = 0; j < 768; j++) {
        // 让“苹果”的特征值偏向 0.9
        vector.add(i == 0 ? 0.9f + random.nextFloat() * 0.1f : random.nextFloat() * 0.5f);
    }
    vectors.add(vector);
}

List<InsertParam.Field> fields = new ArrayList<>();
fields.add(new InsertParam.Field("content", contents));
fields.add(new InsertParam.Field("vector", vectors));

// 先把数据插进去!
client.insert(InsertParam.newBuilder()
        .withCollectionName(collectionName)
        .withFields(fields)
        .build());
System.out.println("数据插入完成!");
System.out.println("2. 开始创建索引 (加快搜索速度)...");
// 数据插完后,再根据数据去建立索引树
client.createIndex(CreateIndexParam.newBuilder()
        .withCollectionName(collectionName)
        .withFieldName("vector")
        .withIndexType(IndexType.HNSW)
        .withMetricType(MetricType.L2)
        .withExtraParam("{"M":16,"efConstruction":8}")
        .withSyncMode(Boolean.TRUE) // TRUE 代表强制等待索引建完再往下走,非常关键!
        .build());

System.out.println("3. 将带有数据的表加载到内存 (Load)...");
// 此时加载进内存的,就是已经包含数据和索引的“完全体”了
client.loadCollection(LoadCollectionParam.newBuilder()
        .withCollectionName(collectionName)
        .build());

System.out.println("用户提问:我想吃点甜甜的水果");

List<Float> questionVector = new ArrayList<>();
for (int j = 0; j < 768; j++) {
    questionVector.add(0.9f + random.nextFloat() * 0.1f);
}
//执行搜索 (Search)
SearchParam searchParam = SearchParam.newBuilder()
        .withCollectionName(collectionName)
        .withMetricType(io.milvus.param.MetricType.L2)
        .withOutFields(java.util.Arrays.asList("content"))
        .withTopK(1)
        .withVectors(java.util.Arrays.asList(questionVector))
        .withVectorFieldName("vector")
        .withParams("{"nprobe":10}")
        // 关键参数:设置一致性级别为强一致性,保证刚插的数据立刻能搜到
        .withConsistencyLevel(ConsistencyLevelEnum.STRONG)
        .build();
SearchResultsWrapper wrapper = new SearchResultsWrapper(client.search(searchParam).getData().getResults());
List<?> outContents = wrapper.getFieldData("content", 0);
System.out.println("检索结果:"+outContents.get(0));
client.close();
System.out.println("连接已安全关闭");

Milvus底层使用的是gRPC协议和Protobuf序列化进行通信,而不是普通的HTTP+JSON。为了保证网络传输速度足够快,Protobuf会把中文字符变成UTF-8的字节数组。因此如果我们如果直接打印Protobuf的响应体,打印出来的是字节而不是中文。因此我们可以使用Milvus的Java JDK中自带的工具 SearchResultsWrapper,只要把原始响应扔给它,告诉它你要拿哪个字段,它就会自动帮你把字节解码成完美的 Java String