基于 Embedding 的智能搜索——从向量化到相似度计算的 Java 实践
基于 Embedding 的智能搜索——从向量化到相似度计算的 Java 实践一、背景与问题传统关键词搜索如 Elasticsearch 的 BM25依赖词汇精确匹配无法理解语义相近但措辞不同的查询。例如用户搜索服务挂了关键词搜索无法匹配到标题为微服务可用性下降的文档——两者的词汇交集为零但语义高度相关。基于 Embedding 的语义搜索通过将文本映射到高维向量空间使得语义相近的文本在空间中距离接近从而支持意义匹配而非词汇匹配。然而从理论到生产级系统工程挑战分布在三个环节向量化Embedding 调用与缓存、存储向量数据库选型与索引构建、检索相似度计算与结果排序。我们在内部技术文档库中实现了语义搜索将搜索满意度用户点击率从 BM25 的 42% 提升到 78%。本文记录从向量化到相似度计算的完整 Java 实践。二、方案设计Embedding 搜索系统的核心管线flowchart LR A[原始文档] -- B[文本预处理br/清洗/分块] B -- C[Embedding APIbr/向量化] C -- D[向量数据库br/索引构建] E[用户查询] -- F[查询 Embedding] F -- G[相似度检索br/ANN 查询] D -- G G -- H[结果排序br/相似度 元数据过滤] H -- I[返回搜索结果]关键设计决策Embedding 模型选型OpenAItext-embedding-3-small1536 维、$0.02/百万Token兼顾精度与成本。对于纯中文场景BGE-M31024 维、开源可本地部署是更经济的替代。向量存储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 Listfloat[] embedBatch(ListString texts) { if (texts null || texts.isEmpty()) { return Collections.emptyList(); } try { // 分批次调用API 单次上限通常为 2048 条 int batchSize 100; Listfloat[] results new ArrayList(); for (int i 0; i texts.size(); i batchSize) { ListString batch texts.subList(i, Math.min(i batchSize, texts.size())); EmbeddingResponse response embeddingModel.embed( new EmbeddingRequest(batch, EmbeddingOptions.builder().build())); Listfloat[] 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(ListIndexedDocument documents) { if (documents null || documents.isEmpty()) { log.warn(文档列表为空跳过索引构建); return; } try { // 批量向量化文档内容 ListString contents documents.stream() .map(IndexedDocument::getContent) .toList(); Listfloat[] vectors embeddingService.embedBatch(contents); // 转换为 Spring AI Document 格式 ListDocument aiDocuments new ArrayList(); for (int i 0; i documents.size(); i) { IndexedDocument doc documents.get(i); MapString, 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 ListSearchResult search(String query, int topK, String categoryFilter) { try { // 语义检索 ListDocument semanticResults vectorStore.similaritySearch( SearchRequest.builder() .query(query) .topK(topK * 2) // 多召回为合并预留空间 .similarityThreshold(0.5) .filterExpression(Map.of(category, categoryFilter)) .build()); // 关键词检索 ListKeywordResult keywordResults keywordSearchService.search( query, topK * 2, categoryFilter); // RRFReciprocal Rank Fusion合并排序 ListSearchResult 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 ListSearchResult rrfMerge(ListDocument semanticDocs, ListKeywordResult keywordDocs, int topK) { int k 60; MapString, Double scoreMap new HashMap(); MapString, 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, DoublecomparingByValue().reversed()) .limit(topK) .map(entry - resultMap.get(entry.getKey())) .filter(Objects::nonNull) .toList(); } }四、深度解析4.1 向量维度的精度与效率权衡模型维度精度MTEB 排名存储开销检索延迟text-embedding-3-small1536Top 10高6KB/条中text-embedding-3-large3072Top 3极高12KB/条高BGE-M31024Top 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 延迟15ms45ms50ms混合搜索的满意度最高但延迟略高。我们通过并行执行语义与关键词检索而非串行将 P95 延迟降至 55ms可接受。4.4 文档分块策略对检索精度的影响Embedding 搜索的精度高度依赖文档分块策略。分块过大如每块 2000 Token向量表示的信息密度高但检索粒度粗——用户查询仅匹配到包含相关信息的段落但无法精确定位到具体句子分块过小如每块 100 Token检索粒度细但上下文不完整容易出现碎片致盲。我们的实践采用滑动窗口分块每块 512 Token窗口重叠 128 Token。这样既保留了足够的上下文512 Token 约 300 中文字又通过重叠窗口避免了断句在关键信息中间的问题。在 50 万文档的测试集上滑动窗口分块相比固定分块的检索精度Recall10提升 12 个百分点。五、总结与展望基于 Embedding 的智能搜索实践关键工程决策总结混合检索优于纯语义检索RRF 合并覆盖语义匹配与精确匹配的双重需求满意度提升 13 个百分点。Embedding 缓存是必要优化相同文本的向量结果稳定缓存命中率约 30%节省 API 成本与延迟。向量维度不是越高越好1536 维在精度与存储间取得平衡配合 Matryoshka 截断策略可灵活调整。后续方向多模态 Embedding文本 图片联合向量的搜索扩展、基于用户点击日志的向量空间微调Fine-tune Embedding on click data、以及向量数据库的增量更新与一致性保障机制。语义搜索不是关键词搜索的替代者而是补充者——两者的混合才是企业搜索系统的最佳实践。