基于 Embedding 的智能搜索——从向量化到相似度计算的 Java 实践
基于 Embedding 的智能搜索——从向量化到相似度计算的 Java 实践
一、背景与问题
传统关键词搜索(如 Elasticsearch 的 BM25)依赖词汇精确匹配,无法理解语义相近但措辞不同的查询。例如用户搜索"服务挂了",关键词搜索无法匹配到标题为"微服务可用性下降"的文档——两者的词汇交集为零,但语义高度相关。
基于 Embedding 的语义搜索通过将文本映射到高维向量空间,使得语义相近的文本在空间中距离接近,从而支持"意义匹配而非词汇匹配"。然而,从理论到生产级系统,工程挑战分布在三个环节:向量化(Embedding 调用与缓存)、存储(向量数据库选型与索引构建)、检索(相似度计算与结果排序)。
我们在内部技术文档库中实现了语义搜索,将搜索满意度(用户点击率)从 BM25 的 42% 提升到 78%。本文记录从向量化到相似度计算的完整 Java 实践。
二、方案设计
Embedding 搜索系统的核心管线:
flowchart LR A[原始文档] --> B[文本预处理<br/>清洗/分块] B --> C[Embedding API<br/>向量化] C --> D[向量数据库<br/>索引构建] E[用户查询] --> F[查询 Embedding] F --> G[相似度检索<br/>ANN 查询] D --> G G --> H[结果排序<br/>相似度 + 元数据过滤] H --> I[返回搜索结果]关键设计决策:
- Embedding 模型选型:OpenAI
text-embedding-3-small(1536 维、$0.02/百万Token),兼顾精度与成本。对于纯中文场景,BGE-M3(1024 维、开源可本地部署)是更经济的替代。 - 向量存储:Milvus 2.3 的 IVF_FLAT 索引,支持百万级文档的 ANN(近似最近邻)查询,延迟 <50ms。
- 混合检索:语义检索 + BM25 关键词检索的 RRF 合并,覆盖语义匹配与精确匹配的双重需求。
三、实战演示
3.1 Embedding 服务封装
/** * Embedding 服务——将文本转换为向量 * 支持 OpenAI 和本地模型两种模式,通过配置切换 */ @Service @Slf4j public class EmbeddingService { private final EmbeddingModel embeddingModel; private final EmbeddingCacheService cacheService; public EmbeddingService(EmbeddingModel embeddingModel, EmbeddingCacheService cacheService) { this.embeddingModel = embeddingModel; this.cacheService = cacheService; } /** * 将文本转换为 Embedding 向量 * 优先从缓存读取,避免重复调用 API * @param text 输入文本 * @return 向量数组(float[]) */ public float[] embed(String text) { if (text == null || text.isBlank()) { throw new IllegalArgumentException("向量化文本不能为空"); } try { // 缓存查询:相同文本的向量结果可复用 float[] cached = cacheService.get(text); if (cached != null) { log.debug("Embedding 缓存命中"); return cached; } // 调用 Embedding 模型 EmbeddingResponse response = embeddingModel.embed( new EmbeddingRequest(List.of(text), EmbeddingOptions.builder().build())); float[] vector = response.getResult().getOutput(); log.debug("Embedding 完成: 文本长度={}, 向量维度={}", text.length(), vector.length); // 写入缓存(文本 → 向量的映射关系稳定,可长期缓存) cacheService.put(text, vector); return vector; } catch (EmbeddingApiException e) { log.error("Embedding API 调用失败: textLength={}", text.length(), e); throw new EmbeddingException("向量化调用失败: " + e.getMessage(), e); } catch (Exception e) { log.error("Embedding 处理异常", e); throw new EmbeddingException("向量化处理异常: " + e.getMessage(), e); } } /** * 批量向量化——用于文档摄入阶段 * 批量调用比逐条调用效率提升约 5 倍 * @param texts 文本列表 * @return 向量列表 */ public List<float[]> embedBatch(List<String> texts) { if (texts == null || texts.isEmpty()) { return Collections.emptyList(); } try { // 分批次调用(API 单次上限通常为 2048 条) int batchSize = 100; List<float[]> results = new ArrayList<>(); for (int i = 0; i < texts.size(); i += batchSize) { List<String> batch = texts.subList(i, Math.min(i + batchSize, texts.size())); EmbeddingResponse response = embeddingModel.embed( new EmbeddingRequest(batch, EmbeddingOptions.builder().build())); List<float[]> batchVectors = response.getResults() .stream() .map(EmbeddingResult::getOutput) .toList(); results.addAll(batchVectors); log.info("批量 Embedding: batch={}, 总进度={}/{}", batchVectors.size(), i + batch.size(), texts.size()); } return results; } catch (Exception e) { log.error("批量 Embedding 异常", e); throw new EmbeddingException("批量向量化失败: " + e.getMessage(), e); } } }3.2 向量存储与索引构建
/** * 向量索引构建服务——将文档向量写入 Milvus 并构建 ANN 索引 */ @Service @Slf4j public class VectorIndexService { private final MilvusVectorStore vectorStore; private final EmbeddingService embeddingService; public VectorIndexService(MilvusVectorStore vectorStore, EmbeddingService embeddingService) { this.vectorStore = vectorStore; this.embeddingService = embeddingService; } /** * 构建文档向量索引 * @param documents 待索引的文档列表 */ public void buildIndex(List<IndexedDocument> documents) { if (documents == null || documents.isEmpty()) { log.warn("文档列表为空,跳过索引构建"); return; } try { // 批量向量化文档内容 List<String> contents = documents.stream() .map(IndexedDocument::getContent) .toList(); List<float[]> vectors = embeddingService.embedBatch(contents); // 转换为 Spring AI Document 格式 List<Document> aiDocuments = new ArrayList<>(); for (int i = 0; i < documents.size(); i++) { IndexedDocument doc = documents.get(i); Map<String, Object> metadata = new HashMap<>(); metadata.put("title", doc.getTitle()); metadata.put("source", doc.getSource()); metadata.put("category", doc.getCategory()); metadata.put("docId", doc.getId()); // Spring AI VectorStore 内部会使用 Embedding 向量 Document aiDoc = new Document(doc.getContent(), metadata); aiDocuments.add(aiDoc); } // 写入向量存储(自动触发索引构建) vectorStore.add(aiDocuments); log.info("向量索引构建完成: 文档数={}", documents.size()); } catch (Exception e) { log.error("向量索引构建异常", e); throw new IndexBuildException("索引构建失败: " + e.getMessage(), e); } } }3.3 混合检索与结果合并
/** * 智能搜索服务——语义检索 + 关键词检索的混合模式 */ @Service @Slf4j public class SmartSearchService { private final VectorStore vectorStore; private final KeywordSearchService keywordSearchService; private final EmbeddingService embeddingService; public SmartSearchService(VectorStore vectorStore, KeywordSearchService keywordSearchService, EmbeddingService embeddingService) { this.vectorStore = vectorStore; this.keywordSearchService = keywordSearchService; this.embeddingService = embeddingService; } /** * 执行混合搜索 * @param query 用户查询 * @param topK 返回结果数 * @param categoryFilter 分类过滤条件 * @return 搜索结果列表(按 RRF 排序合并) */ public List<SearchResult> search(String query, int topK, String categoryFilter) { try { // 语义检索 List<Document> semanticResults = vectorStore.similaritySearch( SearchRequest.builder() .query(query) .topK(topK * 2) // 多召回,为合并预留空间 .similarityThreshold(0.5) .filterExpression(Map.of("category", categoryFilter)) .build()); // 关键词检索 List<KeywordResult> keywordResults = keywordSearchService.search( query, topK * 2, categoryFilter); // RRF(Reciprocal Rank Fusion)合并排序 List<SearchResult> merged = rrfMerge(semanticResults, keywordResults, topK); log.info("混合搜索完成: query={}, 语义结果={}, 关键词结果={}, 合并结果={}", query, semanticResults.size(), keywordResults.size(), merged.size()); return merged; } catch (Exception e) { log.error("混合搜索异常: query={}", query, e); // 降级:仅返回关键词搜索结果 return keywordSearchService.search(query, topK, categoryFilter) .stream() .map(kr -> new SearchResult(kr.getTitle(), kr.getContent(), kr.getSource(), 0.0, "keyword-fallback")) .toList(); } } /** * RRF 合并算法——将两个检索结果列表按排名倒数加权合并 * 公式:score = 1/(k + rank),k 通常取 60 */ private List<SearchResult> rrfMerge(List<Document> semanticDocs, List<KeywordResult> keywordDocs, int topK) { int k = 60; Map<String, Double> scoreMap = new HashMap<>(); Map<String, SearchResult> resultMap = new HashMap<>(); // 语义检索排名加权 for (int i = 0; i < semanticDocs.size(); i++) { Document doc = semanticDocs.get(i); String docId = (String) doc.getMetadata().get("docId"); double score = 1.0 / (k + i + 1); scoreMap.merge(docId, score, Double::sum); resultMap.put(docId, new SearchResult( (String) doc.getMetadata().get("title"), doc.getContent(), (String) doc.getMetadata().get("source"), doc.getMetadata().get("distance", 0.0), "semantic")); } // 关键词检索排名加权 for (int i = 0; i < keywordDocs.size(); i++) { KeywordResult kr = keywordDocs.get(i); double score = 1.0 / (k + i + 1); scoreMap.merge(kr.getId(), score, Double::sum); resultMap.put(kr.getId(), new SearchResult( kr.getTitle(), kr.getContent(), kr.getSource(), kr.getScore(), "keyword")); } // 按合并分数排序,取 topK return scoreMap.entrySet().stream() .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .limit(topK) .map(entry -> resultMap.get(entry.getKey())) .filter(Objects::nonNull) .toList(); } }四、深度解析
4.1 向量维度的精度与效率权衡
| 模型 | 维度 | 精度(MTEB 排名) | 存储开销 | 检索延迟 |
|---|---|---|---|---|
| text-embedding-3-small | 1536 | Top 10 | 高(6KB/条) | 中 |
| text-embedding-3-large | 3072 | Top 3 | 极高(12KB/条) | 高 |
| BGE-M3 | 1024 | Top 15(中文) | 中(4KB/条) | 低 |
1536 维是精度与存储的平衡点。3072 维的精度提升约 5%,但存储翻倍、检索延迟增加 30%。在生产系统中,我们选择 1536 维,并配合 Matryoshka Representation Learning(嵌套维度学习),在需要加速时可将向量截断到 512 维做近似检索,再做 1536 维精确排序。
4.2 ANN 索引的类型选择
| 索引类型 | 构建速度 | 检索速度 | 精度 | 适用规模 |
|---|---|---|---|---|
| FLAT | 极快 | 慢(暴力搜索) | 100% | <10万 |
| IVF_FLAT | 中 | 快 | 95% | 10万~100万 |
| HNSW | 慢 | 极快 | 98% | <500万 |
| IVF_PQ | 快 | 极快 | 90% | >500万 |
我们使用 IVF_FLAT,因为文档库规模约 50 万条,95% 的近似精度足够(最终 RRF 合并会补充关键词精确匹配),构建速度和检索延迟满足要求。
4.3 搜索满意度的量化对比
| 指标 | BM25 纯关键词 | 语义搜索 | 混合搜索 |
|---|---|---|---|
| 首位点击率 | 42% | 65% | 78% |
| 平均浏览深度 | 1.8 页 | 2.3 页 | 2.1 页 |
| 零结果率 | 8% | 3% | 1.5% |
| P95 延迟 | 15ms | 45ms | 50ms |
混合搜索的满意度最高,但延迟略高。我们通过并行执行语义与关键词检索(而非串行),将 P95 延迟降至 55ms,可接受。
4.4 文档分块策略对检索精度的影响
Embedding 搜索的精度高度依赖文档分块策略。分块过大(如每块 2000 Token),向量表示的信息密度高,但检索粒度粗——用户查询仅匹配到包含相关信息的段落,但无法精确定位到具体句子;分块过小(如每块 100 Token),检索粒度细但上下文不完整,容易出现"碎片致盲"。我们的实践采用滑动窗口分块:每块 512 Token,窗口重叠 128 Token。这样既保留了足够的上下文(512 Token 约 300 中文字),又通过重叠窗口避免了"断句在关键信息中间"的问题。在 50 万文档的测试集上,滑动窗口分块相比固定分块的检索精度(Recall@10)提升 12 个百分点。
五、总结与展望
基于 Embedding 的智能搜索实践,关键工程决策总结:
- 混合检索优于纯语义检索:RRF 合并覆盖语义匹配与精确匹配的双重需求,满意度提升 13 个百分点。
- Embedding 缓存是必要优化:相同文本的向量结果稳定,缓存命中率约 30%,节省 API 成本与延迟。
- 向量维度不是越高越好:1536 维在精度与存储间取得平衡,配合 Matryoshka 截断策略可灵活调整。
后续方向:多模态 Embedding(文本 + 图片联合向量)的搜索扩展、基于用户点击日志的向量空间微调(Fine-tune Embedding on click data)、以及向量数据库的增量更新与一致性保障机制。语义搜索不是关键词搜索的替代者,而是补充者——两者的混合才是企业搜索系统的最佳实践。
