Java集成Milvus向量数据库完整教程——从Docker部署到生产级混合检索

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个理由

  1. 混合检索是刚需:纯向量检索在专有名词、缩写、数字上效果差,必须结合BM25关键词检索
  2. Java原生SDKio.milvus:milvus-sdk-java,不依赖Python
  3. LangChain4j官方集成langchain4j-milvus,一行配置搞定
  4. 分布式架构:计算和存储分离,支持水平扩展

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 核心原则

  1. 接口隔离 :依赖EmbeddingStore接口,不依赖具体实现
  2. 开发/生产分离:本地用InMemory,生产用Milvus,一个开关切换
  3. 混合检索优先:语义+关键词,覆盖更多查询场景
  4. 元数据过滤:一个Collection + metadata,实现多知识库完美隔离
  5. 自动回退:混合检索不可用时,自动降级为纯向量检索

11.4 下一步

RAG系统的基础设施已经完整。下一篇文章将介绍RAG检索优化------查询改写、重排序、缓存策略,进一步提升检索效果和降低Token成本。


参考资料


作者简介 :花生智源,Java工程师转型AI应用开发,专注Java+AI工程化落地

相关文章

相关推荐
贵慜_Derek1 小时前
vLLM-07|MegaMoE 与 FusedMoE:路由相同,算 expert 完全不同
人工智能·算法·llm
JeJe同学1 小时前
Opencv之高斯金字塔
人工智能·opencv·计算机视觉
得物技术1 小时前
得物知识问答:复合检索 Agent 的系统设计实践
人工智能·后端·ai编程
机器学习之心1 小时前
基于改进鲸鱼优化算法的CNN-BiLSTM-MATT短期电力负荷预测模型
人工智能·算法·cnn·cnn-bilstm-matt·短期电力负荷预测
南方程序猴1 小时前
Codex 将再次重置:GPT-6.0 发布前的黑暗时刻
人工智能·gpt·ai·ai编程
一线数智1 小时前
从百度搜索到AI推荐 制造业正在迎来新的获客方式
人工智能
天天爱吃肉82181 小时前
【工程师笔记|新能源整车电控一次过CISPR25/BCI,汽车EMC/EMI落地十大核心设计技巧】
大数据·人工智能·笔记·python·汽车
9i编程1 小时前
SKILL 四大铁律准则:从「AI 选择性执行 SKILL」到「铁律强制闭环」
人工智能·openai·ai编程
后端小肥肠2 小时前
自研长篇小说写作 Skills:参考文风 + 自动续篇 + 剧情连续性检测
人工智能·aigc·agent