本文速览:

MilvusConfig 三阶段初始化、自制 MilvusV2EmbeddingStore、KnowledgeService 切片流水线向量检索是企业知识库的必修课,因为传统 ES 关键词检索面对一类问题时是结构性失效的——用户用口语提问,文档用术语描述,二者之间的词汇鸿沟靠倒排索引根本跨不过去。
举几个制造业知识库里典型的对照:
| 用户的提问 | 文档里的实际描述 | ES 关键词检索 |
|---|---|---|
| 机器一直响,怎么处理? | 设备出现异常噪音时…… | 无结果 |
| 轴承发热 | 轴承过热故障排查步骤…… | 无结果 |
| 设备跑不动了 | 电机转速异常降低…… | 无结果 |
| 油压不够 | 液压系统压力不足…… | 无结果 |
| 液压系统压力不足 | 液压系统压力不足…… | 命中 |
差异的本质很清楚:ES 只认字符,不认语义。"机器一直响"和"异常噪音"描述同一现象,但倒排索引里两者没有交集,结果就是召回 0 条。
这是 ES 关键词匹配的天花板:
| 痛点 | 根因 | 调参能解决吗 |
|---|---|---|
| 同义词召不回 | 倒排索引只索引原词 | 同义词词典维护成本爆炸 |
| 口语化召不回 | 工人语言和文档语言天然不同 | 不可枚举 |
| 缩写、别名召不回 | 词形归一化能力弱 | 治标不治本 |
| 排序不准 | TF-IDF 不理解语义权重 | ️ Boost 调参能缓解但不彻底 |
向量检索从根上换思路——把文字编码成高维向量,让"机器一直响"和"异常噪音"在向量空间里成为近邻,检索时找最近邻而不是找字符匹配。AgentX 选用的是 Milvus 2.x + bge-m3 + Ollama 这套组合,下面逐层拆解为什么这么选、代码怎么写、坑在哪里。
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(近似最近邻)检索。
Embedding 模型在海量语料上预训练,已经学到了大量语言规律:
这是 ES 做不到的——它只能匹配你索引里存在的词,不会"举一反三"。
市面上 Embedding 模型不少,为什么推荐 BAAI/bge-m3?
bge-m3 一个模型同时支持三种检索方式:
| 模式 | 原理 | 优势 | 典型场景 |
|---|---|---|---|
| Dense(稠密) | 语义向量,1024 维 float | 理解同义词、口语 | 语义模糊的用户提问 |
| Sparse(稀疏) | 类似 BM25,词频权重 | 精确关键词命中 | 型号、专有名词 |
| ColBERT(多向量) | Token 级细粒度交互 | 长文档精细匹配 | 合同、政策文本 |
大多数场景用 Dense 模式就够了;遇到"型号 YE3-160M-4"这种精确查询,Sparse 模式能弥补语义模型的弱点。
bge-m3 由智源研究院(BAAI)出品,是为中英双语场景专门优化的开源模型。横向看常见 Embedding 模型的定位差异:
| 模型 | 中文语义 | 英文语义 | 维度 | 开源 | 备注 |
|---|---|---|---|---|---|
| bge-m3 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 1024 | 中文 MTEB Top3,多向量模式 | |
| text-embedding-3-small | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 1536 | OpenAI 闭源,按 token 计费 | |
| nomic-embed-text | ⭐⭐⭐ | ⭐⭐⭐⭐ | 768 | 英文场景表现好 |
国内企业知识库以中文为主,再叠加私有化部署需求,bge-m3 是首选。
| 对比维度 | Milvus | pgvector | ES kNN |
|---|---|---|---|
| 专为向量设计 | 扩展插件 | 扩展插件 | |
| 十亿级向量 | ️ 百万级 | ️ 百万级 | |
| HNSW / AUTOINDEX | |||
| 分布式水平扩展 | |||
| Java SDK 成熟度 | MilvusClientV2 | ️ JDBC 扩展 | |
| 混合检索(Dense+Sparse) | ️ 需手动融合 |
pgvector 简单、和 PostgreSQL 集成好,适合数据量不大的场景。企业知识库文档量上去之后(几百万 chunk),Milvus 的 ANN 查询速度优势会非常明显。
LangChain4j 自带 MilvusEmbeddingStore,为什么 AgentX 要自己实现?
有两个原因:
第一,SDK 版本问题。 LangChain4j 早期版本封装的是 Milvus 旧版 SDK,而 io.milvus:milvus-sdk-java:2.4+ 已经升级到 V2 API(MilvusClientV2),接口完全不同。手动适配才能用上最新 SDK 的特性。
第二,metadata 序列化可控。 内置实现的 metadata 处理方式在某些场景下会有类型丢失问题。自己实现可以用 Gson 精准控制 Map<String, String> 的序列化/反序列化,排查问题也更直接。
HuggingFace TEI(Text Embeddings Inference)是生产级向量推理服务,性能很好,但有几个问题:
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 生成答案 │
│ │
└─────────────────────────────────────────────────────────────────────┘
# docker-compose.yml 中的 Milvus 服务(etcd + minio + milvus)
docker compose up -d milvus# 验证
curl
# → {"status":"ok"}# 可选:启动 Attu 管理界面(localhost:8000)
docker compose up -d attu
# 安装 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=
<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>
@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 更安全,也让意图更清晰——这是配置,不是业务对象。
这是整个 RAG 系统最容易踩坑的地方。直接 new MilvusClientV2() 连上 Milvus 就建表是典型反模式——会报 Collection not found 或 Database 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 拿业务 client | dbName 必须在连接时指定,不能切换 |
| 第三阶段 | 检查并创建集合 + 索引 | 集合不存在,后面的 insert/search 全报错 |
@Bean(destroyMethod = "close") 的作用:Spring 容器关闭时自动调用 client.close(),切断与 Milvus 的网络连接,防止资源泄漏。这一行让你不需要实现 DisposableBean 接口。
这是 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?
主要有两个原因:
R<MutationResult> 接口设计,而 Milvus 2.4+ 已经升级到 MilvusClientV2 的全新 API,强行混用会出现编译和运行时双重不稳定自实现 EmbeddingStore 接口只需要覆盖 addAll 和 search 两个核心方法,总共两百多行可读代码,换来的是完整的调试能力和稳定性——出问题时知道去哪改。
@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 还是自定义模型,调用方式完全一样。
@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": "文档已成功向量化并存入私有知识库"
}
@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 自动注入,不需要额外配置。
@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 的强项 |
几点结论:
现象: 应用启动报错 collection agentx_knowledge not found,或 database agentx_db not found;冷启动有时好有时坏,难复现。
原因: 跳过了三阶段初始化的正确顺序。两种典型错误写法:
new MilvusClientV2(config) 连接目标库,但目标库还没创建 → Milvus 直接拒绝连接解决: 严格按 MilvusConfig 里的三阶段顺序执行:
| 阶段 | 关键动作 | 不能省略的原因 |
|---|---|---|
| ① provisionDatabase | admin client 连 default 库 → 检查并创建目标库 | 不能在不存在的库上建立业务连接 |
| ② 建立业务连接 | 用 dbName=agentx_db 重新连接 | dbName 必须在连接时指定,不能事后切换 |
| ③ provisionCollection | 检查集合是否存在,不存在则建表+建索引 | 没集合后续 insert/search 全报错 |
initWaitMs: 500 那行配置不能删——这是给 Milvus 元数据同步留的缓冲。
现象: search 返回的 EmbeddingMatch 里 textSegment().metadata() 是空的,或者解析成 {metadata=null},file_name、category 全丢失。
原因一: 忘记在 SearchReq 里声明 outputFields。Milvus 默认只返回 id 和 score,不返回其他字段,必须显式声明:
.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)));
}
现象: 系统上线后用户高频反馈"问什么都说知识库没有",但去 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 密集,虚拟线程万级并发无压力 |
五个核心结论:
如果你也在做企业知识库,建议这样演进:
代码在公众号回复 「RAG」 获取,包含完整 Docker Compose、Java 源码和测试数据集。
汪旭 / Sunia — Java 全栈开发者,AI 应用工程化实践者
专注企业级 AI 落地,擅长极限资源优化,有 RAG、Agent、知识图谱方向的完整实战经验。
| 平台 | 地址 / 说明 |
|---|---|
| CSDN | SuniaCoder-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