程序员鸭梨头像
关注

基于 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[返回搜索结果]

关键设计决策:

  1. Embedding 模型选型:OpenAI text-embedding-3-small(1536 维、$0.02/百万Token),兼顾精度与成本。对于纯中文场景,BGE-M3(1024 维、开源可本地部署)是更经济的替代。
  2. 向量存储:Milvus 2.3 的 IVF_FLAT 索引,支持百万级文档的 ANN(近似最近邻)查询,延迟 <50ms。
  3. 混合检索:语义检索 + 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-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 万文档的测试集上,滑动窗口分块相比固定分块的检索精度(Recall@10)提升 12 个百分点。

五、总结与展望

基于 Embedding 的智能搜索实践,关键工程决策总结:

  1. 混合检索优于纯语义检索:RRF 合并覆盖语义匹配与精确匹配的双重需求,满意度提升 13 个百分点。
  2. Embedding 缓存是必要优化:相同文本的向量结果稳定,缓存命中率约 30%,节省 API 成本与延迟。
  3. 向量维度不是越高越好:1536 维在精度与存储间取得平衡,配合 Matryoshka 截断策略可灵活调整。

后续方向:多模态 Embedding(文本 + 图片联合向量)的搜索扩展、基于用户点击日志的向量空间微调(Fine-tune Embedding on click data)、以及向量数据库的增量更新与一致性保障机制。语义搜索不是关键词搜索的替代者,而是补充者——两者的混合才是企业搜索系统的最佳实践。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/alex_goden/article/details/162717283

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--