feat(rag): dense+BM25 hybrid on MilvusClientV2, drop SDK path

Replace legacy MilvusServiceClient knowledge search/write with a single
MilvusClientV2 hybrid store (BM25 function + dense ANN + RRFRanker).
Use collection biz_hybrid and require knowledge reindex.
This commit is contained in:
zhuyongxin
2026-07-27 18:49:20 +08:00
parent 376ad0c241
commit f035538531
22 changed files with 813 additions and 936 deletions
@@ -1,51 +1,22 @@
package com.superbiz.agent.config;
import io.milvus.client.MilvusServiceClient;
import com.superbiz.agent.client.MilvusClientFactory;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PreDestroy;
/**
* Milvus 配置类
* 负责创建和管理 MilvusServiceClient Bean
* Milvus configuration notes.
*
* <p>Knowledge RAG uses {@link MilvusHybridKnowledgeStore} (MilvusClientV2) exclusively.
* Legacy {@code MilvusServiceClient} bean is no longer created for the knowledge path.</p>
*/
@Configuration
public class MilvusConfig {
private static final Logger logger = LoggerFactory.getLogger(MilvusConfig.class);
@Autowired
private MilvusClientFactory milvusClientFactory;
private MilvusServiceClient milvusClient;
/**
* 创建 MilvusServiceClient Bean
*
* @return MilvusServiceClient 实例
*/
@Bean
public MilvusServiceClient milvusServiceClient() {
logger.info("正在初始化 Milvus 客户端...");
milvusClient = milvusClientFactory.createClient();
logger.info("Milvus 客户端初始化完成");
return milvusClient;
}
/**
* 应用关闭时清理资源
*/
@PreDestroy
public void cleanup() {
if (milvusClient != null) {
logger.info("正在关闭 Milvus 客户端连接...");
milvusClient.close();
logger.info("Milvus 客户端连接已关闭");
}
public MilvusConfig() {
logger.info("Milvus knowledge path: MilvusClientV2 hybrid store only (legacy SDK search disabled)");
}
}
@@ -8,9 +8,14 @@ public class MilvusConstants {
public static final String MILVUS_DB_NAME = "default";
/**
* Milvus 集合名称
* Legacy collection name (pre hybrid). Knowledge path uses milvus.collection (default biz_hybrid).
*/
public static final String MILVUS_COLLECTION_NAME = "biz";
/**
* Default hybrid dense+BM25 collection.
*/
public static final String MILVUS_HYBRID_COLLECTION_NAME = "biz_hybrid";
/**
* 向量维度(豆包 embedding 模型的维度)
@@ -1,47 +1,36 @@
package com.superbiz.agent.controller;
import io.milvus.client.MilvusServiceClient;
import io.milvus.grpc.ShowCollectionsResponse;
import io.milvus.param.R;
import io.milvus.param.collection.ShowCollectionsParam;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import io.milvus.v2.service.collection.response.ListCollectionsResp;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* Milvus 测试控制器
* 用于测试数据库连接和数据读取
* Milvus health check using the single V2 knowledge backend.
*/
@RestController
@RequestMapping("/milvus")
public class MilvusCheckController {
@Autowired
private MilvusServiceClient milvusClient;
private MilvusHybridKnowledgeStore knowledgeStore;
/**
* 简单的健康检查
*/
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> simpleHealth() {
Map<String, Object> result = new HashMap<>();
try {
R<ShowCollectionsResponse> response = milvusClient.showCollections(
ShowCollectionsParam.newBuilder().build()
);
if (response.getStatus() == 0) {
result.put("message", "ok");
result.put("collections", response.getData().getCollectionNamesList());
return ResponseEntity.ok(result);
} else {
result.put("message", response.getMessage());
return ResponseEntity.status(503).body(result);
}
ListCollectionsResp response = knowledgeStore.client().listCollections();
result.put("message", "ok");
result.put("backend", "milvus-client-v2");
result.put("knowledgeCollection", knowledgeStore.collectionName());
result.put("collections", response == null ? null : response.getCollectionNames());
return ResponseEntity.ok(result);
} catch (Exception e) {
result.put("error", e.getMessage());
return ResponseEntity.status(503).body(result);
@@ -1,17 +1,10 @@
package com.superbiz.agent.service;
import io.milvus.client.MilvusServiceClient;
import io.milvus.grpc.MutationResult;
import io.milvus.param.R;
import io.milvus.param.RpcStatus;
import io.milvus.param.collection.LoadCollectionParam;
import io.milvus.param.dml.DeleteParam;
import io.milvus.param.dml.InsertParam;
import lombok.Getter;
import lombok.Setter;
import com.superbiz.agent.constant.MilvusConstants;
import com.superbiz.agent.dto.DocumentChunk;
import com.superbiz.agent.dto.Frontmatter;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -23,20 +16,15 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 向量索引写入服务(RAG 入库侧)。
*
* <p>负责:读文件/文档块 -&gt; 切片 -&gt; embedding -&gt; 写入 Milvus。
* 检索读取走 {@link VectorSearchService},双方通过 collection + metadata 约定衔接。</p>
*
* <h3>metadata 关键字段</h3>
* docId / _source / chunkIndex / totalChunks / title / breadcrumb / category / kb_scope
*
* <h3>embedding 文本</h3>
* 见 {@link #buildEmbeddingText(DocumentChunk)}:会把 title、breadcrumb 拼进向量文本,
* 而入库 content 字段仍保存原始 chunk 正文(检索返回的是 content,不是 embedding 拼接串)。
* <p>写入单一后端 {@link MilvusHybridKnowledgeStore}(dense + BM25 search_text)。
* 不再使用 legacy {@code MilvusServiceClient} insert/delete。</p>
*/
@Service
public class VectorIndexService {
@@ -44,7 +32,7 @@ public class VectorIndexService {
private static final Logger logger = LoggerFactory.getLogger(VectorIndexService.class);
@Autowired
private MilvusServiceClient milvusClient;
private MilvusHybridKnowledgeStore knowledgeStore;
@Autowired
private VectorEmbeddingService embeddingService;
@@ -55,33 +43,25 @@ public class VectorIndexService {
@Value("${file.upload.path}")
private String uploadPath;
/**
* 索引指定目录下的所有文件
*
* @param directoryPath 目录路径(可选,默认使用配置的上传目录)
* @return 索引结果 这里可以优化:定时重建目录下所有文件的索引
*/
public IndexingResult indexDirectory(String directoryPath) {
IndexingResult result = new IndexingResult();
result.setStartTime(LocalDateTime.now());
try {
// 使用指定目录或默认上传目录
String targetPath = (directoryPath != null && !directoryPath.trim().isEmpty())
String targetPath = (directoryPath != null && !directoryPath.trim().isEmpty())
? directoryPath : uploadPath;
Path dirPath = Paths.get(targetPath).normalize();
File directory = dirPath.toFile();
if (!directory.exists() || !directory.isDirectory()) {
throw new IllegalArgumentException("目录不存在或不是有效目录: " + targetPath);
}
result.setDirectoryPath(directory.getAbsolutePath());
// 获取所有支持的文件
File[] files = directory.listFiles((dir, name) ->
name.endsWith(".txt") || name.endsWith(".md")
File[] files = directory.listFiles((dir, name) ->
name.endsWith(".txt") || name.endsWith(".md")
);
if (files == null || files.length == 0) {
@@ -95,25 +75,20 @@ public class VectorIndexService {
result.setTotalFiles(files.length);
logger.info("开始索引目录: {}, 找到 {} 个文件", targetPath, files.length);
// 遍历并索引每个文件
for (File file : files) {
try {
indexSingleFile(file.getAbsolutePath());
result.incrementSuccessCount();
logger.info("✓ 文件索引成功: {}", file.getName());
logger.info("文件索引成功: {}", file.getName());
} catch (Exception e) {
result.incrementFailCount();
result.addFailedFile(file.getAbsolutePath(), e.getMessage());
logger.error("✗ 文件索引失败: {}", file.getName(), e);
logger.error("文件索引失败: {}", file.getName(), e);
}
}
result.setSuccess(result.getFailCount() == 0);
result.setEndTime(LocalDateTime.now());
logger.info("目录索引完成: 总数={}, 成功={}, 失败={}",
result.getTotalFiles(), result.getSuccessCount(), result.getFailCount());
return result;
} catch (Exception e) {
@@ -125,148 +100,85 @@ public class VectorIndexService {
}
}
/**
* 索引单个文件
*
* @param filePath 文件路径
* @throws Exception 索引失败时抛出异常
*/
public void indexSingleFile(String filePath) throws Exception {
Path path = Paths.get(filePath).normalize();
File file = path.toFile();
if (!file.exists() || !file.isFile()) {
throw new IllegalArgumentException("文件不存在: " + filePath);
}
logger.info("开始索引文件: {}", path);
// 1. 读取文件内容
String content = Files.readString(path);
logger.info("读取文件: {}, 内容长度: {} 字符", path, content.length());
// 2. 删除该文件的旧数据(如果存在)
deleteExistingData(path.toString());
// 3. 文档分片
List<DocumentChunk> chunks = chunkService.chunkDocument(content, path.toString());
logger.info("文档分片完成: {} -> {} 个分片", filePath, chunks.size());
// 4. 为每个分片生成向量并插入 Milvus
for (int i = 0; i < chunks.size(); i++) {
DocumentChunk chunk = chunks.get(i);
try {
// 生成向量
List<Float> vector = embeddingService.generateEmbedding(buildEmbeddingText(chunk));
// 构建元数据(包含文件信息)
Map<String, Object> metadata = buildMetadata(path.toString(), chunk, chunks.size());
// 插入到 Milvus
insertToMilvus(chunk.getContent(), vector, metadata, chunk.getChunkIndex());
logger.info("✓ 分片 {}/{} 索引成功", i + 1, chunks.size());
knowledgeStore.upsertChunk(
chunk.getContent(),
buildSearchText(chunk),
vector,
metadata,
chunk.getChunkIndex());
logger.info("分片 {}/{} 索引成功", i + 1, chunks.size());
} catch (Exception e) {
logger.error("✗ 分片 {}/{} 索引失败", i + 1, chunks.size(), e);
logger.error("分片 {}/{} 索引失败", i + 1, chunks.size(), e);
throw new RuntimeException("分片索引失败: " + e.getMessage(), e);
}
}
logger.info("文件索引完成: {}, 共 {} 个分片", filePath, chunks.size());
}
/**
* 索引文档分块(用于上传文档的向量化)
*
* @param docId 文档ID
* @param chunks 文档分块列表
* @param category 文档类别(api、domain、troubleshoot 等)
* @throws Exception 索引失败时抛出异常
*/
public void indexDocumentChunks(String docId, List<DocumentChunk> chunks, String category) throws Exception {
indexDocumentChunks(docId, chunks, category, null);
}
public void indexDocumentChunks(String docId, List<DocumentChunk> chunks, String category, Frontmatter frontmatter) throws Exception {
public void indexDocumentChunks(String docId,
List<DocumentChunk> chunks,
String category,
Frontmatter frontmatter) throws Exception {
if (chunks == null || chunks.isEmpty()) {
throw new IllegalArgumentException("文档分块列表为空");
}
logger.info("开始索引文档分块,docId: {}, 分块数: {}, 类别: {}", docId, chunks.size(), category);
// 1. 删除该文档的旧数据(如果存在)
deleteDocumentChunks(docId);
// 2. 为每个分块生成向量并插入 Milvus
for (int i = 0; i < chunks.size(); i++) {
DocumentChunk chunk = chunks.get(i);
try {
// 生成向量
List<Float> vector = embeddingService.generateEmbedding(buildEmbeddingText(chunk));
// 构建元数据(使用 docId 和 category)
Map<String, Object> metadata = buildDocumentMetadata(docId, chunk, chunks.size(), category, frontmatter);
// 插入到 Milvus
insertToMilvus(chunk.getContent(), vector, metadata, chunk.getChunkIndex());
logger.info("✓ 文档分块 {}/{} 索引成功,docId: {}", i + 1, chunks.size(), docId);
knowledgeStore.upsertChunk(
chunk.getContent(),
buildSearchText(chunk),
vector,
metadata,
chunk.getChunkIndex());
logger.info("文档分块 {}/{} 索引成功,docId: {}", i + 1, chunks.size(), docId);
} catch (Exception e) {
logger.error("✗ 文档分块 {}/{} 索引失败,docId: {}", i + 1, chunks.size(), docId, e);
logger.error("文档分块 {}/{} 索引失败,docId: {}", i + 1, chunks.size(), docId, e);
throw new RuntimeException("文档分块索引失败: " + e.getMessage(), e);
}
}
logger.info("文档索引完成,docId: {}, 共 {} 个分块,类别: {}", docId, chunks.size(), category);
}
/**
* 删除文档的所有分块(根据 docId)
*/
public void deleteDocumentChunks(String docId) {
try {
// 构建删除表达式:metadata["docId"] == "xxx"
String expr = String.format("metadata[\"docId\"] == \"%s\"", docId);
logger.info("准备删除文档旧数据,docId: {}, 表达式: {}", docId, expr);
// 确保 collection 已加载
R<RpcStatus> loadResponse = milvusClient.loadCollection(
LoadCollectionParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.build()
);
if (loadResponse.getStatus() != 0 && loadResponse.getStatus() != 65535) {
logger.warn("加载 collection 失败: {}", loadResponse.getMessage());
return;
}
DeleteParam deleteParam = DeleteParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withExpr(expr)
.build();
R<MutationResult> deleteResponse = milvusClient.delete(deleteParam);
if (deleteResponse.getStatus() == 0) {
logger.info("删除文档旧数据成功,docId: {}", docId);
} else {
logger.warn("删除文档旧数据失败,docId: {}, 原因: {}", docId, deleteResponse.getMessage());
}
knowledgeStore.deleteByDocId(docId);
logger.info("删除文档旧数据成功,docId: {}", docId);
} catch (Exception e) {
logger.warn("删除文档旧数据异常,docId: {}", docId, e);
}
}
/**
* 构建文档元数据(用于上传文档)
*/
static Map<String, Object> buildDocumentMetadata(String docId, DocumentChunk chunk, int totalChunks, String category) {
return buildDocumentMetadata(docId, chunk, totalChunks, category, null);
}
@@ -277,47 +189,37 @@ public class VectorIndexService {
String category,
Frontmatter frontmatter) {
Map<String, Object> metadata = new HashMap<>();
// 文档标识
String source = firstNonBlank(frontmatter != null ? frontmatter.getSource() : null, "upload:" + docId);
metadata.put("docId", docId);
metadata.put("_source", source); // 区分文件索引和上传文档
metadata.put("_source", source);
metadata.put("source", source);
// 分片信息
metadata.put("chunkIndex", chunk.getChunkIndex());
metadata.put("totalChunks", totalChunks);
// 标题信息
String title = firstNonBlank(chunk.getTitle(), frontmatter != null ? frontmatter.getTitle() : null);
if (title != null) {
metadata.put("title", title);
}
// 面包屑导航(完整标题层级路径)
String breadcrumb = firstNonBlank(frontmatter != null ? frontmatter.getBreadcrumb() : null, chunk.getBreadcrumb());
if (breadcrumb != null) {
metadata.put("breadcrumb", breadcrumb);
}
// 文档类别
metadata.put("category", category != null && !category.isBlank() ? category : "upload");
String kbScope = trimToNull(frontmatter != null ? frontmatter.getKbScope() : null);
if (kbScope != null) {
metadata.put("kb_scope", kbScope);
}
return metadata;
}
/**
* 构造送入 embedding 模型的文本。
*
* <p>把标题链路注入向量语义,缓解“正文片段缺上下文”导致的召回漂移。
* 注意:这里只影响向量,不影响 Milvus content 字段存储的原文。</p>
*/
static String buildEmbeddingText(DocumentChunk chunk) {
return buildSearchText(chunk);
}
/**
* Text used for BM25 {@code search_text} and dense embedding.
*/
static String buildSearchText(DocumentChunk chunk) {
String content = trimToEmpty(chunk.getContent());
String title = trimToEmpty(chunk.getTitle());
String breadcrumb = trimToEmpty(chunk.getBreadcrumb());
@@ -358,64 +260,22 @@ public class VectorIndexService {
return null;
}
/**
* 删除文件的旧数据(根据 metadata._source)
*/
private void deleteExistingData(String filePath) {
try {
// 使用统一的路径分隔符(正斜杠)用于Milvus存储,避免表达式解析错误
// 将系统路径转换为统一格式
Path path = Paths.get(filePath).normalize();
String normalizedPath = path.toString().replace(File.separator, "/");
// 构建删除表达式:metadata["_source"] == "xxx"
String expr = String.format("metadata[\"_source\"] == \"%s\"", normalizedPath);
logger.info("准备删除旧数据,路径: {}, 表达式: {}", normalizedPath, expr);
// 确保 collection 已加载(删除操作需要集合已加载)
R<RpcStatus> loadResponse = milvusClient.loadCollection(
LoadCollectionParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.build()
);
// 状态码 65535 表示集合已经加载,这不是错误
if (loadResponse.getStatus() != 0 && loadResponse.getStatus() != 65535) {
logger.warn("加载 collection 失败: {}", loadResponse.getMessage());
return;
}
DeleteParam deleteParam = DeleteParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withExpr(expr)
.build();
R<MutationResult> response = milvusClient.delete(deleteParam);
if (response.getStatus() != 0) {
logger.warn("删除旧数据时出现警告: {}", response.getMessage());
} else {
long deletedCount = response.getData().getDeleteCnt();
logger.info("✓ 已删除文件的旧数据: {}, 删除记录数: {}", normalizedPath, deletedCount);
}
knowledgeStore.deleteBySource(normalizedPath);
logger.info("已删除文件的旧数据: {}", normalizedPath);
} catch (Exception e) {
logger.warn("删除旧数据失败(可能是首次索引): {}", e.getMessage());
}
}
/**
* 构建元数据(包含文件信息)
*/
private Map<String, Object> buildMetadata(String filePath, DocumentChunk chunk, int totalChunks) {
Map<String, Object> metadata = new HashMap<>();
// 标准化路径:使用统一的路径分隔符(正斜杠)用于存储,确保跨平台一致性
Path path = Paths.get(filePath).normalize();
String normalizedPath = path.toString().replace(File.separator, "/");
// 文件信息
Path fileName = path.getFileName();
String fileNameStr = fileName != null ? fileName.toString() : "";
String extension = "";
@@ -425,42 +285,28 @@ public class VectorIndexService {
}
metadata.put("_source", normalizedPath);
metadata.put("source", normalizedPath);
metadata.put("_extension", extension);
metadata.put("_file_name", fileNameStr);
// 提取类别(从文件路径中提取目录名)
String category = extractCategory(normalizedPath);
if (category != null && !category.isEmpty()) {
metadata.put("category", category);
}
// 分片信息
metadata.put("chunkIndex", chunk.getChunkIndex());
metadata.put("totalChunks", totalChunks);
// 标题信息
if (chunk.getTitle() != null && !chunk.getTitle().isEmpty()) {
metadata.put("title", chunk.getTitle());
}
// 面包屑导航(完整标题层级路径)
if (chunk.getBreadcrumb() != null && !chunk.getBreadcrumb().isEmpty()) {
metadata.put("breadcrumb", chunk.getBreadcrumb());
}
return metadata;
}
/**
* 从文件路径中提取类别
* 例如:aiops-docs/api/redis-api.md → "api"
*/
private String extractCategory(String filePath) {
try {
// 标准化路径分隔符
String normalized = filePath.replace("\\", "/");
// 查找 aiops-docs/ 后的第一级目录
int docsIndex = normalized.indexOf("aiops-docs/");
if (docsIndex >= 0) {
String afterDocs = normalized.substring(docsIndex + "aiops-docs/".length());
@@ -469,13 +315,10 @@ public class VectorIndexService {
return afterDocs.substring(0, slashIndex);
}
}
// 如果没有 aiops-docs,返回第一级目录
int firstSlash = normalized.indexOf("/");
if (firstSlash > 0) {
return normalized.substring(0, firstSlash);
}
return null;
} catch (Exception e) {
logger.warn("提取类别失败,路径: {}", filePath, e);
@@ -483,68 +326,6 @@ public class VectorIndexService {
}
}
/**
* 插入向量到 Milvus
*/
private void insertToMilvus(String content, List<Float> vector,
Map<String, Object> metadata, int chunkIndex) throws Exception {
try {
// 确保 collection 已加载
R<RpcStatus> loadResponse = milvusClient.loadCollection(
LoadCollectionParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.build()
);
if (loadResponse.getStatus() != 0 && loadResponse.getStatus() != 65535) {
throw new RuntimeException("加载 collection 失败: " + loadResponse.getMessage());
}
// 生成唯一 ID(使用 _source + 分片索引)
String source = (String) metadata.get("_source");
String id = UUID.nameUUIDFromBytes((source + "_" + chunkIndex).getBytes()).toString();
// 构建字段数据
List<InsertParam.Field> fields = new ArrayList<>();
// ID 字段
fields.add(new InsertParam.Field("id", Collections.singletonList(id)));
// content 字段
fields.add(new InsertParam.Field("content", Collections.singletonList(content)));
// vector 字段
fields.add(new InsertParam.Field("vector", Collections.singletonList(vector)));
// metadata 字段(JSON 对象)
com.google.gson.Gson gson = new com.google.gson.Gson();
com.google.gson.JsonObject metadataJson = gson.toJsonTree(metadata).getAsJsonObject();
fields.add(new InsertParam.Field("metadata", Collections.singletonList(metadataJson)));
// 构建插入参数
InsertParam insertParam = InsertParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withFields(fields)
.build();
// 执行插入
R<MutationResult> insertResponse = milvusClient.insert(insertParam);
if (insertResponse.getStatus() != 0) {
throw new RuntimeException("插入向量失败: " + insertResponse.getMessage());
}
logger.debug("向量插入成功: id={}, source={}, chunk={}", id, source, chunkIndex);
} catch (Exception e) {
logger.error("插入向量到 Milvus 失败", e);
throw e;
}
}
/**
* 索引结果类
*/
@Getter
public static class IndexingResult {
@Setter
@@ -1,53 +1,23 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.constant.MilvusConstants;
import io.milvus.client.MilvusServiceClient;
import io.milvus.grpc.SearchResults;
import io.milvus.param.R;
import io.milvus.param.dml.SearchParam;
import io.milvus.response.SearchResultsWrapper;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Locale;
/**
* L1 向量检索门面。
* Knowledge vector retrieval facade.
*
* <p>对上层({@link KnowledgeDocumentRetriever})只暴露稳定 API:
* {@link #searchSimilarDocuments(String, int, String)}。底层实现可切换:</p>
* <ul>
* <li>{@code sdk}:Milvus SDK 直连</li>
* <li>{@code spring} / {@code spring-ai}:Spring AI VectorStore</li>
* <li>{@code auto}(默认):先 Spring AI,失败再 fallback 到 SDK</li>
* </ul>
*
* <h3>分数兼容约定</h3>
* 后处理 {@code KnowledgeEvidencePostProcessor} 按“L2 距离越小越相似”归一化。
* 因此本类统一把 {@link SearchResult#score} 填成兼容 L2 距离:
* <ul>
* <li>SDK 路径:直接用 Milvus L2 score</li>
* <li>Spring AI 路径:优先 metadata.distance;否则把 similarity 映射为
* {@code (1 - similarity) * maxL2Distance}</li>
* </ul>
*
* <h3>过滤</h3>
* 可选 category + 全局 {@code retrieval.kb-scope}。两条实现路径的 filter 语法不同,
* 但语义一致:只在对应 metadata 字段上收窄。
* <p><b>Single backend:</b> {@link MilvusHybridKnowledgeStore} (Milvus Java SDK v2).
* Legacy {@code MilvusServiceClient} search and Spring AI VectorStore routing for
* {@code lookup_knowledge} have been removed.</p>
*/
@Service
public class VectorSearchService {
@@ -55,276 +25,42 @@ public class VectorSearchService {
private static final Logger logger = LoggerFactory.getLogger(VectorSearchService.class);
@Autowired
private MilvusServiceClient milvusClient;
private MilvusHybridKnowledgeStore knowledgeStore;
@Autowired
private VectorEmbeddingService embeddingService;
@Autowired
private ObjectProvider<VectorStore> vectorStoreProvider;
@Autowired
private ObjectMapper objectMapper;
/** 检索实现路由:auto / spring / spring-ai / sdk。 */
@Value("${retrieval.vector-store.mode:auto}")
private String vectorStoreMode = "auto";
/** similarity -> 兼容 L2 时使用的距离上限,需与后处理归一化配置一致。 */
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance = 2.0;
/** 非空时只检索该 kb_scope 下的 chunk(多租户/多知识库隔离)。 */
@Value("${retrieval.kb-scope:}")
private String kbScope = "";
/**
* dense | hybrid
*/
@Value("${retrieval.search.mode:dense}")
private String searchMode = "dense";
public List<SearchResult> searchSimilarDocuments(String query, int topK) {
return searchSimilarDocuments(query, topK, null);
}
/**
* 按 query 召回 topK 相似文档片段。
*
* @param query 检索文本(会再 embedding)
* @param topK 返回条数
* @param category 可选 category 过滤;null/blank 表示不过滤
*/
public List<SearchResult> searchSimilarDocuments(String query, int topK, String category) {
String mode = vectorStoreMode == null ? "auto" : vectorStoreMode.trim().toLowerCase();
return switch (mode) {
case "sdk" -> searchSimilarDocumentsWithSdk(query, topK, category);
case "spring", "spring-ai" -> searchSimilarDocumentsWithVectorStore(query, topK, category);
case "auto" -> searchWithAutoFallback(query, topK, category);
default -> {
logger.warn("Unknown retrieval.vector-store.mode={}, using auto mode", vectorStoreMode);
yield searchWithAutoFallback(query, topK, category);
}
};
String mode = searchMode == null ? "dense" : searchMode.trim().toLowerCase(Locale.ROOT);
List<Float> queryVector = embeddingService.generateQueryVector(query);
if ("hybrid".equals(mode)) {
logger.info("Hybrid dense+BM25 search topK={} category={} collection={}",
topK, category, knowledgeStore.collectionName());
return knowledgeStore.searchHybrid(query, queryVector, topK, category);
}
logger.info("Dense search topK={} category={} collection={}",
topK, category, knowledgeStore.collectionName());
return knowledgeStore.searchDense(query, queryVector, topK, category);
}
/** auto:Spring AI 优先,任意异常则降级 SDK(保证检索可用性)。 */
private List<SearchResult> searchWithAutoFallback(String query, int topK, String category) {
try {
return searchSimilarDocumentsWithVectorStore(query, topK, category);
} catch (Exception e) {
logger.warn("Spring AI VectorStore retrieval failed, falling back to Milvus SDK: errorType={}",
e.getClass().getSimpleName());
return searchSimilarDocumentsWithSdk(query, topK, category);
}
}
/**
* Spring AI VectorStore 路径。
* 注意:写入索引仍主要由 Milvus SDK 完成;这里是读适配,依赖双方 metadata schema 一致。
*/
List<SearchResult> searchSimilarDocumentsWithVectorStore(String query, int topK, String category) {
VectorStore vectorStore = vectorStoreProvider != null ? vectorStoreProvider.getIfAvailable() : null;
if (vectorStore == null) {
throw new IllegalStateException("Spring AI VectorStore bean is unavailable");
}
logger.info("Starting Spring AI VectorStore search: topK={}, category={}, kbScope={}",
topK, category, effectiveKbScope());
SearchRequest.Builder builder = SearchRequest.builder()
.query(query)
.topK(topK)
// 不做框架层阈值截断,相关性判断交给后处理
.similarityThresholdAll();
String filterExpression = buildSpringAiFilterExpression(category);
if (filterExpression != null) {
builder.filterExpression(filterExpression);
logger.info("Spring AI VectorStore metadata filter: {}", filterExpression);
}
List<Document> documents = vectorStore.similaritySearch(builder.build());
List<SearchResult> results = new ArrayList<>();
for (Document document : documents) {
SearchResult result = new SearchResult();
result.setId(document.getId());
result.setContent(document.getText());
result.setMetadata(toJson(document.getMetadata()));
result.setRawScore(document.getScore());
result.setScoreLabel("similarity");
// 下游统一按 L2 距离消费,这里做兼容映射
result.setScore(toCompatibleL2Distance(document));
results.add(result);
}
logger.info("Spring AI VectorStore search complete, candidates={}", results.size());
return results;
}
/** Milvus SDK 原生 L2 检索路径。 */
List<SearchResult> searchSimilarDocumentsWithSdk(String query, int topK, String category) {
try {
logger.info("Starting Milvus SDK search: topK={}, category={}, kbScope={}",
topK, category, effectiveKbScope());
List<Float> queryVector = embeddingService.generateQueryVector(query);
logger.debug("Query vector generated, dimension={}", queryVector.size());
SearchParam.Builder searchParamBuilder = SearchParam.newBuilder()
.withCollectionName(MilvusConstants.MILVUS_COLLECTION_NAME)
.withVectorFieldName("vector")
.withVectors(Collections.singletonList(queryVector))
.withTopK(topK)
.withMetricType(io.milvus.param.MetricType.L2)
.withOutFields(List.of("id", "content", "metadata"))
.withParams("{\"nprobe\":10}");
String expr = buildSdkFilterExpression(category);
if (expr != null) {
searchParamBuilder.withExpr(expr);
logger.info("Milvus SDK metadata filter: {}", expr);
}
R<SearchResults> searchResponse = milvusClient.search(searchParamBuilder.build());
if (searchResponse.getStatus() != 0) {
throw new RuntimeException("Vector search failed: " + searchResponse.getMessage());
}
SearchResultsWrapper wrapper = new SearchResultsWrapper(searchResponse.getData().getResults());
List<SearchResult> results = new ArrayList<>();
for (int i = 0; i < wrapper.getRowRecords(0).size(); i++) {
SearchResult result = new SearchResult();
result.setId((String) wrapper.getIDScore(0).get(i).get("id"));
result.setContent((String) wrapper.getFieldData("content", 0).get(i));
// Milvus L2:数值越小越相似
result.setScore(wrapper.getIDScore(0).get(i).getScore());
result.setRawScore((double) result.getScore());
result.setScoreLabel("l2_distance");
Object metadataObj = wrapper.getFieldData("metadata", 0).get(i);
if (metadataObj != null) {
result.setMetadata(metadataObj.toString());
}
results.add(result);
}
logger.info("Milvus SDK search complete, candidates={}", results.size());
return results;
} catch (Exception e) {
logger.error("Milvus SDK vector search failed: errorType={}", e.getClass().getSimpleName());
throw new RuntimeException("Vector search failed: " + e.getMessage(), e);
}
}
/** 优先使用 metadata.distance;否则把 similarity 映射为兼容 L2。 */
private float toCompatibleL2Distance(Document document) {
Double distance = extractDistance(document.getMetadata());
if (distance != null) {
return distance.floatValue();
}
return toCompatibleL2Distance(document.getScore());
}
/**
* similarity ∈ [0,1] 越大越相似 -> 兼容 L2 距离。
* 映射:distance = (1 - similarity) * maxL2Distance
*/
private float toCompatibleL2Distance(Double similarity) {
if (similarity == null) {
return (float) maxL2Distance;
}
double bounded = Math.max(0.0, Math.min(1.0, similarity));
return (float) ((1.0 - bounded) * maxL2Distance);
}
private Double extractDistance(Map<String, Object> metadata) {
if (metadata == null) {
return null;
}
Object value = metadata.get("distance");
if (value instanceof Number number) {
return number.doubleValue();
}
if (value instanceof String text) {
try {
return Double.parseDouble(text);
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
private String toJson(Map<String, Object> metadata) {
if (metadata == null || metadata.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(metadata);
} catch (JsonProcessingException e) {
return metadata.toString();
}
}
private String escapeFilterValue(String value) {
return value.replace("'", "\\'");
}
/** Spring AI filter DSL,例如:category == 'mysql' && kb_scope == 'prod' */
String buildSpringAiFilterExpression(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
if (categoryFilter != null) {
parts.add("category == '" + escapeFilterValue(categoryFilter) + "'");
}
String scopeFilter = effectiveKbScope();
if (scopeFilter != null) {
parts.add("kb_scope == '" + escapeFilterValue(scopeFilter) + "'");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
/** Milvus boolean expr,字段在 JSON metadata 内。 */
String buildSdkFilterExpression(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
if (categoryFilter != null) {
parts.add("metadata[\"category\"] == \"" + escapeMilvusString(categoryFilter) + "\"");
}
String scopeFilter = effectiveKbScope();
if (scopeFilter != null) {
parts.add("metadata[\"kb_scope\"] == \"" + escapeMilvusString(scopeFilter) + "\"");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
private String effectiveKbScope() {
return trimToNull(kbScope);
}
private String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private String escapeMilvusString(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
/**
* 统一检索命中结构。
*
* <ul>
* <li>{@code score}:兼容 L2 距离,供后处理 normalizeL2</li>
* <li>{@code rawScore}:底层原始分(similarity 或 l2)</li>
* <li>{@code scoreLabel}:解释 rawScore 语义</li>
* <li>{@code metadata}:JSON 字符串,含 docId/chunkIndex/title 等</li>
* </ul>
*/
@Setter
@Getter
public static class SearchResult {
private String id;
private String content;
/**
* Compatibility score used by existing lookup relevance normalization.
* SDK mode keeps L2 distance; VectorStore mode prefers the Milvus
* distance metadata and falls back to similarity mapping.
* Compatibility score for post-process normalizeL2.
* Dense path: L2 distance. Hybrid path: dense L2 when available.
*/
private float score;
private Double rawScore;
@@ -0,0 +1,426 @@
package com.superbiz.agent.service.milvus;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.superbiz.agent.config.MilvusProperties;
import com.superbiz.agent.constant.MilvusConstants;
import com.superbiz.agent.service.VectorSearchService;
import io.milvus.common.clientenum.FunctionType;
import io.milvus.v2.client.ConnectConfig;
import io.milvus.v2.client.MilvusClientV2;
import io.milvus.v2.common.DataType;
import io.milvus.v2.common.IndexParam;
import io.milvus.v2.service.collection.request.AddFieldReq;
import io.milvus.v2.service.collection.request.CreateCollectionReq;
import io.milvus.v2.service.collection.request.HasCollectionReq;
import io.milvus.v2.service.collection.request.LoadCollectionReq;
import io.milvus.v2.service.index.request.CreateIndexReq;
import io.milvus.v2.service.vector.request.AnnSearchReq;
import io.milvus.v2.service.vector.request.DeleteReq;
import io.milvus.v2.service.vector.request.HybridSearchReq;
import io.milvus.v2.service.vector.request.InsertReq;
import io.milvus.v2.service.vector.request.SearchReq;
import io.milvus.v2.service.vector.request.data.BaseVector;
import io.milvus.v2.service.vector.request.data.EmbeddedText;
import io.milvus.v2.service.vector.request.data.FloatVec;
import io.milvus.v2.service.vector.request.ranker.RRFRanker;
import io.milvus.v2.service.vector.response.SearchResp;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Single knowledge vector backend (Milvus Java SDK v2).
*
* <p>Supports dense ANN and dense+BM25 hybrid search via {@code hybridSearch} + {@link RRFRanker}.
* Legacy {@code MilvusServiceClient} search is not used.</p>
*/
@Service
public class MilvusHybridKnowledgeStore {
private static final Logger log = LoggerFactory.getLogger(MilvusHybridKnowledgeStore.class);
private static final Gson GSON = new Gson();
public static final String FIELD_ID = "id";
public static final String FIELD_CONTENT = "content";
public static final String FIELD_SEARCH_TEXT = "search_text";
public static final String FIELD_SPARSE = "sparse_vector";
public static final String FIELD_DENSE = "vector";
public static final String FIELD_METADATA = "metadata";
private final MilvusProperties milvusProperties;
@Value("${milvus.collection:biz_hybrid}")
private String collectionName = "biz_hybrid";
@Value("${retrieval.hybrid.rrf-k:60}")
private int rrfK = 60;
@Value("${retrieval.normalization.max-l2-distance:2.0}")
private double maxL2Distance = 2.0;
@Value("${retrieval.kb-scope:}")
private String kbScope = "";
private volatile MilvusClientV2 client;
public MilvusHybridKnowledgeStore(MilvusProperties milvusProperties) {
this.milvusProperties = milvusProperties;
}
public synchronized MilvusClientV2 client() {
if (client == null) {
client = connect();
ensureCollection(client);
loadCollection(client);
}
return client;
}
public String collectionName() {
return collectionName;
}
public void upsertChunk(String content,
String searchText,
List<Float> denseVector,
Map<String, Object> metadata,
int chunkIndex) {
String source = metadata == null ? null : stringVal(metadata.get("_source"));
if (source == null) {
source = metadata == null ? null : stringVal(metadata.get("source"));
}
if (source == null) {
source = metadata == null ? null : stringVal(metadata.get("docId"));
}
String idSeed = (source == null ? "chunk" : source) + "_" + chunkIndex;
String id = UUID.nameUUIDFromBytes(idSeed.getBytes()).toString();
JsonObject row = new JsonObject();
row.addProperty(FIELD_ID, id);
row.addProperty(FIELD_CONTENT, content == null ? "" : content);
row.addProperty(FIELD_SEARCH_TEXT, searchText == null ? "" : searchText);
row.add(FIELD_DENSE, GSON.toJsonTree(denseVector));
row.add(FIELD_METADATA, GSON.toJsonTree(metadata == null ? Map.of() : metadata));
client().insert(InsertReq.builder()
.collectionName(collectionName)
.data(List.of(row))
.build());
}
public void deleteByDocId(String docId) {
if (docId == null || docId.isBlank()) {
return;
}
String filter = "metadata[\"docId\"] == \"" + escapeFilter(docId) + "\"";
client().delete(DeleteReq.builder()
.collectionName(collectionName)
.filter(filter)
.build());
}
public void deleteBySource(String sourcePath) {
if (sourcePath == null || sourcePath.isBlank()) {
return;
}
String normalized = sourcePath.replace('\\', '/');
String filter = "metadata[\"_source\"] == \"" + escapeFilter(normalized) + "\"";
client().delete(DeleteReq.builder()
.collectionName(collectionName)
.filter(filter)
.build());
}
public List<VectorSearchService.SearchResult> searchDense(String queryEmbeddingText,
List<Float> queryVector,
int topK,
String category) {
String filter = buildFilter(category);
SearchReq.SearchReqBuilder builder = SearchReq.builder()
.collectionName(collectionName)
.annsField(FIELD_DENSE)
.data(List.of(new FloatVec(queryVector)))
.topK(topK)
.outputFields(List.of(FIELD_ID, FIELD_CONTENT, FIELD_METADATA))
.metricType(IndexParam.MetricType.L2);
if (filter != null) {
builder.filter(filter);
}
SearchResp resp = client().search(builder.build());
return toSearchResults(resp, "l2_distance", false);
}
/**
* Dense + BM25 hybrid fused by RRF. Dense L2 scores are attached when the same id
* appears in a parallel dense search so quality thresholds stay meaningful.
*/
public List<VectorSearchService.SearchResult> searchHybrid(String queryText,
List<Float> queryVector,
int topK,
String category) {
String filter = buildFilter(category);
int pathTopK = Math.max(topK, 10);
AnnSearchReq.AnnSearchReqBuilder denseAnn = AnnSearchReq.builder()
.vectorFieldName(FIELD_DENSE)
.vectors(List.of((BaseVector) new FloatVec(queryVector)))
.topK(pathTopK)
.metricType(IndexParam.MetricType.L2)
.params("{\"nprobe\":10}");
if (filter != null) {
denseAnn.filter(filter);
}
AnnSearchReq.AnnSearchReqBuilder sparseAnn = AnnSearchReq.builder()
.vectorFieldName(FIELD_SPARSE)
.vectors(List.of((BaseVector) new EmbeddedText(queryText == null ? "" : queryText)))
.topK(pathTopK)
.metricType(IndexParam.MetricType.BM25);
if (filter != null) {
sparseAnn.filter(filter);
}
HybridSearchReq hybridReq = HybridSearchReq.builder()
.collectionName(collectionName)
.searchRequests(List.of(denseAnn.build(), sparseAnn.build()))
.ranker(new RRFRanker(Math.max(1, rrfK)))
.topK(topK)
.outFields(List.of(FIELD_ID, FIELD_CONTENT, FIELD_METADATA))
.build();
SearchResp hybridResp = client().hybridSearch(hybridReq);
List<VectorSearchService.SearchResult> fused = toSearchResults(hybridResp, "rrf_fused", true);
// Attach dense-compatible L2 when available.
Map<String, Float> denseScores = new HashMap<>();
try {
for (VectorSearchService.SearchResult denseHit :
searchDense(queryText, queryVector, pathTopK, category)) {
if (denseHit.getId() != null) {
denseScores.put(denseHit.getId(), denseHit.getScore());
}
}
} catch (Exception e) {
log.warn("Dense score enrichment failed: {}", e.getMessage());
}
for (VectorSearchService.SearchResult hit : fused) {
Float dense = denseScores.get(hit.getId());
if (dense != null) {
hit.setScore(dense);
hit.setScoreLabel("l2_distance");
} else {
// BM25-only hit: treat as weak for legacy thresholds
hit.setScore((float) maxL2Distance);
hit.setScoreLabel("bm25_only_no_dense");
}
}
return fused;
}
private List<VectorSearchService.SearchResult> toSearchResults(SearchResp resp,
String scoreLabel,
boolean fused) {
List<VectorSearchService.SearchResult> out = new ArrayList<>();
if (resp == null || resp.getSearchResults() == null || resp.getSearchResults().isEmpty()) {
return out;
}
List<SearchResp.SearchResult> first = resp.getSearchResults().get(0);
if (first == null) {
return out;
}
for (SearchResp.SearchResult row : first) {
VectorSearchService.SearchResult mapped = new VectorSearchService.SearchResult();
Object id = row.getId();
mapped.setId(id == null ? null : String.valueOf(id));
Map<String, Object> entity = row.getEntity() == null ? Map.of() : row.getEntity();
Object content = entity.get(FIELD_CONTENT);
mapped.setContent(content == null ? null : String.valueOf(content));
Object metadata = entity.get(FIELD_METADATA);
if (metadata instanceof JsonObject jsonObject) {
mapped.setMetadata(jsonObject.toString());
} else if (metadata instanceof Map<?, ?> map) {
mapped.setMetadata(GSON.toJson(map));
} else if (metadata != null) {
mapped.setMetadata(String.valueOf(metadata));
}
Float score = row.getScore();
mapped.setRawScore(score == null ? null : score.doubleValue());
mapped.setScoreLabel(scoreLabel);
if (fused) {
// temporary; may be overwritten with dense L2
mapped.setScore(score == null ? (float) maxL2Distance : invertUnknownScore(score));
} else {
mapped.setScore(score == null ? (float) maxL2Distance : score);
}
out.add(mapped);
}
return out;
}
private float invertUnknownScore(float score) {
// RRF-like small scores: map higher better -> small L2-like distance
double bounded = Math.max(0.0, Math.min(1.0, score));
if (score > 1.0f) {
// already distance-like
return score;
}
return (float) ((1.0 - bounded) * maxL2Distance);
}
private String buildFilter(String category) {
List<String> parts = new ArrayList<>();
String categoryFilter = trimToNull(category);
if (categoryFilter != null) {
parts.add("metadata[\"category\"] == \"" + escapeFilter(categoryFilter) + "\"");
}
String scope = trimToNull(kbScope);
if (scope != null) {
parts.add("metadata[\"kb_scope\"] == \"" + escapeFilter(scope) + "\"");
}
return parts.isEmpty() ? null : String.join(" && ", parts);
}
private MilvusClientV2 connect() {
String uri;
if (milvusProperties.isSecure() || milvusProperties.getPort() == 443) {
uri = "https://" + milvusProperties.getHost() + ":" + milvusProperties.getPort();
} else {
uri = "http://" + milvusProperties.getHost() + ":" + milvusProperties.getPort();
}
ConnectConfig.ConnectConfigBuilder builder = ConnectConfig.builder()
.uri(uri)
.connectTimeoutMs(milvusProperties.getTimeout() == null ? 10000L : milvusProperties.getTimeout());
if (milvusProperties.getToken() != null && !milvusProperties.getToken().isBlank()) {
builder.token(milvusProperties.getToken());
builder.secure(true);
} else if (milvusProperties.getUsername() != null && !milvusProperties.getUsername().isBlank()) {
builder.username(milvusProperties.getUsername());
builder.password(milvusProperties.getPassword());
}
if (milvusProperties.getDatabase() != null && !milvusProperties.getDatabase().isBlank()) {
builder.dbName(milvusProperties.getDatabase());
}
log.info("Connecting MilvusClientV2 uri={} db={} collection={}",
uri, milvusProperties.getDatabase(), collectionName);
return new MilvusClientV2(builder.build());
}
private void ensureCollection(MilvusClientV2 milvusClient) {
Boolean exists = milvusClient.hasCollection(HasCollectionReq.builder()
.collectionName(collectionName)
.build());
if (Boolean.TRUE.equals(exists)) {
log.info("Hybrid collection '{}' already exists", collectionName);
return;
}
log.info("Creating hybrid collection '{}'", collectionName);
CreateCollectionReq.CollectionSchema schema = milvusClient.createSchema();
schema.setEnableDynamicField(false);
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_ID)
.dataType(DataType.VarChar)
.maxLength(MilvusConstants.ID_MAX_LENGTH)
.isPrimaryKey(true)
.autoID(false)
.build());
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_CONTENT)
.dataType(DataType.VarChar)
.maxLength(MilvusConstants.CONTENT_MAX_LENGTH)
.build());
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_SEARCH_TEXT)
.dataType(DataType.VarChar)
.maxLength(MilvusConstants.CONTENT_MAX_LENGTH)
.enableAnalyzer(true)
.build());
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_SPARSE)
.dataType(DataType.SparseFloatVector)
.build());
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_DENSE)
.dataType(DataType.FloatVector)
.dimension(milvusProperties.getVectorDim())
.build());
schema.addField(AddFieldReq.builder()
.fieldName(FIELD_METADATA)
.dataType(DataType.JSON)
.build());
schema.addFunction(CreateCollectionReq.Function.builder()
.functionType(FunctionType.BM25)
.name("bm25_fn")
.inputFieldNames(List.of(FIELD_SEARCH_TEXT))
.outputFieldNames(List.of(FIELD_SPARSE))
.build());
milvusClient.createCollection(CreateCollectionReq.builder()
.collectionName(collectionName)
.collectionSchema(schema)
.description("Knowledge hybrid dense+BM25 collection")
.numShards(MilvusConstants.DEFAULT_SHARD_NUMBER)
.build());
List<IndexParam> indexes = List.of(
IndexParam.builder()
.fieldName(FIELD_DENSE)
.indexType(IndexParam.IndexType.IVF_FLAT)
.metricType(IndexParam.MetricType.L2)
.extraParams(Map.of("nlist", 128))
.build(),
IndexParam.builder()
.fieldName(FIELD_SPARSE)
.indexType(IndexParam.IndexType.SPARSE_INVERTED_INDEX)
.metricType(IndexParam.MetricType.BM25)
.build()
);
milvusClient.createIndex(CreateIndexReq.builder()
.collectionName(collectionName)
.indexParams(indexes)
.build());
log.info("Hybrid collection '{}' created with dense+BM25 indexes", collectionName);
}
private void loadCollection(MilvusClientV2 milvusClient) {
milvusClient.loadCollection(LoadCollectionReq.builder()
.collectionName(collectionName)
.build());
}
@PreDestroy
public void close() {
if (client != null) {
try {
client.close();
} catch (Exception e) {
log.warn("Error closing MilvusClientV2: {}", e.getMessage());
}
}
}
private static String escapeFilter(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private static String stringVal(Object value) {
return value == null ? null : String.valueOf(value);
}
}
@@ -2,22 +2,19 @@ package com.superbiz.agent.service.retrieval;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.service.VectorSearchService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* {@link KnowledgeSearchPort} backed by the existing vector search facade.
* {@link KnowledgeSearchPort} adapter.
*
* <ul>
* <li>{@code DENSE}: single dense path (legacy behavior)</li>
* <li>{@code HYBRID}: dense unfiltered + optional dense filtered + lexical rank, fused by RRF</li>
* </ul>
* <p>Delegates to {@link VectorSearchService}, which is backed solely by
* Milvus V2 dense / dense+BM25 hybrid store. Mode selection lives in
* {@code retrieval.search.mode}.</p>
*/
@Component
public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
@@ -25,21 +22,6 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
private final VectorSearchService vectorSearchService;
private final ObjectMapper objectMapper;
@Value("${retrieval.search.mode:dense}")
private String configuredMode = "dense";
@Value("${retrieval.hybrid.rrf-k:60}")
private int rrfK = 60;
@Value("${retrieval.hybrid.weight.dense-unfiltered:1.0}")
private double weightDenseUnfiltered = 1.0;
@Value("${retrieval.hybrid.weight.dense-filtered:1.0}")
private double weightDenseFiltered = 1.0;
@Value("${retrieval.hybrid.weight.lexical:1.0}")
private double weightLexical = 1.0;
public VectorKnowledgeSearchAdapter(VectorSearchService vectorSearchService, ObjectMapper objectMapper) {
this.vectorSearchService = vectorSearchService;
this.objectMapper = objectMapper;
@@ -47,100 +29,14 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
@Override
public List<KnowledgeSearchHit> search(KnowledgeSearchRequest request) {
KnowledgeSearchMode mode = resolveMode(request.mode());
if (mode == KnowledgeSearchMode.HYBRID) {
return searchHybrid(request);
}
return searchDense(request.query(), request.topK(), request.categoryFilter());
}
private List<KnowledgeSearchHit> searchDense(String query, int topK, String categoryFilter) {
// Mode is global on VectorSearchService; request.mode is advisory for future per-call overrides.
List<VectorSearchService.SearchResult> results = vectorSearchService.searchSimilarDocuments(
query, topK, categoryFilter);
request.query(),
request.topK(),
request.categoryFilter());
return toHits(results);
}
private List<KnowledgeSearchHit> searchHybrid(KnowledgeSearchRequest request) {
String query = request.query();
int topK = request.topK();
String category = trimToNull(request.categoryFilter());
List<KnowledgeSearchHit> unfiltered = searchDense(query, topK, null);
List<KnowledgeSearchHit> filtered = category == null
? List.of()
: searchDense(query, topK, category);
Map<String, KnowledgeSearchHit> unionByKey = new LinkedHashMap<>();
for (KnowledgeSearchHit hit : unfiltered) {
unionByKey.putIfAbsent(hit.evidenceKey(), hit);
}
for (KnowledgeSearchHit hit : filtered) {
unionByKey.putIfAbsent(hit.evidenceKey(), hit);
}
List<KnowledgeSearchHit> union = new ArrayList<>(unionByKey.values());
List<KnowledgeSearchHit> lexical = LexicalRanker.rank(query, union);
List<RrfFusion.RankedPath<KnowledgeSearchHit>> paths = new ArrayList<>();
paths.add(new RrfFusion.RankedPath<>("dense_unfiltered", unfiltered, weightDenseUnfiltered));
if (!filtered.isEmpty()) {
paths.add(new RrfFusion.RankedPath<>("dense_filtered", filtered, weightDenseFiltered));
}
if (!lexical.isEmpty()) {
paths.add(new RrfFusion.RankedPath<>("lexical", lexical, weightLexical));
}
List<RrfFusion.Scored<KnowledgeSearchHit>> fused = RrfFusion.fuse(
paths, rrfK, KnowledgeSearchHit::evidenceKey);
List<KnowledgeSearchHit> ordered = new ArrayList<>();
int rank = 1;
for (RrfFusion.Scored<KnowledgeSearchHit> scored : fused) {
if (ordered.size() >= topK) {
break;
}
KnowledgeSearchHit base = scored.item();
Map<String, String> metadata = new LinkedHashMap<>(
base.metadata() == null ? Map.of() : base.metadata());
metadata.put("fusedScore", Double.toString(scored.rrfScore()));
metadata.put("fusionRanks", scored.ranks().toString());
metadata.put("fusionRank", Integer.toString(rank));
ordered.add(new KnowledgeSearchHit(
base.id(),
base.content(),
base.score(),
base.rawScore(),
base.scoreLabel(),
base.metadataJson(),
metadata,
base.docId(),
base.chunkIndex(),
base.evidenceKey(),
base.source(),
base.title(),
base.breadcrumb(),
rank
));
rank++;
}
return ordered;
}
private KnowledgeSearchMode resolveMode(KnowledgeSearchMode requestMode) {
if (requestMode == KnowledgeSearchMode.HYBRID) {
return KnowledgeSearchMode.HYBRID;
}
if (requestMode == KnowledgeSearchMode.DENSE) {
// Allow global config to force hybrid even if caller passes DENSE default.
String configured = configuredMode == null ? "dense" : configuredMode.trim().toLowerCase(Locale.ROOT);
if ("hybrid".equals(configured)) {
return KnowledgeSearchMode.HYBRID;
}
return KnowledgeSearchMode.DENSE;
}
String configured = configuredMode == null ? "dense" : configuredMode.trim().toLowerCase(Locale.ROOT);
return "hybrid".equals(configured) ? KnowledgeSearchMode.HYBRID : KnowledgeSearchMode.DENSE;
}
private List<KnowledgeSearchHit> toHits(List<VectorSearchService.SearchResult> results) {
if (results == null || results.isEmpty()) {
return List.of();
@@ -201,11 +97,4 @@ public class VectorKnowledgeSearchAdapter implements KnowledgeSearchPort {
return Map.of();
}
}
private static String trimToNull(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
}
+14 -4
View File
@@ -25,6 +25,8 @@ milvus:
token: ${MILVUS_TOKEN}
secure: true
vector-dim: 1024 # BGE-M3 = 1024,换模型时同步改
# dense+BM25 hybrid collection (requires reindex; legacy "biz" is not used by knowledge path)
collection: biz_hybrid
# =====================================================
# 模型路由配置
@@ -162,16 +164,24 @@ rag:
enabled: false
content-preview-limit: 300
# 检索归一化配置
# 检索配置(单一 Milvus V2 后端;已移除 sdk/spring/auto 路由)
retrieval:
kb-scope: "" # empty means search all legacy documents; use rag-eval for eval seed docs
vector-store:
mode: auto # auto | spring-ai | sdk
kb-scope: "" # empty means search all documents in hybrid collection
search:
mode: hybrid # dense | hybrid (dense + BM25 RRF)
hybrid:
rrf-k: 60
normalization:
max-l2-distance: 2.0 # L2 距离上界(BGE-M3 单位向量 = 2.0)
highly-relevant-threshold: 0.75 # similarity >= 0.75 → HIGHLY_RELEVANT
reference-threshold: 0.5 # similarity >= 0.5 → REFERENCE
# RAG 召回宽度(Delivery 1)
rag:
retrieve-k: 20
return-n: 5
max-chunks-per-document: 2
# Prometheus 配置
prometheus:
base-url: http://localhost:9090
@@ -1,208 +1,61 @@
package com.superbiz.agent.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.service.milvus.MilvusHybridKnowledgeStore;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class VectorSearchServiceTest {
@Test
void sdkModeBypassesVectorStore() {
VectorSearchService service = spy(new VectorSearchService());
setMode(service, "sdk");
VectorSearchService.SearchResult expected = result("sdk-doc", 0.2f);
doReturn(List.of(expected))
.when(service).searchSimilarDocumentsWithSdk("query", 3, null);
void denseModeCallsHybridStoreDenseSearch() {
MilvusHybridKnowledgeStore store = mock(MilvusHybridKnowledgeStore.class);
VectorEmbeddingService embeddingService = mock(VectorEmbeddingService.class);
when(embeddingService.generateQueryVector("query")).thenReturn(List.of(0.1f, 0.2f));
VectorSearchService.SearchResult expected = result("doc-1", 0.2f);
when(store.searchDense(eq("query"), eq(List.of(0.1f, 0.2f)), eq(3), isNull()))
.thenReturn(List.of(expected));
when(store.collectionName()).thenReturn("biz_hybrid");
VectorSearchService service = new VectorSearchService();
ReflectionTestUtils.setField(service, "knowledgeStore", store);
ReflectionTestUtils.setField(service, "embeddingService", embeddingService);
ReflectionTestUtils.setField(service, "searchMode", "dense");
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("query", 3, null);
assertEquals(List.of(expected), results);
verify(service, never()).searchSimilarDocumentsWithVectorStore(any(), eq(3), any());
verify(store).searchDense(eq("query"), eq(List.of(0.1f, 0.2f)), eq(3), isNull());
}
@Test
void autoModeUsesVectorStoreWhenAvailable() {
VectorStore vectorStore = mock(VectorStore.class);
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(vectorStore);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of(
Document.builder()
.id("spring-doc")
.text("spring content")
.metadata(Map.of("_source", "spring.md", "category", "api"))
.score(0.8)
.build()
));
void hybridModeCallsHybridStoreHybridSearch() {
MilvusHybridKnowledgeStore store = mock(MilvusHybridKnowledgeStore.class);
VectorEmbeddingService embeddingService = mock(VectorEmbeddingService.class);
when(embeddingService.generateQueryVector("pool")).thenReturn(List.of(0.3f));
VectorSearchService.SearchResult expected = result("doc-h", 0.4f);
when(store.searchHybrid(eq("pool"), eq(List.of(0.3f)), eq(5), eq("mysql")))
.thenReturn(List.of(expected));
when(store.collectionName()).thenReturn("biz_hybrid");
VectorSearchService service = new VectorSearchService();
setMode(service, "auto");
setVectorStore(service, provider);
ReflectionTestUtils.setField(service, "knowledgeStore", store);
ReflectionTestUtils.setField(service, "embeddingService", embeddingService);
ReflectionTestUtils.setField(service, "searchMode", "hybrid");
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("query", 3, null);
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("pool", 5, "mysql");
assertEquals(1, results.size());
assertEquals("spring-doc", results.get(0).getId());
assertEquals("similarity", results.get(0).getScoreLabel());
assertEquals(0.8, results.get(0).getRawScore(), 0.0001);
assertEquals(0.4f, results.get(0).getScore(), 0.0001);
assertTrue(results.get(0).getMetadata().contains("spring.md"));
}
@Test
void vectorStoreSearchUsesDistanceMetadataAsCompatibleScore() {
VectorStore vectorStore = mock(VectorStore.class);
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(vectorStore);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of(
Document.builder()
.id("spring-doc")
.text("spring content")
.metadata(Map.of("distance", 0.5659486, "category", "api"))
.score(0.4340514)
.build()
));
VectorSearchService service = new VectorSearchService();
setMode(service, "auto");
setVectorStore(service, provider);
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("query", 3, null);
assertEquals(1, results.size());
assertEquals("similarity", results.get(0).getScoreLabel());
assertEquals(0.4340514, results.get(0).getRawScore(), 0.0001);
assertEquals(0.5659486f, results.get(0).getScore(), 0.0001);
}
@Test
void autoModeFallsBackToSdkWhenVectorStoreFails() {
VectorStore vectorStore = mock(VectorStore.class);
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(vectorStore);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenThrow(new RuntimeException("vectorstore down"));
VectorSearchService service = spy(new VectorSearchService());
setMode(service, "auto");
setVectorStore(service, provider);
VectorSearchService.SearchResult fallback = result("sdk-doc", 0.3f);
doReturn(List.of(fallback))
.when(service).searchSimilarDocumentsWithSdk("query", 3, null);
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("query", 3, null);
assertEquals(List.of(fallback), results);
}
@Test
void autoModeFallsBackToSdkWhenVectorStoreUnavailable() {
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(null);
VectorSearchService service = spy(new VectorSearchService());
setMode(service, "auto");
setVectorStore(service, provider);
VectorSearchService.SearchResult fallback = result("sdk-doc", 0.3f);
doReturn(List.of(fallback))
.when(service).searchSimilarDocumentsWithSdk("query", 3, null);
List<VectorSearchService.SearchResult> results = service.searchSimilarDocuments("query", 3, null);
assertEquals(List.of(fallback), results);
}
@Test
void vectorStoreSearchUsesCategoryFilter() {
VectorStore vectorStore = mock(VectorStore.class);
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(vectorStore);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
VectorSearchService service = new VectorSearchService();
setMode(service, "spring-ai");
setVectorStore(service, provider);
service.searchSimilarDocuments("query", 5, "api");
ArgumentCaptor<SearchRequest> requestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
verify(vectorStore).similaritySearch(requestCaptor.capture());
SearchRequest request = requestCaptor.getValue();
assertEquals("query", request.getQuery());
assertEquals(5, request.getTopK());
assertTrue(request.hasFilterExpression());
assertTrue(request.toString().contains("category"));
assertTrue(request.toString().contains("api"));
}
@Test
void springModeUsesVectorStoreAlias() {
VectorStore vectorStore = mock(VectorStore.class);
ObjectProvider<VectorStore> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(vectorStore);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
VectorSearchService service = new VectorSearchService();
setMode(service, "spring");
setVectorStore(service, provider);
service.searchSimilarDocuments("query", 5, "api");
verify(vectorStore).similaritySearch(any(SearchRequest.class));
}
@Test
void defaultScopeDoesNotAddMetadataFilter() {
VectorSearchService service = new VectorSearchService();
setKbScope(service, "");
assertNull(service.buildSpringAiFilterExpression(null));
assertNull(service.buildSdkFilterExpression(null));
assertEquals("category == 'api'", service.buildSpringAiFilterExpression("api"));
assertEquals("metadata[\"category\"] == \"api\"", service.buildSdkFilterExpression("api"));
}
@Test
void configuredScopeCombinesWithCategoryFilter() {
VectorSearchService service = new VectorSearchService();
setKbScope(service, "rag-eval");
assertEquals("kb_scope == 'rag-eval'", service.buildSpringAiFilterExpression(null));
assertEquals("category == 'api' && kb_scope == 'rag-eval'",
service.buildSpringAiFilterExpression("api"));
assertEquals("metadata[\"category\"] == \"api\" && metadata[\"kb_scope\"] == \"rag-eval\"",
service.buildSdkFilterExpression("api"));
}
private static void setMode(VectorSearchService service, String mode) {
ReflectionTestUtils.setField(service, "vectorStoreMode", mode);
}
private static void setVectorStore(VectorSearchService service, ObjectProvider<VectorStore> provider) {
ReflectionTestUtils.setField(service, "vectorStoreProvider", provider);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "maxL2Distance", 2.0);
}
private static void setKbScope(VectorSearchService service, String kbScope) {
ReflectionTestUtils.setField(service, "kbScope", kbScope);
assertEquals("doc-h", results.get(0).getId());
verify(store).searchHybrid(eq("pool"), eq(List.of(0.3f)), eq(5), eq("mysql"));
}
private static VectorSearchService.SearchResult result(String id, float score) {
@@ -3,41 +3,36 @@ package com.superbiz.agent.service.retrieval;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.service.VectorSearchService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class VectorKnowledgeSearchAdapterHybridTest {
@Test
void hybridFusesFilteredAndUnfilteredDensePaths() {
void adapterMapsStoreHitsWithChunkIdentity() {
VectorSearchService vectorSearchService = mock(VectorSearchService.class);
when(vectorSearchService.searchSimilarDocuments("pool timeout", 3, null)).thenReturn(List.of(
result("u1", "{\"_source\":\"a.md\",\"docId\":\"a\",\"chunkIndex\":0,\"title\":\"generic\"}", "generic pool", 0.4f),
result("u2", "{\"_source\":\"b.md\",\"docId\":\"b\",\"chunkIndex\":0,\"title\":\"other\"}", "other", 0.5f)
));
when(vectorSearchService.searchSimilarDocuments("pool timeout", 3, "mysql")).thenReturn(List.of(
result("f1", "{\"_source\":\"c.md\",\"docId\":\"c\",\"chunkIndex\":0,\"title\":\"mysql pool timeout\"}", "mysql pool timeout runbook", 0.35f)
result("id-1",
"{\"_source\":\"c.md\",\"docId\":\"c\",\"chunkIndex\":0,\"title\":\"mysql pool timeout\"}",
"mysql pool timeout runbook",
0.35f)
));
VectorKnowledgeSearchAdapter adapter = new VectorKnowledgeSearchAdapter(vectorSearchService, new ObjectMapper());
ReflectionTestUtils.setField(adapter, "configuredMode", "hybrid");
ReflectionTestUtils.setField(adapter, "rrfK", 60);
ReflectionTestUtils.setField(adapter, "weightDenseUnfiltered", 1.0);
ReflectionTestUtils.setField(adapter, "weightDenseFiltered", 1.0);
ReflectionTestUtils.setField(adapter, "weightLexical", 1.0);
VectorKnowledgeSearchAdapter adapter =
new VectorKnowledgeSearchAdapter(vectorSearchService, new ObjectMapper());
List<KnowledgeSearchHit> hits = adapter.search(
new KnowledgeSearchRequest("pool timeout", 3, "mysql", KnowledgeSearchMode.HYBRID));
assertEquals(3, hits.size());
assertTrue(hits.stream().anyMatch(hit -> "c#chunk-0".equals(hit.evidenceKey())));
assertTrue(hits.get(0).metadata().containsKey("fusedScore"));
assertEquals(1, hits.size());
assertEquals("c#chunk-0", hits.get(0).evidenceKey());
assertEquals("c.md", hits.get(0).source());
verify(vectorSearchService).searchSimilarDocuments("pool timeout", 3, "mysql");
}
private static VectorSearchService.SearchResult result(String id, String metadata, String content, float score) {