基于 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)、以及向量数据库的增量更新与一致性保障机制。语义搜索不是关键词搜索的替代者,而是补充者——两者的混合才是企业搜索系统的最佳实践。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/alex_goden/article/details/162717283



