AgentX专栏 05-RAG进阶:用Milvus+bge-m3构建比ES更懂语义的企业知识库

作者:袖梨 2026-06-05

RAG 进阶:用 Milvus + bge-m3 构建比 ES 更懂语义的企业知识库|AgentX 专栏⑤


本文速览:

《AgentX 专栏》05-RAG进阶:用Milvus+bge-m3构建比ES更懂语义的企业知识库

  • 为什么传统 ES 关键词检索撑不起企业知识库?这是结构性问题,不是调参能解决的
  • Embedding 是什么?向量相似度如何捕捉"同义词"和"口语化表达"?
  • 为什么选 bge-m3?Dense + Sparse + ColBERT 三合一,中文 MTEB Top3
  • 为什么选 Milvus 而非 pgvector 或 ES kNN?三方横向对比
  • 为什么用 Ollama 跑 bge-m3,而不是 HuggingFace TEI?
  • 四个核心类完整解析:MilvusConfig 三阶段初始化、自制 MilvusV2EmbeddingStoreKnowledgeService 切片流水线
  • chunk 策略深挖:500 token / 50 overlap,每个参数背后的考量
  • 三个实战大坑:Collection 找不到、metadata 解析丢失、minScore 阈值过滤过严

一、为什么 ES 检索撑不起企业知识库

向量检索是企业知识库的必修课,因为传统 ES 关键词检索面对一类问题时是结构性失效的——用户用口语提问,文档用术语描述,二者之间的词汇鸿沟靠倒排索引根本跨不过去。

举几个制造业知识库里典型的对照:

用户的提问文档里的实际描述ES 关键词检索
机器一直响,怎么处理?设备出现异常噪音时…… 无结果
轴承发热轴承过热故障排查步骤…… 无结果
设备跑不动了电机转速异常降低…… 无结果
油压不够液压系统压力不足…… 无结果
液压系统压力不足液压系统压力不足…… 命中

差异的本质很清楚:ES 只认字符,不认语义。"机器一直响"和"异常噪音"描述同一现象,但倒排索引里两者没有交集,结果就是召回 0 条。

这是 ES 关键词匹配的天花板:

痛点根因调参能解决吗
同义词召不回倒排索引只索引原词 同义词词典维护成本爆炸
口语化召不回工人语言和文档语言天然不同 不可枚举
缩写、别名召不回词形归一化能力弱 治标不治本
排序不准TF-IDF 不理解语义权重️ Boost 调参能缓解但不彻底

向量检索从根上换思路——把文字编码成高维向量,让"机器一直响"和"异常噪音"在向量空间里成为近邻,检索时找最近邻而不是找字符匹配。AgentX 选用的是 Milvus 2.x + bge-m3 + Ollama 这套组合,下面逐层拆解为什么这么选、代码怎么写、坑在哪里。


二、向量检索:让机器真正"理解"文字

2.1 Embedding:把语义压缩进数字

Embedding 模型做一件事:把一段文字变成一个高维数字向量。

"轴承发热"      → [0.23, -0.71, 0.45, ..., 0.12]   // 1024 维
"轴承过热故障"   → [0.25, -0.69, 0.43, ..., 0.11]   // 向量非常接近
"今天吃什么"    → [-0.88, 0.34, -0.12, ..., 0.67]   // 向量相差很远

语义相近的文字,在高维空间里距离很近(余弦相似度接近 1.0)。检索时,把用户问题也变成向量,在向量库里找最近邻,对应的文档就是答案——这叫 ANN(近似最近邻)检索

2.2 为什么向量能"懂"语义?

Embedding 模型在海量语料上预训练,已经学到了大量语言规律:

  • "发热" 和 "过热" 在语义空间里是近邻
  • "机器响" 和 "异常噪音" 描述同一现象
  • "跑不动" 在工业语境下等价于 "转速异常降低"

这是 ES 做不到的——它只能匹配你索引里存在的词,不会"举一反三"。


三、为什么选 bge-m3?

市面上 Embedding 模型不少,为什么推荐 BAAI/bge-m3

3.1 三种检索模式合一

bge-m3 一个模型同时支持三种检索方式:

模式原理优势典型场景
Dense(稠密)语义向量,1024 维 float理解同义词、口语语义模糊的用户提问
Sparse(稀疏)类似 BM25,词频权重精确关键词命中型号、专有名词
ColBERT(多向量)Token 级细粒度交互长文档精细匹配合同、政策文本

大多数场景用 Dense 模式就够了;遇到"型号 YE3-160M-4"这种精确查询,Sparse 模式能弥补语义模型的弱点。

3.2 中文场景的首选

bge-m3 由智源研究院(BAAI)出品,是为中英双语场景专门优化的开源模型。横向看常见 Embedding 模型的定位差异:

模型中文语义英文语义维度开源备注
bge-m3⭐⭐⭐⭐⭐⭐⭐⭐⭐1024中文 MTEB Top3,多向量模式
text-embedding-3-small⭐⭐⭐⭐⭐⭐⭐⭐1536OpenAI 闭源,按 token 计费
nomic-embed-text⭐⭐⭐⭐⭐⭐⭐768英文场景表现好

国内企业知识库以中文为主,再叠加私有化部署需求,bge-m3 是首选。


四、为什么选 Milvus?

4.1 三方对比

对比维度MilvuspgvectorES kNN
专为向量设计 扩展插件 扩展插件
十亿级向量️ 百万级️ 百万级
HNSW / AUTOINDEX
分布式水平扩展
Java SDK 成熟度 MilvusClientV2️ JDBC 扩展
混合检索(Dense+Sparse)️ 需手动融合

pgvector 简单、和 PostgreSQL 集成好,适合数据量不大的场景。企业知识库文档量上去之后(几百万 chunk),Milvus 的 ANN 查询速度优势会非常明显。

4.2 AgentX 为什么不用 LangChain4j 内置的 MilvusEmbeddingStore?

LangChain4j 自带 MilvusEmbeddingStore,为什么 AgentX 要自己实现?

有两个原因:

第一,SDK 版本问题。 LangChain4j 早期版本封装的是 Milvus 旧版 SDK,而 io.milvus:milvus-sdk-java:2.4+ 已经升级到 V2 API(MilvusClientV2),接口完全不同。手动适配才能用上最新 SDK 的特性。

第二,metadata 序列化可控。 内置实现的 metadata 处理方式在某些场景下会有类型丢失问题。自己实现可以用 Gson 精准控制 Map<String, String> 的序列化/反序列化,排查问题也更直接。

4.3 为什么用 Ollama 跑 bge-m3,而不是 TEI?

HuggingFace TEI(Text Embeddings Inference)是生产级向量推理服务,性能很好,但有几个问题:

  1. 部署复杂——需要额外起一个 Python 服务(Docker 或独立进程),运维成本高
  2. 首次下载慢——从 HuggingFace Hub 拉模型,国内网络经常超时
  3. 和 LangChain4j 整合需要自写 HTTP 客户端

AgentX 的选择是 Ollama:一条命令 ollama pull bge-m3:latest 拉取模型,本地跑,LangChain4j 有现成的 OllamaEmbeddingModel 自动配置,application.yml 里填几行配置就接好了。开发效率更高,私有化部署更彻底。

ai:
  ollama:
    base-url: http://${AI_SERVER_IP:127.0.0.1}:11434
    embedding:
      model: bge-m3:latest        # Ollama 拉取并运行 bge-m3
      timeout-seconds: 600
    chat:
      model: qwen2.5:3b           # 聊天模型,CPU 跑 3b 效果不错
      timeout-seconds: 600

LangChain4j 读取这段配置,自动注入 EmbeddingModel Bean,后续代码直接 @Autowired 拿来用——一行 HTTP 客户端代码都不用写。


五、整体架构:四步流水线

┌─────────────────────────────────────────────────────────────────────┐
│                        【离线阶段:知识入库】                          │
│                                                                     │
│  文档上传                                                            │
│  POST /api/v1/knowledge/upload                                      │
│       │                                                             │
│       ▼                                                             │
│  KnowledgeController                                                │
│       │                                                             │
│       ▼                                                             │
│  KnowledgeService.importDocument()                                  │
│  ① Apache Tika 解析(PDF/Word/Excel/TXT)                           │
│  ② DocumentSplitters.recursive(500, 50) 递归切片                    │
│  ③ OllamaEmbeddingModel → bge-m3:latest 向量化                     │
│  ④ MilvusV2EmbeddingStore.addAll() 写入 Milvus                     │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────────────────────┐
│                        【在线阶段:问答检索】                          │
│                                                                     │
│  用户提问                                                            │
│       │                                                             │
│       ▼                                                             │
│  OllamaEmbeddingModel → bge-m3:latest 向量化问题                    │
│       │                                                             │
│       ▼                                                             │
│  MilvusV2EmbeddingStore.search()                                    │
│  FloatVec ANN 检索,COSINE 相似度,minScore 过滤                     │
│       │                                                             │
│       ▼                                                             │
│  LangChain4j AiService / RetrievalAugmentor                        │
│  把 top-K 片段注入 Prompt → qwen2.5:3b 生成答案                     │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

六、环境搭建

6.1 启动 Milvus

# docker-compose.yml 中的 Milvus 服务(etcd + minio + milvus)
docker compose up -d milvus# 验证
curl 
# → {"status":"ok"}# 可选:启动 Attu 管理界面(localhost:8000)
docker compose up -d attu

6.2 用 Ollama 拉取 bge-m3

# 安装 Ollama(如已安装跳过)
curl -fsSL  | sh# 拉取 bge-m3 向量模型(约 1.2GB)
ollama pull bge-m3:latest# 验证(返回向量数组即成功)
curl  
  -d '{"model":"bge-m3:latest","prompt":"测试"}'# 顺手拉聊天模型
ollama pull qwen2.5:3b

国内机器拉取慢可以先换镜像源再 pull:

export OLLAMA_MIRRORS=

6.3 Maven 依赖

<properties>
    <java.version>21</java.version>
    <langchain4j.version>1.0.0-beta3</langchain4j.version>
</properties><!-- LangChain4j Ollama(自动配置 EmbeddingModel + ChatModel) -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-ollama-spring-boot-starter</artifactId>
    <version>${langchain4j.version}</version>
</dependency><!-- Milvus V2 SDK(手动管理,不依赖 LangChain4j 内置封装) -->
<dependency>
    <groupId>io.milvus</groupId>
    <artifactId>milvus-sdk-java</artifactId>
    <version>2.4.9</version>
</dependency><!-- Apache Tika 文档解析 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-document-parser-apache-tika</artifactId>
    <version>${langchain4j.version}</version>
</dependency><!-- LangChain4j 核心 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-spring-boot-starter</artifactId>
    <version>${langchain4j.version}</version>
</dependency><!-- Gson(metadata 序列化) -->
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
</dependency>

七、核心代码:四个关键类

7.1 MilvusProperties — 配置绑定

@Validated
@ConfigurationProperties(prefix = "milvus")
public record MilvusProperties(
    String uri,
    String databaseName,
    String username,
    String password,
    Long connectTimeout,
    String collectionName,
    Integer dimension,
    Long initWaitMs
) {}

对应 application.yml

milvus:
  uri: http://${DATA_SERVER_IP:127.0.0.1}:19530
  username: ${MILVUS_USERNAME:root}
  password: ${MILVUS_PASSWORD:milvus}
  database-name: agentx_db
  collection-name: agentx_knowledge
  dimension: 1024          # 必须与 bge-m3 的输出维度完全匹配
  connect-timeout: 5
  init-wait-ms: 500

为什么用 Java Record? 配置类一旦初始化就不应该被修改。Record 天然不可变(final 字段,无 setter),比传统 @Data 的 POJO 更安全,也让意图更清晰——这是配置,不是业务对象。


7.2 MilvusConfig — 三阶段初始化

这是整个 RAG 系统最容易踩坑的地方。直接 new MilvusClientV2() 连上 Milvus 就建表是典型反模式——会报 Collection not foundDatabase not found,根因是缺了三阶段的初始化顺序。

@Slf4j
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(MilvusProperties.class)
public class MilvusConfig {    private static final String DEFAULT_DB = "default";
    private final MilvusProperties properties;    @Bean(destroyMethod = "close")
    public MilvusClientV2 milvusClientV2() {
        String targetDb = (properties.databaseName() == null
                || properties.databaseName().isBlank())
                ? DEFAULT_DB : properties.databaseName();        // 第一阶段:置备数据库(先用 admin 连接 default 库,检查目标库)
        provisionDatabase(targetDb);        // 第二阶段:连接目标数据库
        log.info("[Milvus]  正在连接正式业务数据库: [{}]", targetDb);
        ConnectConfig finalConfig = ConnectConfig.builder()
                .uri(properties.uri())
                .dbName(targetDb)  //  这一行是关键,指定连哪个库
                .username(properties.username())
                .password(properties.password())
                .connectTimeoutMs(TimeUnit.SECONDS.toMillis(properties.connectTimeout()))
                .build();
        MilvusClientV2 client = new MilvusClientV2(finalConfig);        // 第三阶段:置备集合(建表+建索引)
        provisionCollection(client);        return client;
    }    private void provisionDatabase(String dbName) {
        if (DEFAULT_DB.equalsIgnoreCase(dbName)) return;        MilvusClientV2 adminClient = null;
        try {
            // 必须连 default 库才能执行 listDatabases
            adminClient = new MilvusClientV2(ConnectConfig.builder()
                    .uri(properties.uri()).dbName(DEFAULT_DB)
                    .username(properties.username()).password(properties.password())
                    .build());
            List<String> databases = adminClient.listDatabases().getDatabaseNames();            if (!databases.contains(dbName)) {
                log.info("[Milvus] 数据库 [{}] 未找到,初始化建库...", dbName);
                adminClient.createDatabase(CreateDatabaseReq.builder()
                        .databaseName(dbName).build());
                // 建库后元数据同步有延迟,必须等待
                TimeUnit.MILLISECONDS.sleep(properties.initWaitMs());
                log.info(" [Milvus] 数据库 [{}] 创建成功", dbName);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.warn("️ [Milvus] 数据库检查/创建异常: {}", e.getMessage());
        } finally {
            if (adminClient != null) {
                try { adminClient.close(); } catch (Exception ignored) {}
            }
        }
    }    private void provisionCollection(MilvusClientV2 client) {
        String collectionName = properties.collectionName();
        try {
            boolean exists = client.hasCollection(
                    HasCollectionReq.builder().collectionName(collectionName).build());            if (!exists) {
                log.info("[Milvus] ️ 自动创建集合: {}", collectionName);                // enableDynamicField(true):允许存储 Schema 未定义的动态字段
                // 即使以后新增元数据字段,也不需要改 Schema
                CreateCollectionReq.CollectionSchema schema =
                        CreateCollectionReq.CollectionSchema.builder()
                                .enableDynamicField(true).build();                schema.addField(AddFieldReq.builder().fieldName("id")
                        .dataType(DataType.VarChar).maxLength(100).isPrimaryKey(true).build());
                schema.addField(AddFieldReq.builder().fieldName("vector")
                        .dataType(DataType.FloatVector).dimension(properties.dimension()).build());
                schema.addField(AddFieldReq.builder().fieldName("text")
                        .dataType(DataType.VarChar).maxLength(65535).build());
                schema.addField(AddFieldReq.builder().fieldName("metadata")
                        .dataType(DataType.VarChar).maxLength(65535).build());                // AUTOINDEX:让 Milvus 根据数据量自动选最合适的索引算法
                // 4G 内存机器它会选 IVF_FLAT,内存充足时选 HNSW
                List<IndexParam> indexParams = Collections.singletonList(
                        IndexParam.builder()
                                .fieldName("vector")
                                .indexType(IndexParam.IndexType.AUTOINDEX)
                                .metricType(IndexParam.MetricType.COSINE)
                                .build());                client.createCollection(CreateCollectionReq.builder()
                        .collectionName(collectionName)
                        .collectionSchema(schema)
                        .indexParams(indexParams)
                        .build());                log.info(" [Milvus] 集合 [{}] 创建成功", collectionName);
            }
        } catch (Exception e) {
            log.error(" [Milvus] 集合建模异常: {}", e.getMessage());
        }
    }
}

为什么要三阶段?

阶段动作为什么不能跳过
第一阶段default 库检查并创建 agentx_db直接连不存在的库会报 database not found
第二阶段agentx_db 拿业务 clientdbName 必须在连接时指定,不能切换
第三阶段检查并创建集合 + 索引集合不存在,后面的 insert/search 全报错

@Bean(destroyMethod = "close") 的作用:Spring 容器关闭时自动调用 client.close(),切断与 Milvus 的网络连接,防止资源泄漏。这一行让你不需要实现 DisposableBean 接口。


7.3 MilvusV2EmbeddingStore — 自制适配器

这是 AgentX 最有意思的一个类。它把 LangChain4j 的标准接口 EmbeddingStore<TextSegment> 和 Milvus V2 SDK 的 API 桥接起来:

@Slf4j
public class MilvusV2EmbeddingStore implements EmbeddingStore<TextSegment> {    private final MilvusClientV2 client;
    private final String collectionName;    private static final String ID_FIELD       = "id";
    private static final String VECTOR_FIELD   = "vector";
    private static final String TEXT_FIELD     = "text";
    private static final String METADATA_FIELD = "metadata";    // 单例复用,避免频繁创建 Gson 实例
    private static final Gson GSON = new Gson();
    private static final Type MAP_TYPE = new TypeToken<Map<String, String>>() {}.getType();    // ── 存入逻辑 ──────────────────────────────────────────────────────────    @Override
    public void addAll(List<String> ids, List<Embedding> embeddings,
                       List<TextSegment> embedded) {
        if (embeddings == null || embeddings.isEmpty()) return;        // Milvus V2 SDK 接收 List<JsonObject>,每行是一个 JSON 对象
        List<JsonObject> dataRows = new ArrayList<>(embeddings.size());        for (int i = 0; i < embeddings.size(); i++) {
            JsonObject row = new JsonObject();
            row.addProperty(ID_FIELD, ids.get(i));            // ️ 向量是 List<Float>,addProperty 不认识 List,必须用 toJsonTree
            row.add(VECTOR_FIELD, GSON.toJsonTree(embeddings.get(i).vectorAsList()));            if (embedded != null && i < embedded.size() && embedded.get(i) != null) {
                TextSegment segment = embedded.get(i);
                row.addProperty(TEXT_FIELD, segment.text());
                // metadata 序列化为 JSON 字符串存储,检索时再反序列化
                row.addProperty(METADATA_FIELD,
                        GSON.toJson(segment.metadata().toMap()));
            } else {
                row.addProperty(TEXT_FIELD, "");
                row.addProperty(METADATA_FIELD, "{}");
            }
            dataRows.add(row);
        }        client.insert(InsertReq.builder()
                .collectionName(collectionName)
                .data(dataRows)
                .build());
        log.debug("[Milvus] 批量写入 {} 条向量记录", dataRows.size());
    }    // ── 检索逻辑 ──────────────────────────────────────────────────────────    @Override
    public EmbeddingSearchResult<TextSegment> search(EmbeddingSearchRequest request) {
        // 1. 问题向量 → FloatVec(Milvus V2 的查询格式)
        FloatVec queryVec = new FloatVec(request.queryEmbedding().vectorAsList());        SearchReq searchReq = SearchReq.builder()
                .collectionName(collectionName)
                .data(Collections.singletonList(queryVec))
                .limit(request.maxResults())
                // ️ 必须显式声明 outputFields,否则返回结果里没有 text 和 metadata
                .outputFields(Arrays.asList(ID_FIELD, TEXT_FIELD, METADATA_FIELD))
                .build();        SearchResp searchResp = client.search(searchReq);        if (searchResp == null || searchResp.getSearchResults().isEmpty()) {
            return new EmbeddingSearchResult<>(Collections.emptyList());
        }        List<EmbeddingMatch<TextSegment>> matches =
                searchResp.getSearchResults().getFirst().stream()
                .map(res -> {
                    Map<String, Object> entity = res.getEntity();
                    String text = String.valueOf(entity.getOrDefault(TEXT_FIELD, ""));                    // metadata 可能是 String(需要 Gson 反序列化),也可能已是 Map
                    Object rawMetadata = entity.get(METADATA_FIELD);
                    Map<String, String> metadataMap = new HashMap<>();
                    try {
                        if (rawMetadata instanceof String s) {
                            metadataMap = GSON.fromJson(s, MAP_TYPE);
                        } else if (rawMetadata instanceof Map<?, ?> m) {
                            m.forEach((k, v) -> metadataMap.put(
                                    String.valueOf(k), String.valueOf(v)));
                        }
                    } catch (Exception e) {
                        log.warn("[Milvus] metadata 解析失败,id={}", res.getId());
                    }                    TextSegment segment = TextSegment.from(text, Metadata.from(metadataMap));
                    return new EmbeddingMatch<>(
                            (double) res.getScore(),
                            String.valueOf(res.getId()),
                            null,      // 不返回原始向量,节省内存
                            segment
                    );
                })
                // minScore 过滤:低于阈值的噪音直接丢弃
                .filter(match -> match.score() >= request.minScore())
                .toList();        log.debug("[Milvus] 检索完成,命中 {} 条", matches.size());
        return new EmbeddingSearchResult<>(matches);
    }
}

为什么不用 LangChain4j 内置的 MilvusEmbeddingStore

主要有两个原因:

  1. SDK 版本兼容性问题 —— 内置封装基于旧版 Milvus Java SDK 的 R<MutationResult> 接口设计,而 Milvus 2.4+ 已经升级到 MilvusClientV2 的全新 API,强行混用会出现编译和运行时双重不稳定
  2. metadata 反序列化不可控 —— 内置实现里 metadata 返回类型不稳定(有时是 String,有时是 Map),下游消费方很容易拿到空标签

自实现 EmbeddingStore 接口只需要覆盖 addAllsearch 两个核心方法,总共两百多行可读代码,换来的是完整的调试能力和稳定性——出问题时知道去哪改。


7.4 KnowledgeService — 文档入库流水线

@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeService {    private final EmbeddingModel embeddingModel;          // Ollama bge-m3 自动注入
    private final EmbeddingStore<TextSegment> embeddingStore; // MilvusV2EmbeddingStore    public void importDocument(MultipartFile file,
                               Map<String, String> metadata) throws IOException {
        // 1. 保存临时文件(LangChain4j 的 Loader 需要 Path)
        Path tempFile = Files.createTempFile("agentx-", file.getOriginalFilename());
        try {
            file.transferTo(tempFile);            // 2. Apache Tika 解析(PDF / Word / Excel / Markdown / TXT 全支持)
            DocumentParser parser = new ApacheTikaDocumentParser();
            Document document = FileSystemDocumentLoader.loadDocument(tempFile, parser);            // 补充元数据(文件名、分类)
            document.metadata().put("file_name", file.getOriginalFilename());
            metadata.forEach((k, v) -> document.metadata().put(k, v));            // 3. 递归切片:500 token / 50 token 重叠
            DocumentSplitter splitter = DocumentSplitters.recursive(500, 50);
            List<TextSegment> segments = splitter.split(document);
            log.info("[Knowledge] 文档切分为 {} 个片段", segments.size());            // 4. 批量向量化 + 写入 Milvus
            embeddingStore.addAll(
                    embeddingModel.embedAll(segments).content(),
                    segments
            );
            log.info("[Knowledge]  文档 [{}] 入库完成", file.getOriginalFilename());        } finally {
            Files.deleteIfExists(tempFile); // 临时文件必须清理
        }
    }
}

为什么 chunk size 选 500,overlap 选 50?

参考社区实践与 bge-m3 官方建议,常见 chunk 取值的适用区间如下:

chunk size适用场景 / 已知问题
< 128 token每片段上下文太少,AI 拿不到完整语义
128 ~ 256适合 FAQ 类短问答
500(AgentX 当前配置)适合正文段落,一个自然段基本能装下,召回粒度合适
> 1024上下文虽然更全,但 embedding 信息会被稀释,长距离相似度下降

overlap 50 的作用:如果一个知识点刚好被切在两个片段的边界,50 token 的重叠能保证两片段都能召回它,避免边界截断问题。

embeddingModel.embedAll(segments).content() 这一行值得说一下:embedAll 返回 Response<List<Embedding>>.content() 取出实际的 embedding 列表。LangChain4j 把这层包装统一化了,不管底层是 Ollama、OpenAI 还是自定义模型,调用方式完全一样。


7.5 KnowledgeController — 上传接口

@Slf4j
@RestController
@RequestMapping("/api/v1/knowledge")
@RequiredArgsConstructor
public class KnowledgeController {    private final KnowledgeService knowledgeService;    /**
     * 上传文档入向量库
     * 支持 PDF / Word / Excel / TXT / Markdown
     */
    @PostMapping("/upload")
    public ResponseEntity<Map<String, Object>> uploadDocument(
            @RequestParam("file") MultipartFile file,
            @RequestParam(value = "category", defaultValue = "general") String category) {        log.info("[API] 文件上传: {}, 分类: {}", file.getOriginalFilename(), category);        try {
            long start = System.currentTimeMillis();
            knowledgeService.importDocument(file, Map.of("category", category));            Map<String, Object> response = new LinkedHashMap<>();
            response.put("success", true);
            response.put("fileName", file.getOriginalFilename());
            response.put("costMs", System.currentTimeMillis() - start);
            response.put("message", "文档已成功向量化并存入私有知识库");
            return ResponseEntity.ok(response);        } catch (Exception e) {
            log.error("[API] 文档导入失败", e);
            return ResponseEntity.internalServerError().body(Map.of(
                    "success", false,
                    "error", e.getMessage()
            ));
        }
    }
}

测试上传:

# 上传设备手册
curl -X POST  
  -F "file=@设备维修手册.pdf" 
  -F "category=equipment"# 响应示例
{
  "success": true,
  "fileName": "设备维修手册.pdf",
  "costMs": 8432,
  "message": "文档已成功向量化并存入私有知识库"
}

7.6 把 MilvusV2EmbeddingStore 注册为 Bean

@Configuration
@RequiredArgsConstructor
public class EmbeddingStoreConfig {    private final MilvusClientV2 milvusClientV2;
    private final MilvusProperties properties;    @Bean
    public EmbeddingStore<TextSegment> embeddingStore() {
        return new MilvusV2EmbeddingStore(
                milvusClientV2,
                properties.collectionName()
        );
    }
}

注册成 Spring Bean 之后,KnowledgeService@RequiredArgsConstructor 自动注入,不需要额外配置。

7.7 接入 LangChain4j RAG 问答

@Configuration
@RequiredArgsConstructor
public class KnowledgeQaConfig {    private final EmbeddingModel embeddingModel;
    private final EmbeddingStore<TextSegment> embeddingStore;    @Bean
    public KnowledgeQaService knowledgeQaService(ChatLanguageModel chatModel) {
        // 1. 检索器:把向量库搜索封装成 ContentRetriever
        ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
                .embeddingModel(embeddingModel)
                .embeddingStore(embeddingStore)
                .maxResults(5)        // 召回 top5
                .minScore(0.60)       // 余弦相似度阈值,低于 0.6 视为无关
                .build();        // 2. RAG 增强器:自动把检索结果注入 Prompt
        RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder()
                .contentRetriever(retriever)
                .build();        // 3. AiService:一行代码完成"检索→注入→生成"
        return AiServices.builder(KnowledgeQaService.class)
                .chatLanguageModel(chatModel)
                .retrievalAugmentor(augmentor)
                .systemMessageProvider(chatMemoryId ->
                        "你是一个企业知识库助手。请严格基于提供的参考资料回答问题。" +
                        "如果资料中没有相关信息,请明确说明"知识库中暂无此内容",不要编造答案。")
                .build();
    }
}// 接口定义
public interface KnowledgeQaService {
    String answer(String question);
}

调用方式极简:

@RestController
@RequiredArgsConstructor
public class QaController {
    private final KnowledgeQaService qaService;    @PostMapping("/api/v1/knowledge/ask")
    public Map<String, String> ask(@RequestBody Map<String, String> req) {
        String answer = qaService.answer(req.get("question"));
        return Map.of("answer", answer);
    }
}

测试问答:

# 口语化问题(ES 无法召回)
curl -X POST  
  -H "Content-Type: application/json" 
  -d '{"question": "机器一直响怎么处理?"}'# 返回:基于知识库的答案,不是 AI 乱说的

八、效果分析:不同问题类型的召回表现

从原理层面分析,ES 和 Milvus + bge-m3 在不同类型问题上的表现差异是可以预判的:

问题类型示例ES 关键词Milvus 向量原因
A. 标准术语"液压系统压力不足" 较好 较好双方都能匹配
B. 口语化表达"机器一直响"、"轴承发热"ES 无法跨词汇匹配,向量能捕捉语义
C. 同义词替换"电机过载" vs "马达超载"向量空间里同义词是近邻
D. 精确型号"YE3-160M-4 额定功率"️ 偏弱精确字符匹配是 ES 的强项

几点结论:

  • B/C 类(口语化、同义词)是 ES 的结构性短板,向量检索在这里的提升最显著
  • D 类(精确型号、编码)ES 反而更可靠——字符串完全匹配比语义近邻更准确
  • 实际生产中,D 类可以用 bge-m3 的 Sparse 模式,或者关键词 + 向量混合召回来弥补

九、三个实战大坑

坑一:Collection 找不到,启动直接失败

现象: 应用启动报错 collection agentx_knowledge not found,或 database agentx_db not found;冷启动有时好有时坏,难复现。

原因: 跳过了三阶段初始化的正确顺序。两种典型错误写法:

  1. 直接 new MilvusClientV2(config) 连接目标库,但目标库还没创建 → Milvus 直接拒绝连接
  2. 先建库再立刻建集合,没给 Milvus 留元数据同步时间 → 建集合时 Milvus 还没"看到"新库

解决: 严格按 MilvusConfig 里的三阶段顺序执行:

阶段关键动作不能省略的原因
① provisionDatabaseadmin client 连 default 库 → 检查并创建目标库不能在不存在的库上建立业务连接
② 建立业务连接dbName=agentx_db 重新连接dbName 必须在连接时指定,不能事后切换
③ provisionCollection检查集合是否存在,不存在则建表+建索引没集合后续 insert/search 全报错

initWaitMs: 500 那行配置不能删——这是给 Milvus 元数据同步留的缓冲。


坑二:检索结果的 metadata 字段全是 null

现象: search 返回的 EmbeddingMatchtextSegment().metadata() 是空的,或者解析成 {metadata=null}file_namecategory 全丢失。

原因一: 忘记在 SearchReq 里声明 outputFields。Milvus 默认只返回 idscore,不返回其他字段,必须显式声明:

.outputFields(Arrays.asList("id", "text", "metadata"))

原因二: metadata 入库时是 JSON 字符串,检索出来类型不固定——Milvus SDK 版本不同,返回值可能是 String 也可能是 Map<String, Object>。直接强转 (Map) metadata 会偶发 ClassCastException

解决: 检查 outputFields 是否声明;metadata 反序列化必须做 instanceof String / Map 双路处理:

if (rawMetadata instanceof String s) {
    metadataMap = GSON.fromJson(s, MAP_TYPE);
} else if (rawMetadata instanceof Map<?, ?> m) {
    m.forEach((k, v) -> metadataMap.put(String.valueOf(k), String.valueOf(v)));
}

坑三:minScore 设太高,召回率骤降

现象: 系统上线后用户高频反馈"问什么都说知识库没有",但去 Attu 控制台手动查向量距离,明明有高度相关的文档存在。

原因: minScore 阈值与实际业务问题的余弦相似度分布不匹配。口语化问题与术语化文档之间,余弦相似度普遍在 0.55~0.70 区间,如果 minScore 设了 0.80,结果就是全被过滤。

解决: 调参前先关掉过滤,打日志看真实分数分布:

// 临时调试:minScore 设 0.0,观察真实分数分布
ContentRetriever debugRetriever = EmbeddingStoreContentRetriever.builder()
        .embeddingModel(embeddingModel)
        .embeddingStore(embeddingStore)
        .maxResults(20)
        .minScore(0.0)   // 先不过滤
        .build();

观察日志中的 score 分布之后再决定阈值。参考区间如下:

知识库类型minScore 推荐区间
标准文档(术语规范、合同政策)0.65 ~ 0.75
口语化知识库(运维、客服)0.50 ~ 0.60
混合场景0.55 起步,按效果迭代

原则:宁可召回多一点(用 Reranker 精排兜底),也不要漏召。漏召是黑盒,召回多是可控的。


十、总结

用一张表收尾:

技术选择为什么这样选
bge-m3 via Ollama中文 Top3,本地私有化,无需 TEI,零网络依赖
MilvusClientV2 SDK最新 V2 API,比旧版性能和稳定性更好
自制 MilvusV2EmbeddingStore解决 SDK 版本兼容问题,metadata 序列化完全可控
AUTOINDEX + COSINE让 Milvus 自适应硬件选最优索引,余弦相似度更适合语义比较
chunk 500 / overlap 50一个自然段一个切片,重叠防止边界截断
LangChain4j AiService + RAG检索→注入→生成三步封装成一行调用
Java 21 虚拟线程embedding 和 Milvus 操作都是 IO 密集,虚拟线程万级并发无压力

五个核心结论:

  1. ES 的天花板是结构性的,关键词匹配搞不定语义理解,不是调参能解决的
  2. bge-m3 + Ollama 是性价比最高的本地私有化方案,一条命令拉模型,零依赖 TEI
  3. MilvusConfig 三阶段初始化是绕不开的,任何一步缺失都会在生产环境埋雷
  4. 自制 EmbeddingStore 适配器虽然多了 200 行代码,但换来的是完整的调试能力和稳定性
  5. minScore 一定要基于真实数据分布来调,业务口语化程度越高,阈值应越低

如果你也在做企业知识库,建议这样演进:

  • 第一阶段:Ollama + bge-m3 + Milvus,快速跑通主链路
  • 第二阶段:接入 bge-reranker,精排 top20 → top5,召回率再提 10%
  • 第三阶段:Sparse + Dense 混合检索,解决精确型号查询的弱点

代码在公众号回复 「RAG」 获取,包含完整 Docker Compose、Java 源码和测试数据集。



关于作者 & 联系方式

汪旭 / Sunia — Java 全栈开发者,AI 应用工程化实践者

专注企业级 AI 落地,擅长极限资源优化,有 RAG、Agent、知识图谱方向的完整实战经验。

平台地址 / 说明
CSDNSuniaCoder-AI|13.5 万+ 阅读,RAG/Agent 系列持续更新
微信公众号搜索【SuniaCoder-AI全栈架构实战】|关注回复「RAG」获取本文完整代码包
掘金SuniaCoder-AI
知乎SuniaCoder-AI
合作咨询提供企业私有化大模型部署与定制开发(基础部署 / 企业定制 / 年度维保)欢迎私信洽谈

上一篇 → 工具系统:从@Tool注解到MCP协议,构建企业级Agent工具体系


Tags#AgentX #RAG #Milvus #bge-m3 #向量数据库 #LangChain4j #Java21 #知识库 #企业AI #Ollama

相关文章

精彩推荐