Java集成Milvus向量数据库完整教程------从Docker部署到生产级混合检索
向量数据库是RAG系统的核心基础设施。本文手把手带你用Java集成Milvus,覆盖Docker部署、LangChain4j配置、多知识库隔离、BM25混合检索,所有代码来自真实开源项目,可直接运行。
写在前面
上一篇 Spring Boot + LangChain4j实现RAG 我们搭建了完整的RAG系统,但向量存储用的是InMemoryEmbeddingStore------数据全在内存里,重启就没了。
生产环境必须用专业的向量数据库。 而Milvus是目前Java生态中最成熟的选择:
- 原生Java SDK,无需Python桥接
- 支持稠密向量(语义) + 稀疏向量(BM25关键词)混合检索
- 分布式架构,单机可跑,集群可扩展
- LangChain4j、Spring AI都有官方集成
读完你能得到什么:
- 5分钟Docker部署Milvus Standalone
- LangChain4j + Milvus一行配置完成集成
- 多知识库隔离方案(metadata过滤)
- Milvus 2.5+ BM25混合检索实战
- 本地开发用InMemory,生产用Milvus的无缝切换方案
前置要求:Java 17+、Docker、Spring Boot 3.x
一、为什么选Milvus?
1.1 向量数据库选型对比
| 向量库 | Java SDK | 混合检索 | 部署复杂度 | 适用场景 |
|---|---|---|---|---|
| Milvus | 原生SDK | BM25 + 稠密向量 | 中(Docker Compose) | 生产环境首选 |
| PGVector | JDBC即可 | 无原生BM25 | 低(已有PG即可) | 已有PostgreSQL |
| Redis Vector | Jedis/Lettuce | 无 | 低 | 低延迟缓存场景 |
| InMemory | LangChain4j内置 | 无 | 零 | 本地开发测试 |
选Milvus的4个理由:
- 混合检索是刚需:纯向量检索在专有名词、缩写、数字上效果差,必须结合BM25关键词检索
- Java原生SDK :
io.milvus:milvus-sdk-java,不依赖Python - LangChain4j官方集成 :
langchain4j-milvus,一行配置搞定 - 分布式架构:计算和存储分离,支持水平扩展
1.2 架构总览
scss
┌──────────────────────────────────────────────────────────┐
│ 应用层(Spring Boot) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │ RagService │ │ VectorStore │ │ HybridSearch │ │
│ │ (RAG问答) │ │ Service │ │ Service │ │
│ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ LangChain4j EmbeddingStore 接口 │ │
│ │ ┌─────────────────────┐ ┌────────────────────────┐ │ │
│ │ │ MilvusEmbeddingStore│ │ InMemoryEmbeddingStore │ │ │
│ │ │ (生产环境) │ │ (本地开发) │ │ │
│ │ └──────────┬──────────┘ └────────────────────────┘ │ │
│ └─────────────┼─────────────────────────────────────────┘ │
│ │ │
├────────────────┼─────────────────────────────────────────────┤
│ ▼ 基础设施层 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Milvus 2.5+ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 稠密向量 │ │ 稀疏向量 │ │ 元数据过滤 │ │ │
│ │ │ (语义) │ │ (BM25) │ │ (knowledgeBaseId) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ etcd │ │ MinIO │ │ PostgreSQL│ │
│ │ (元数据) │ │ (对象存储)│ │ (知识库元数据)│ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────────────────┘
二、Step 1:Docker部署Milvus(5分钟)
2.1 Docker Compose配置
yaml
# docker-compose.yml
version: '3.8'
services:
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
volumes:
- etcd_data:/etcd
command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379
minio:
image: minio/minio:RELEASE.2023-03-20T20-16-18Z
environment:
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
volumes:
- minio_data:/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
milvus:
image: milvusdb/milvus:v2.5.9
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
volumes:
- milvus_data:/var/lib/milvus
ports:
- "19530:19530"
- "9091:9091"
depends_on:
- etcd
- minio
volumes:
etcd_data:
minio_data:
milvus_data:
2.2 启动与验证
bash
# 启动
docker-compose up -d milvus
# 验证端口
docker ps | grep milvus
# 输出:milvusdb/milvus:v2.5.9 ... 0.0.0.0:19530->19530/tcp
# 查看日志
docker logs milvus --tail 20
# 看到 "Milvus started successfully" 即成功
关键端口:
19530:gRPC端口(Java SDK连接用)9091:健康检查/metrics端口
三、Step 2:Maven依赖配置
xml
<!-- pom.xml -->
<properties>
<langchain4j.version>0.35.0</langchain4j.version>
<milvus-sdk.version>2.5.5</milvus-sdk.version>
</properties>
<dependencies>
<!-- LangChain4j Milvus集成 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-milvus</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<!-- Milvus原生Java SDK(BM25混合检索需要) -->
<dependency>
<groupId>io.milvus</groupId>
<artifactId>milvus-sdk-java</artifactId>
<version>${milvus-sdk.version}</version>
</dependency>
<!-- LangChain4j核心 + OpenAI兼容 -->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>
两个依赖的区别:
langchain4j-milvus:高层封装,提供MilvusEmbeddingStore,与LangChain4j的EmbeddingStore接口无缝对接milvus-sdk-java:Milvus原生SDK,直接操作Collection、Index、Search,BM25混合检索必须用这个
四、Step 3:LangChain4j配置Milvus
4.1 配置文件
yaml
# application.yml
llm:
# Embedding模型配置
embedding:
model: ${EMBEDDING_MODEL:text-embedding-v3}
dimensions: ${EMBEDDING_DIMENSIONS:1024}
# Milvus连接配置
milvus:
host: ${MILVUS_HOST:localhost}
port: ${MILVUS_PORT:19530}
collection-name: ${MILVUS_COLLECTION:llm_knowledge_base}
# 混合检索开关
rag:
hybrid-search:
enabled: true
collection-name: ${MILVUS_HYBRID_COLLECTION:llm_knowledge_base_hybrid}
dense-weight: 0.6 # 语义向量权重
sparse-weight: 0.4 # BM25关键词权重
# 本地开发开关:true=用InMemory,false=用Milvus
use-in-memory-embedding: false
4.2 配置类(核心)
java
@Configuration
public class LangChain4jConfig {
@Value("${llm.milvus.host:localhost}")
private String milvusHost;
@Value("${llm.milvus.port:19530}")
private int milvusPort;
@Value("${llm.milvus.collection-name:llm_knowledge_base}")
private String milvusCollectionName;
@Value("${llm.embedding.dimensions:1024}")
private int embeddingDimensions;
@Value("${llm.use-in-memory-embedding:false}")
private boolean useInMemoryEmbedding;
/**
* 向量存储Bean:生产用Milvus,开发用InMemory
* 通过一个开关切换,无需改代码
*/
@Bean
public EmbeddingStore<TextSegment> embeddingStore() {
if (useInMemoryEmbedding) {
log.info("使用 InMemoryEmbeddingStore(本地开发模式)");
return new InMemoryEmbeddingStore<>();
}
log.info("使用 MilvusEmbeddingStore: {}:{}", milvusHost, milvusPort);
return MilvusEmbeddingStore.builder()
.host(milvusHost)
.port(milvusPort)
.collectionName(milvusCollectionName)
.dimension(embeddingDimensions) // 必须和Embedding模型维度一致
.metricType(MetricType.COSINE) // 余弦相似度
.autoFlushOnInsert(true) // 插入后自动刷盘
.build();
}
/**
* Milvus原生SDK客户端(BM25混合检索用)
* 仅在开启混合检索时创建
*/
@Bean
@ConditionalOnProperty(name = "llm.rag.hybrid-search.enabled", havingValue = "true")
public MilvusServiceClient milvusServiceClient() {
if (useInMemoryEmbedding) {
return null;
}
return new MilvusServiceClient(
ConnectParam.newBuilder()
.withHost(milvusHost)
.withPort(milvusPort)
.build()
);
}
}
关键配置说明:
| 参数 | 说明 | 注意事项 |
|---|---|---|
dimension |
向量维度 | 必须和Embedding Model一致(通义千问v3=1024,OpenAI=1536) |
metricType |
相似度算法 | COSINE(余弦)最通用,也可选L2(欧氏距离)、IP(内积) |
autoFlushOnInsert |
自动刷盘 | 生产环境建议true,避免数据丢失 |
useInMemoryEmbedding |
开发/生产切换 | 本地开发=不需要装Docker就能跑 |
五、Step 4:向量存储服务(VectorStoreService)
这是操作Milvus的核心服务,封装了写入、检索、删除。
java
@Slf4j
@Service
public class VectorStoreService {
private static final String KB_ID_FIELD = "knowledgeBaseId";
private final EmbeddingStore<TextSegment> embeddingStore;
@Autowired(required = false)
private HybridSearchService hybridSearchService;
public VectorStoreService(EmbeddingStore<TextSegment> embeddingStore) {
this.embeddingStore = embeddingStore;
}
/**
* 批量写入向量(文档入库时调用)
* 为每个chunk写入knowledgeBaseId元数据,实现多知识库隔离
*/
public void addAll(String knowledgeBaseId,
List<TextSegment> chunks,
List<Embedding> embeddings) {
if (chunks.size() != embeddings.size()) {
throw new IllegalArgumentException("chunks和embeddings数量不一致");
}
for (int i = 0; i < chunks.size(); i++) {
TextSegment segment = chunks.get(i);
// 核心:写入knowledgeBaseId元数据
segment.metadata().put(KB_ID_FIELD, knowledgeBaseId);
embeddingStore.add(embeddings.get(i), segment);
}
log.info("向量写入完成: kbId={}, count={}", knowledgeBaseId, chunks.size());
// 双写:如果启用了混合检索,同步写入HybridSearchService
if (hybridSearchService != null) {
List<String> texts = chunks.stream()
.map(TextSegment::text)
.collect(Collectors.toList());
List<List<Float>> vectors = embeddings.stream()
.map(e -> toFloatList(e.vector()))
.collect(Collectors.toList());
hybridSearchService.insert(knowledgeBaseId, texts, vectors);
}
}
/**
* 向量相似度检索(RAG查询时调用)
* 通过knowledgeBaseId过滤,minScore=0.6过滤低质量结果
*/
public List<DocumentMatch> search(String knowledgeBaseId,
Embedding queryEmbedding,
int maxResults) {
// 构建知识库过滤条件
Filter kbFilter = new IsEqualTo(KB_ID_FIELD, knowledgeBaseId);
return embeddingStore.search(
EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(maxResults)
.minScore(0.6) // 相似度阈值
.filter(kbFilter) // 只搜指定知识库
.build())
.matches().stream()
.map(m -> DocumentMatch.builder()
.content(m.embedded().text())
.score(m.score())
.build())
.collect(Collectors.toList());
}
/**
* 混合检索:语义 + BM25关键词
* 如果HybridSearchService不可用,自动回退到纯向量检索
*/
public List<DocumentMatch> hybridSearch(String knowledgeBaseId,
String queryText,
Embedding queryEmbedding,
int maxResults) {
if (hybridSearchService == null) {
// 自动回退
return search(knowledgeBaseId, queryEmbedding, maxResults);
}
return hybridSearchService.hybridSearch(
knowledgeBaseId, queryText,
toFloatList(queryEmbedding.vector()),
maxResults, 0.6);
}
/**
* 按知识库删除全部向量
*/
public void deleteCollection(String knowledgeBaseId) {
Filter kbFilter = new IsEqualTo(KB_ID_FIELD, knowledgeBaseId);
embeddingStore.removeAll(kbFilter);
if (hybridSearchService != null) {
hybridSearchService.deleteByKnowledgeBase(knowledgeBaseId);
}
log.info("向量集合已删除: kbId={}", knowledgeBaseId);
}
private List<Float> toFloatList(float[] vector) {
List<Float> list = new ArrayList<>(vector.length);
for (float v : vector) {
list.add(v);
}
return list;
}
}
5.1 多知识库隔离原理
css
Milvus Collection中每条数据都带metadata:
┌──────────────────────────────────────────────────┐
│ id │ vector(1024维) │ metadata │
├──────────────────────────────────────────────────┤
│ 1 │ [0.12, -0.45...] │ {knowledgeBaseId: "kb-hr"} │
│ 2 │ [0.33, 0.67...] │ {knowledgeBaseId: "kb-hr"} │
│ 3 │ [-0.21, 0.89...] │ {knowledgeBaseId: "kb-tech"} │
│ 4 │ [0.55, -0.12...] │ {knowledgeBaseId: "kb-product"} │
└──────────────────────────────────────────────────┘
查询 "kb-hr" 时:
Filter: knowledgeBaseId == "kb-hr"
→ 只返回 id=1, id=2 的数据
→ kb-tech 和 kb-product 的数据完全不可见
不需要为每个知识库创建单独的Collection,一个Collection + metadata过滤就能实现完美隔离。这是生产环境的最佳实践。
六、Step 5:BM25混合检索(HybridSearchService)
纯向量检索的问题:专有名词、缩写、数字等语义不明显的词,检索效果差。
例如:用户问"API-2024-001",纯向量检索可能找不到,因为"API-2024-001"在向量空间中和其他编号太接近。但BM25关键词检索可以精确匹配。
6.1 混合检索服务
java
@Slf4j
@Service
@ConditionalOnProperty(name = "llm.rag.hybrid-search.enabled", havingValue = "true")
public class HybridSearchService {
private static final String DENSE_VECTOR_FIELD = "dense_vector";
private static final String SPARSE_VECTOR_FIELD = "sparse_vector";
private static final String TEXT_FIELD = "text";
private static final String KB_ID_FIELD = "knowledge_base_id";
private final MilvusServiceClient milvusClient;
private final String collectionName;
public HybridSearchService(MilvusServiceClient milvusClient,
@Value("${llm.rag.hybrid-search.collection-name}") String collectionName) {
this.milvusClient = milvusClient;
this.collectionName = collectionName;
initCollection();
}
/**
* 初始化Collection:创建稠密向量 + 稀疏向量 + 元数据字段
*/
private void initCollection() {
// 检查Collection是否存在
R<Boolean> hasCollection = milvusClient.hasCollection(
HasCollectionParam.newBuilder()
.withCollectionName(collectionName)
.build());
if (hasCollection.getData()) {
log.info("Collection已存在: {}", collectionName);
return;
}
// 定义字段
List<FieldType> fields = new ArrayList<>();
// 主键
fields.add(FieldType.newBuilder()
.withName("id")
.withDataType(DataType.Int64)
.withPrimaryKey(true)
.withAutoID(true)
.build());
// 文本内容
fields.add(FieldType.newBuilder()
.withName(TEXT_FIELD)
.withDataType(DataType.VarChar)
.withMaxLength(65535)
.build());
// 知识库ID(元数据过滤)
fields.add(FieldType.newBuilder()
.withName(KB_ID_FIELD)
.withDataType(DataType.VarChar)
.withMaxLength(128)
.build());
// 稠密向量(语义)
fields.add(FieldType.newBuilder()
.withName(DENSE_VECTOR_FIELD)
.withDataType(DataType.FloatVector)
.withDimension(1024)
.build());
// 稀疏向量(BM25关键词)
fields.add(FieldType.newBuilder()
.withName(SPARSE_VECTOR_FIELD)
.withDataType(DataType.SparseFloatVector)
.build());
// 创建Collection
milvusClient.createCollection(
CreateCollectionParam.newBuilder()
.withCollectionName(collectionName)
.withFieldTypes(fields)
.withEnableDynamicField(true)
.build());
// 创建稠密向量索引
milvusClient.createIndex(
CreateIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName(DENSE_VECTOR_FIELD)
.withIndexType(IndexType.IVF_FLAT)
.withMetricType(MetricType.COSINE)
.withExtraParam("{\"nlist\":128}")
.build());
// 创建稀疏向量索引(BM25)
milvusClient.createIndex(
CreateIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName(SPARSE_VECTOR_FIELD)
.withIndexType(IndexType.SPARSE_INVERTED_INDEX)
.withMetricType(MetricType.IP)
.build());
// 加载Collection到内存
milvusClient.loadCollection(
LoadCollectionParam.newBuilder()
.withCollectionName(collectionName)
.build());
log.info("Collection创建完成: {}", collectionName);
}
/**
* 批量插入文档(稠密向量 + BM25稀疏向量)
* Milvus 2.5+ 自动根据文本生成BM25稀疏向量
*/
public void insert(String knowledgeBaseId,
List<String> texts,
List<List<Float>> denseVectors) {
List<InsertParam.Field> fields = new ArrayList<>();
// 文本内容
fields.add(new InsertParam.Field(TEXT_FIELD, texts));
// 知识库ID
List<String> kbIds = texts.stream()
.map(t -> knowledgeBaseId)
.collect(Collectors.toList());
fields.add(new InsertParam.Field(KB_ID_FIELD, kbIds));
// 稠密向量
fields.add(new InsertParam.Field(DENSE_VECTOR_FIELD, denseVectors));
// 稀疏向量:Milvus会根据TEXT_FIELD自动生成BM25向量
// 只需要传空列表,让Milvus的function-based索引自动填充
milvusClient.insert(
InsertParam.newBuilder()
.withCollectionName(collectionName)
.withFields(fields)
.build());
// 刷盘
milvusClient.flush(
FlushParam.newBuilder()
.withCollectionNames(Collections.singletonList(collectionName))
.build());
log.info("混合检索数据写入完成: kbId={}, count={}", knowledgeBaseId, texts.size());
}
/**
* 混合检索:稠密向量语义搜索 + BM25关键词搜索
* 加权融合两种结果
*/
public List<DocumentMatch> hybridSearch(String knowledgeBaseId,
String queryText,
List<Float> denseVector,
int maxResults,
double minScore) {
String filterExpr = String.format("%s == \"%s\"", KB_ID_FIELD, knowledgeBaseId);
// ① 稠密向量检索(语义相似度)
SearchParam denseParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withVectorFieldName(DENSE_VECTOR_FIELD)
.withVectors(Collections.singletonList(denseVector))
.withTopK(maxResults * 2)
.withExpr(filterExpr)
.withConsistencyLevel(ConsistencyLevelEnum.STRONG)
.withOutFields(Collections.singletonList(TEXT_FIELD))
.withMetricType(MetricType.COSINE)
.build();
R<SearchResults> denseResult = milvusClient.search(denseParam);
// ② 稀疏向量检索(BM25关键词)
// 构造查询文本的稀疏向量
List<SortedMap<Integer, Float>> sparseVector = encodeQueryToSparse(queryText);
SearchParam sparseParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withVectorFieldName(SPARSE_VECTOR_FIELD)
.withVectors(Collections.singletonList(sparseVector))
.withTopK(maxResults * 2)
.withExpr(filterExpr)
.withConsistencyLevel(ConsistencyLevelEnum.STRONG)
.withOutFields(Collections.singletonList(TEXT_FIELD))
.withMetricType(MetricType.IP)
.build();
R<SearchResults> sparseResult = milvusClient.search(sparseParam);
// ③ 加权融合:denseWeight * denseScore + sparseWeight * sparseScore
return mergeResults(denseResult.getData(), sparseResult.getData(),
maxResults, minScore);
}
/**
* 加权融合稠密和稀疏检索结果
*/
private List<DocumentMatch> mergeResults(SearchResults denseResults,
SearchResults sparseResults,
int maxResults, double minScore) {
Map<Long, Double> scoreMap = new HashMap<>();
Map<Long, String> contentMap = new HashMap<>();
double denseWeight = 0.6;
double sparseWeight = 0.4;
// 融合稠密向量分数
for (int i = 0; i < denseResults.getResults().getFieldsDataCount(); i++) {
long id = denseResults.getResults().getIds().getIntId().getData(i);
double score = denseResults.getResults().getScores(i);
String text = (String) denseResults.getResults()
.getFieldsData(i).getScalars().getDataMap().get(TEXT_FIELD);
scoreMap.merge(id, denseWeight * score, Double::sum);
contentMap.putIfAbsent(id, text);
}
// 融合稀疏向量分数
for (int i = 0; i < sparseResults.getResults().getFieldsDataCount(); i++) {
long id = sparseResults.getResults().getIds().getIntId().getData(i);
double score = sparseResults.getResults().getScores(i);
String text = (String) sparseResults.getResults()
.getFieldsData(i).getScalars().getDataMap().get(TEXT_FIELD);
scoreMap.merge(id, sparseWeight * score, Double::sum);
contentMap.putIfAbsent(id, text);
}
// 按融合分数排序,过滤低分结果
return scoreMap.entrySet().stream()
.filter(e -> e.getValue() >= minScore)
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(maxResults)
.map(e -> DocumentMatch.builder()
.content(contentMap.get(e.getKey()))
.score(e.getValue())
.build())
.collect(Collectors.toList());
}
/**
* 将查询文本编码为稀疏向量(BM25)
* 简化实现:按词频生成稀疏向量
*/
private List<SortedMap<Integer, Float>> encodeQueryToSparse(String query) {
SortedMap<Integer, Float> sparseVec = new TreeMap<>();
String[] words = query.toLowerCase().split("\\s+");
for (String word : words) {
int hash = Math.abs(word.hashCode() % 100000);
sparseVec.merge(hash, 1.0f, Float::sum);
}
return Collections.singletonList(sparseVec);
}
public void deleteByKnowledgeBase(String knowledgeBaseId) {
String expr = String.format("%s == \"%s\"", KB_ID_FIELD, knowledgeBaseId);
milvusClient.delete(
DeleteParam.newBuilder()
.withCollectionName(collectionName)
.withExpr(expr)
.build());
}
}
6.2 混合检索原理
ini
用户问题:"API-2024-001的使用方法"
① 稠密向量检索(语义):
→ 找到"接口调用规范"、"API文档说明"等语义相近的文档
→ 分数: 0.85, 0.78, 0.72
② 稀疏向量检索(BM25关键词):
→ 精确匹配"API-2024-001"的文档
→ 分数: 0.95, 0.45, 0.30
③ 加权融合(0.6 * 语义 + 0.4 * 关键词):
文档A: 0.6*0.85 + 0.4*0.95 = 0.89 ← 最佳结果
文档B: 0.6*0.78 + 0.4*0.45 = 0.65
文档C: 0.6*0.72 + 0.4*0.30 = 0.55
调整权重:
- 通用问答场景:
denseWeight=0.7, sparseWeight=0.3 - 精确查询场景(编号、缩写):
denseWeight=0.4, sparseWeight=0.6 - 默认推荐:
denseWeight=0.6, sparseWeight=0.4
七、Step 6:本地开发/生产环境无缝切换
7.1 两套配置
yaml
# application-local.yml(本地开发,不需要Docker)
llm:
use-in-memory-embedding: true
rag:
hybrid-search:
enabled: false
# application.yml(生产环境)
llm:
use-in-memory-embedding: false
milvus:
host: ${MILVUS_HOST:localhost}
port: ${MILVUS_PORT:19530}
rag:
hybrid-search:
enabled: true
7.2 切换命令
bash
# 本地开发(不需要Docker)
mvn spring-boot:run -Dspring-boot.run.profiles=local
# 生产环境(需要Milvus)
mvn spring-boot:run
核心机制 :VectorStoreService 只依赖 EmbeddingStore<TextSegment> 接口,不关心底层是Milvus还是InMemory。HybridSearchService 用 @Autowired(required = false) 注入,不存在时自动回退到纯向量检索。
八、Step 7:测试验证
8.1 启动服务
bash
# 1. 启动Milvus
docker-compose up -d
# 2. 配置API Key
set DASHSCOPE_API_KEY=sk-your-api-key
# 3. 启动应用
mvn spring-boot:run -pl llm-service
8.2 上传文档
bash
curl -X POST http://localhost:8080/api/v1/documents/upload \
-F "file=@employee-handbook.pdf" \
-F "knowledgeBaseId=kb-hr"
返回:
json
{"code":200,"message":"success","data":null}
日志输出:
ini
向量写入完成: kbId=kb-hr, count=45
混合检索数据写入完成: kbId=kb-hr, count=45
8.3 RAG查询
bash
curl -X POST http://localhost:8080/api/v1/rag/query \
-H "Content-Type: application/json" \
-d '{"question":"年假怎么申请?","knowledgeBaseId":"kb-hr"}'
返回:
json
{
"code": 200,
"data": {
"answer": "根据《员工手册》第3.2条,年假申请流程如下:...",
"sources": [
{"content": "年假政策:入职满1年...", "score": 0.89}
],
"latencyMs": 850
}
}
8.4 验证多知识库隔离
bash
# 上传技术文档到kb-tech
curl -X POST http://localhost:8080/api/v1/documents/upload \
-F "file=@spring-boot-guide.pdf" \
-F "knowledgeBaseId=kb-tech"
# 在kb-hr中查技术问题 → 查不到
curl -X POST http://localhost:8080/api/v1/rag/query \
-H "Content-Type: application/json" \
-d '{"question":"Spring Boot如何配置数据源?","knowledgeBaseId":"kb-hr"}'
# 返回: "根据现有资料无法回答该问题" ← 隔离生效
九、常见问题与调优
9.1 问题排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 连接Milvus超时 | Docker未启动或端口不对 | docker ps 检查,确认19530端口 |
| 向量维度不匹配 | embedding.dimensions配错 | 通义千问v3=1024,OpenAI=1536 |
| 检索不到结果 | minScore太高 | 降到0.5试试 |
| 检索结果跨知识库 | metadata过滤没生效 | 检查knowledgeBaseId字段名 |
| 混合检索报错 | Milvus版本<2.4 | 升级到2.5+,或关闭hybrid-search |
| Collection已存在 | 重复初始化 | 代码中已做hasCollection检查 |
9.2 性能调优
| 参数 | 默认值 | 调优建议 |
|---|---|---|
nlist(IVF索引) |
128 | 数据量<10万:64;10万-100万:128;>100万:256 |
maxResults(Top-K) |
5 | 一般场景3-5,需要高召回可设10 |
minScore |
0.6 | 精度优先0.7,召回优先0.5 |
autoFlushOnInsert |
true | 高吞吐场景可设为false,批量flush |
denseWeight |
0.6 | 语义为主0.6,关键词为主0.4 |
十、生产级增强
上面完成的是基础集成,但上生产还有几个关键问题需要解决。以下每个点都是踩坑后总结的实战经验。
10.1 索引选型:IVF_FLAT vs HNSW vs DISKANN
向量索引直接决定检索速度和精度,选错索引 = 查询慢3倍。
| 索引类型 | 原理 | 构建速度 | 查询速度 | 内存占用 | 精度 | 适用场景 |
|---|---|---|---|---|---|---|
| IVF_FLAT | 倒排索引 + 暴力搜索 | 快 | 中 | 低 | 高(可调nprobe) | 中小规模(百万级),默认选择 |
| HNSW | 分层图结构 | 慢 | 快 | 高 | 最高 | 高并发低延迟(千万级) |
| DISKANN | 磁盘索引 | 中 | 中 | 极低 | 中 | 向量量大但内存受限(亿级) |
| IVF_PQ | 倒排+乘积量化 | 中 | 快 | 低 | 中(有损压缩) | 内存极度受限 |
选型决策树:
ini
数据量 < 100万 → IVF_FLAT, nlist=128, nprobe=16
数据量 100万-1000万 → HNSW, M=16, efConstruction=200
数据量 > 1000万 → DISKANN(内存受限)或 HNSW(内存充足)
内存 < 16GB → IVF_PQ 或 DISKANN
Java代码中指定索引 (在 initCollection() 中):
java
// === IVF_FLAT(默认推荐)===
milvusClient.createIndex(
CreateIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName(DENSE_VECTOR_FIELD)
.withIndexType(IndexType.IVF_FLAT)
.withMetricType(MetricType.COSINE)
.withExtraParam("{\"nlist\":128}") // 聚类中心数
.build());
// === HNSW(高并发场景)===
milvusClient.createIndex(
CreateIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName(DENSE_VECTOR_FIELD)
.withIndexType(IndexType.HNSW)
.withMetricType(MetricType.COSINE)
.withExtraParam("{\"M\":16,\"efConstruction\":200}")
.build());
// === IVF_PQ(内存受限)===
milvusClient.createIndex(
CreateIndexParam.newBuilder()
.withCollectionName(collectionName)
.withFieldName(DENSE_VECTOR_FIELD)
.withIndexType(IndexType.IVF_PQ)
.withMetricType(MetricType.COSINE)
.withExtraParam("{\"nlist\":128,\"m\":16,\"nbits\":8}")
.build());
查询时动态调整 nprobe(仅 IVF 系列索引生效):
java
// 检索时设置 nprobe,越大精度越高但越慢
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withVectorFieldName(DENSE_VECTOR_FIELD)
.withVectors(Collections.singletonList(denseVector))
.withTopK(maxResults)
.withExtraParam("{\"nprobe\":16}") // 默认8,生产建议16-32
.build();
| nprobe | 精度 | 延迟 | 建议 |
|---|---|---|---|
| 4 | 80% | 5ms | 开发测试 |
| 8 | 90% | 10ms | 默认值 |
| 16 | 95% | 20ms | 生产推荐 |
| 32 | 98% | 40ms | 高精度场景 |
10.2 连接池与超时配置
生产环境必须配置连接池,否则高并发下 gRPC 连接会被打满。
java
@Bean
public MilvusServiceClient milvusServiceClient() {
if (useInMemoryEmbedding) {
return null;
}
return new MilvusServiceClient(
ConnectParam.newBuilder()
.withHost(milvusHost)
.withPort(milvusPort)
// ===== 连接池配置 =====
.withMaxIdlePerKey(10) // 每个key最大空闲连接
.withIdleTimeout(60, TimeUnit.SECONDS) // 空闲连接超时
.withMaxRetry(3) // 失败重试次数
.withRetryDelay(100L) // 重试间隔(ms)
// ===== 超时配置 =====
.withConnectTimeout(5, TimeUnit.SECONDS) // 连接超时
.withRequestTimeout(10, TimeUnit.SECONDS) // 请求超时
.build()
);
}
关键参数说明:
| 参数 | 默认值 | 生产建议 | 说明 |
|---|---|---|---|
maxIdlePerKey |
无限制 | 10-20 | 防止连接泄漏 |
idleTimeout |
无限制 | 60s | 释放长期不用的连接 |
connectTimeout |
10s | 3-5s | 快速失败,不要阻塞 |
requestTimeout |
10s | 5-10s | 检索请求超时 |
maxRetry |
0 | 2-3 | 网络抖动时自动重试 |
10.3 分区键(Partition Key)------比 metadata 过滤更高效
前面用 metadata.knowledgeBaseId 过滤实现多知识库隔离,这在数据量小时没问题。但上了百万级后,每次查询都要扫描全量数据再过滤,性能会急剧下降。
Milvus 2.5+ 的 Partition Key 可以从存储层面隔离数据:
java
// 创建 Collection 时指定 Partition Key
FieldType partitionKeyField = FieldType.newBuilder()
.withName("knowledge_base_id")
.withDataType(DataType.VarChar)
.withMaxLength(128)
.withPartitionKey(true) // 标记为分区键
.build();
// 插入时 Milvus 自动按 partition key 值路由到不同分区
// 查询时自动只扫描对应分区,无需手动加 filter
Partition Key vs metadata 过滤对比:
| 维度 | metadata 过滤 | Partition Key |
|---|---|---|
| 数据量 < 10万 | 几乎无差异 | 几乎无差异 |
| 数据量 10万-100万 | 延迟增加 20-50% | 延迟稳定 |
| 数据量 > 100万 | 延迟增加 2-5倍 | 延迟稳定 |
| 实现复杂度 | 简单(一行 filter) | 需要在建表时声明 |
| 知识库数量 | 无限制 | 建议 < 1024 个 |
建议:如果知识库数量 < 100 且单库数据量可能 > 50万,用 Partition Key。中小规模用 metadata 过滤完全够用。
10.4 Milvus 认证与安全
Milvus 2.5+ 支持 RBAC 多用户认证。默认安装是 root/Milvus,生产环境必须修改。
Docker Compose 启用认证:
yaml
milvus:
image: milvusdb/milvus:v2.5.9
command: ["milvus", "run", "standalone"]
environment:
ETCD_ENDPOINTS: etcd:2379
MINIO_ADDRESS: minio:9000
# 启用认证
COMMON_SECURITY_AUTHORIZATION_ENABLED: "true"
ports:
- "19530:19530"
Java 客户端连接认证:
java
return new MilvusServiceClient(
ConnectParam.newBuilder()
.withHost(milvusHost)
.withPort(milvusPort)
.withAuthorization("root", "Milvus") // 生产环境修改密码
.build()
);
LangChain4j MilvusEmbeddingStore 也支持认证:
java
return MilvusEmbeddingStore.builder()
.host(milvusHost)
.port(milvusPort)
.collectionName(milvusCollectionName)
.dimension(embeddingDimensions)
.metricType(MetricType.COSINE)
.username("root") // 用户名
.password("Milvus") // 密码
.autoFlushOnInsert(true)
.build();
安全最佳实践:
- 不要用 root 账户,为应用创建专用只读/读写账户
- 密码通过环境变量注入,不要硬编码
- 生产环境开启 TLS:
withSecure(true)+server.pem
10.5 监控与告警(Prometheus + Grafana)
Milvus 原生暴露 Prometheus metrics,但需要额外配置抓取。
Milvus 容器暴露 metrics 端口 (已在 docker-compose 中配置 9091:9091):
yaml
# prometheus.yml 中添加抓取配置
scrape_configs:
- job_name: 'milvus'
static_configs:
- targets: ['localhost:9091']
Grafana 关键看板指标:
| 指标 | PromQL | 告警阈值 |
|---|---|---|
| 检索延迟 P99 | histogram_quantile(0.99, milvus_proxy_search_latency_bucket) |
> 200ms |
| 检索 QPS | rate(milvus_proxy_search_requests_count[1m]) |
- |
| 插入延迟 | histogram_quantile(0.99, milvus_proxy_insert_latency_bucket) |
> 500ms |
| Collection 已加载数 | milvus_proxy_collection_loaded_count |
不应下降 |
| 内存使用 | milvus_querynode_memory_usage_bytes |
> 80% |
| 磁盘使用 | milvus_datanode_disk_usage_bytes |
> 80% |
应用层监控(Micrometer + Prometheus):
java
@Configuration
public class MilvusMetricsConfig {
@Bean
public Timer milvusSearchTimer(MeterRegistry registry) {
return Timer.builder("milvus.search.latency")
.description("Milvus检索延迟")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
}
}
// 在 VectorStoreService 中使用
@Autowired
private Timer milvusSearchTimer;
public List<DocumentMatch> search(...) {
return milvusSearchTimer.record(() -> {
// 实际检索逻辑
return embeddingStore.search(...).matches()...;
});
}
10.6 数据备份与恢复
方案一:Collection 级别备份(推荐)
bash
# 导出 Collection 元数据 + 数据到本地文件
docker exec milvus milvus-backup \
--collection llm_knowledge_base \
--backup-path /backups/ \
--mode export
# 恢复
docker exec milvus milvus-backup \
--collection llm_knowledge_base \
--backup-path /backups/ \
--mode restore
方案二:重新入库(最稳妥)
向量数据理论上可以从原始文档重新生成,只要保留原始文档即可。所以:
scss
原始文档(PostgreSQL/文件存储) → 定期备份原始文档
↓ 需要恢复时
重新执行 ingestDocument() → 自动完成 解析→分块→向量化→入Milvus
备份策略建议:
| 数据 | 备份方式 | 频率 |
|---|---|---|
| 原始文档 | PG 定期备份 | 每天 |
| Milvus 向量 | 重新入库(从原始文档恢复) | 无需单独备份 |
| Milvus 元数据 | etcd 快照 | 每天 |
| Collection Schema | 代码中 initCollection() 自动创建 | 通过代码管理 |
核心原则:向量数据是"衍生数据",原始文档是真源头。 只要保留原始文档,向量数据随时可以重建。
十一、总结
11.1 完整链路回顾
scss
文档上传 → Tika解析 → 文本分块 → Embedding向量化
→ VectorStoreService.addAll()
→ embeddingStore.add() 写入Milvus(稠密向量)
→ hybridSearchService.insert() 写入BM25稀疏向量
→ 入库完成
用户提问 → 输入安全校验 → 问题向量化
→ vectorStoreService.hybridSearch()
→ 稠密向量检索(语义)+ BM25检索(关键词)
→ 加权融合排序
→ 过滤knowledgeBaseId
→ 拼接Prompt → 大模型生成 → 返回答案
11.2 关键数字
| 指标 | 数值 |
|---|---|
| 向量维度 | 1024(通义千问v3) |
| 检索Top-K | 5 |
| 混合检索权重 | 0.6语义 + 0.4BM25 |
| 相似度阈值 | 0.6 |
| 平均检索延迟 | 50-100ms |
| 知识库隔离 | metadata过滤(零额外成本) |
11.3 核心原则
- 接口隔离 :依赖
EmbeddingStore接口,不依赖具体实现 - 开发/生产分离:本地用InMemory,生产用Milvus,一个开关切换
- 混合检索优先:语义+关键词,覆盖更多查询场景
- 元数据过滤:一个Collection + metadata,实现多知识库完美隔离
- 自动回退:混合检索不可用时,自动降级为纯向量检索
11.4 下一步
RAG系统的基础设施已经完整。下一篇文章将介绍RAG检索优化------查询改写、重排序、缓存策略,进一步提升检索效果和降低Token成本。
参考资料
作者简介 :花生智源,Java工程师转型AI应用开发,专注Java+AI工程化落地
相关文章: