最近做项目用到了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,用来专门存放那串代表语义的“浮点数数组”,我们可以把它想象成给每一段文字配备了一个极其精确的“数字条形码”。
核心原理
建立向量表的三个核心要素:
- Schema(表结构):规定有哪些列
- Dimension(维度):向量字段必须指定长度。比如你的Ai模型给的字符串是768个数字组成的,那这个字段的维度就是768。这是向量数据库独有的概念。
- 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,只要把原始响应扔给它,告诉它你要拿哪个字段,它就会自动帮你把字节解码成完美的 JavaString。