Embedding 语义查询:详细讲解与代码示例

本文有一个短视频:YouTubeTikTok

1. 概述

当你问聊天机器人“我的航班被取消了,已预订的酒店费用能退吗?“时,RAG 系统需要找到讲述”行程中断“的正确政策段落——虽然提问词句与原文档 100% 不匹配。本文讲解 embedding 机制如何实现这一点:将文本转换成向量,按语义距离匹配,同时保存足够信息让 LLM 能追溯到原始文本。读完本文,你将能用 Java 从头写一个简单的语义搜索管道,并用测试验证它的正确性。

2. 什么是 Embedding

Embedding 模型接收一段文本,返回一个向量——一串实数,通常有几百到几千个维度。这不是“压缩的关键词”,而是高维空间中的一个点,其中语义相近的文本段落会有接近的向量。

public record EmbeddingVector(float[] values) {
    public int dimension() {
        return values.length;
    }
}

EmbeddingVector 只是对 float[] 的包装,但有了它能帮你避免在不同模型间混淆 float[] 传递——这是系统规模增长时很容易出现的错误。

2.1. 为什么不应该把 embedding 当作有明确标签的“地图”

在真实的地图上,你有经度、纬度轴,有“河内”“岘港”这样的标签。对于 embedding,每个维度不带有“酒店”“航班”“退款”这样的标签。含义只通过整个向量的综合模式而出现。因此你不能“读取”单个维度来了解它代表什么——你只能将整个向量与另一个向量比较。

这很重要,因为它解释了你为什么不应该随意截断向量(例如只保留 100/1536 维来节省内存)——这样做会破坏模式,严重降低搜索精度。

3. 按语义距离匹配

两个向量“接近”通常用 cosine similarity(余弦相似度)来衡量——越接近 1 语义越相似。

public final class VectorMath {

    public static double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            throw new IllegalArgumentException(
                "向量维度不匹配:%d vs %d".formatted(a.length, b.length));
        }
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}
  • 第 if 行:立即阻止常见错误——比较来自两个不同模型的向量(例如一个 768 维,一个 1536 维)如果不提前检查会导致难以调试的运行时错误。
  • dot、normA、normB:按标准余弦公式计算点积和每个向量的范数。

注意: 两个模型维度数相同不意味着兼容。模型 A 和模型 B 都输出 1536 维向量,但它们的语义空间不同——交叉比较会产生无意义的结果,即使代码运行无误。

4. 模拟 Embedding 模型来练习,无需调用真实 API

为了让示例能直接运行,我使用一个 EmbeddingClient 模拟实现,基于关键词频率的“主题”(travel、password、billing)。实际上你会用 OpenAI、Cohere 或开源模型替换,但接口保持不变。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
    EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {

    private static final List<String> TRAVEL_WORDS =
        List.of("航班", "取消", "酒店", "行程", "机场");
    private static final List<String> PASSWORD_WORDS =
        List.of("密码", "登录", "账户", "重置", "忘记");
    private static final List<String> BILLING_WORDS =
        List.of("退款", "支付", "发票", "费用", "收费");

    @Override
    public EmbeddingVector embed(String text) {
        String normalized = text.toLowerCase();
        float travel = score(normalized, TRAVEL_WORDS);
        float password = score(normalized, PASSWORD_WORDS);
        float billing = score(normalized, BILLING_WORDS);
        return new EmbeddingVector(new float[] { travel, password, billing });
    }

    private float score(String text, List<String> keywords) {
        long matches = keywords.stream().filter(text::contains).count();
        return matches / (float) keywords.size();
    }
}

KeywordEmbeddingClient 只用 3 个维度(travel/password/billing)便于观察——真实 embedding 使用通过数据学习的数百个维度,但“语义相近的文本→向量接近”的思想是相同的。

5. 存储 Embedding 及元数据,让 LLM 能追溯原始文本

向量只帮助定位文本段落,LLM 仍需要文字内容来生成答案。因此向量存储中的每条记录必须将 embedding 与原始 chunk 和元数据关联。

public record DocumentChunk(
    String chunkId,
    String documentId,
    String text,
    String section,
    LocalDate effectiveDate,
    int version,
    EmbeddingVector embedding
) {}
  • chunkId 和 documentId:允许在需要时追回完整文档或更新新版本。
  • section、effectiveDate、version:帮助在语义匹配前进行过滤——例如只搜索当前有效的政策,跳过已过期的旧版本。
  • text:必须与之一起存储,因为 LLM 无法“读取”向量——它需要真实文字来生成答案。
public class InMemoryVectorStore {

    private final List<DocumentChunk> chunks = new ArrayList<>();

    public void add(DocumentChunk chunk) {
        chunks.add(chunk);
    }

    public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
        return chunks.stream()
            .sorted(Comparator.comparingDouble(
                (DocumentChunk c) -> -VectorMath.cosineSimilarity(
                    query.values(), c.embedding().values())))
            .limit(topK)
            .toList();
    }
}

findNearest 按余弦相似度递减排序(用 - 反转 Comparator)并取 topK 个最接近的结果——这正是 RAG 中的“检索”步骤。

6. 完整示例:措辞不同但意思相同的问题

public class SemanticSearchDemo {

    public static void main(String[] args) {
        EmbeddingClient embedder = new KeywordEmbeddingClient();
        InMemoryVectorStore store = new InMemoryVectorStore();

        store.add(new DocumentChunk(
            "chunk-1", "policy-travel", "航班取消或行程中断时的退款政策。",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("航班取消或行程中断时的退款政策。")
        ));

        store.add(new DocumentChunk(
            "chunk-2", "policy-account", "忘记登录账户时的密码重置指南。",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("忘记登录账户时的密码重置指南。")
        ));

        String question = "我的航班被取消了,已预订的酒店费用能退吗?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);
        System.out.println(results.get(0).text());
    }
}

运行此程序打印:

航班取消或行程中断时的退款政策。

问题不包含“政策”或“行程”这样的词,但因为问题和文本在语义空间中都靠近(两者的 travel 维度都很高),系统选中了正确的 chunk——完全忽略了关于密码重置的 chunk。

7. 用测试来验证

class SemanticSearchTest {

    private final EmbeddingClient embedder = new KeywordEmbeddingClient();
    private final InMemoryVectorStore store = new InMemoryVectorStore();

    @BeforeEach
    void setUp() {
        store.add(new DocumentChunk(
            "chunk-1", "policy-travel",
            "航班取消或行程中断时的退款政策。",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("航班取消或行程中断时的退款政策。")
        ));
        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "忘记登录账户时的密码重置指南。",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("忘记登录账户时的密码重置指南。")
        ));
    }

    @Test
    void shouldMatchTravelPolicyEvenWithoutSharedWords() {
        String question = "我的航班被取消了,已预订的酒店费用能退吗?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);

        assertThat(results)
            .hasSize(1)
            .first()
            .extracting(DocumentChunk::documentId)
            .isEqualTo("policy-travel");
    }

    @Test
    void shouldRejectComparingDifferentDimensions() {
        float[] a = { 1f, 0f, 0f };
        float[] b = { 1f, 0f };

        assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("维度不匹配");
    }
}

第一个测试断言核心行为:措辞完全不同的问题仍能找到正确的 chunk。第二个测试确保你不会意外比较两个不兼容的向量——当切换 embedding 模型却忘记重新 embedding 旧数据时,这个错误很容易发生。

8. 常见错误和何时不应使用

  • 切换 embedding 模型但没有重新 embedding 旧数据:旧向量和新向量不在同一空间,匹配结果会无声地出错——代码没有异常提示。
  • chunk 太长或太短:长 chunk 导致向量“混合”多个意思,降低精度;太短的 chunk 失去上下文。通常 200–500 token 是合理的起点,需根据具体领域调整。
  • 忘记保存元数据:如果只存向量没存 documentId/version,政策更改时无法正确删除或更新文档。
  • 对需要精确匹配的数据用 embedding:查找订单号 ORD-2026-00123 时,精确搜索或按主键索引仍比 embedding 更快更准——embedding 的优势在于语义匹配而非字符串精确匹配。

注意: 对小数据集(几百个 chunk),上面的 InMemoryVectorStore 足够用。当扩展到百万级 chunk 时,需要专用向量数据库(pgvector、Pinecone、Milvus…)支持近似近邻搜索(ANN),避免逐个向量比对。

9. 总结

Embedding 将文本转换成向量让机器比较“含义”而不只是关键词匹配——这是所有 RAG 系统的基础。关键要点:query 和文档必须用同一个兼容的编码器、每个向量必须附带元数据以追溯原始文本、相同维度数不等于两个模型兼容。从上面的 Java 例子,你可以把 KeywordEmbeddingClient 替换成真实的 embedding 模型(通过 Spring AI 或直接 API 调用),而其余部分——余弦相似度、向量存储、元数据——全部保持不变。

ragembeddingvector-searchspring-aillm