课程0基础Agent开发课 / LLM基础 / Embedding向量化原理-语义搜索的数学基础
— 16 min read

Embedding向量化原理-语义搜索的数学基础

> **本文适合谁**

Embedding 向量化原理:语义搜索的数学基础

本文适合谁

准备构建 RAG 系统或语义搜索功能的开发者。Embedding 是 RAG 的数学基础,理解它才能理解"为什么检索能找到意思相近的内容"。不需要线性代数背景,用直觉理解向量空间。


关键词搜索只能匹配字面上相同的词。搜索"苹果手机"不会返回包含"iPhone"的文档,搜索"价格便宜"也不会匹配"性价比高"。语义搜索要解决的是另一个问题:理解文本的含义,而不只是找词的匹配。

Embedding(嵌入向量)是实现语义搜索的核心技术:把文本转换成一个数字向量(比如用1536个数字来表示一句话的含义),意思相近的文本在向量空间中的距离也近。

1.1 从词袋模型到稠密向量

1.1.1 为什么词袋模型失败了:稀疏与语义缺失

理解 Embedding 之前,先看它解决了什么问题。

Embedding 向量空间示意图
图 6.10:Embedding 向量空间——语义相似的词在高维空间中距离更近

想象这样一个场景:你有一个文档数据库,想找出所有"讨论苹果公司产品"的文章。如果用关键词搜索,你必须知道所有可能的关键词:"Apple"、"iPhone"、"iPad"、"Mac"、"Tim Cook"……哪怕漏掉一个,相关文档就会被遗漏。

更根本的问题是:关键词搜索无法理解语义等价性。"这款手机价格实惠"和"这款手机性价比高"表达的是相同的意思,但在关键词层面完全不同。传统搜索系统没有能力发现这种语义上的等价。

词袋模型(Bag of Words) 是最早的文本表示方法:把文档表示成词频向量。词汇表有 10 万个词,每个文档就是一个 10 万维的向量,每个维度是对应词在文档中出现的次数。

python
# 词袋模型示例
documents = ["我爱苹果手机", "iPhone 很好用", "苹果是一种水果"]
vocab = {"我": 0, "爱": 1, "苹果": 2, "手机": 3, "iPhone": 4, "很": 5, "好用": 6, "是": 7, "一种": 8, "水果": 9}

# 文档向量(词频)
doc1 = [1, 1, 1, 1, 0, 0, 0, 0, 0, 0]  # "我爱苹果手机"
doc2 = [0, 0, 0, 0, 1, 1, 1, 0, 0, 0]  # "iPhone 很好用"
doc3 = [0, 0, 1, 0, 0, 0, 0, 1, 1, 1]  # "苹果是一种水果"

# 问题:doc1 和 doc2 在语义上相近(都是说苹果手机好),
# 但向量没有任何重叠,余弦相似度为 0

词袋模型的问题是稀疏(大量 0)和无语义("iPhone"和"苹果手机"是完全不同的向量维度)。

稠密向量(Dense Vector) 是 Embedding 的输出:把文本映射到一个低维(通常 256~3072 维)的连续向量空间。这个空间是通过大量文本数据训练得到的,训练过程让语义相近的文本映射到距离相近的位置。

"相似含义的词在空间中距离近"不是一个人为设计的规则,而是训练过程自然涌现的属性。当模型被训练来预测上下文词汇时,经常出现在相同上下文中的词(如"iPhone"和"苹果手机"经常出现在讨论科技产品的文章中)就会被映射到向量空间的相近位置。

python
# 稠密向量示例(实际是 1536 维,这里用 4 维简化说明)
# 这些数字是模型学到的,不需要人工设计
doc1_embedding = [0.82, -0.31,  0.15,  0.64]  # "苹果手机"
doc2_embedding = [0.79, -0.28,  0.18,  0.61]  # "iPhone",和上面很接近
doc3_embedding = [0.12,  0.67, -0.45,  0.23]  # "苹果水果",和上面差距大

1.2 余弦相似度 vs 欧氏距离

1.2.1 为什么要用余弦相似度:方向比距离更重要

在向量空间中比较两个向量的相似度,最直觉的方法是欧氏距离:两点之间的直线距离。但对于文本语义比较,欧氏距离有一个严重问题。

考虑这两段文本:"人工智能很厉害"(5个字)和"人工智能技术非常厉害,改变了很多行业"(17个字)。两段文本表达的核心意思相近,但因为长度不同,它们的向量长度(模)可能相差很大。欧氏距离会受向量模长影响,导致这两段文本看起来"距离很远",但实际上语义相近。

余弦相似度只看两个向量的方向,不受长度影响。两个向量指向相同方向(余弦值接近 1)就意味着语义相近,无论它们的长度如何。这对文本场景特别合适:一句话和一篇文章表达相同意思,它们的向量应该方向相近,尽管长度不同。

两个向量的相似度有多种计算方式,最常用的是余弦相似度(Cosine Similarity,衡量两个向量"方向"的相近程度,取值 -1 到 1,越接近 1 越相似):

python
import numpy as np
from typing import Union

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    """
    余弦相似度:计算两个向量夹角的余弦值
    范围 [-1, 1],1 表示完全相同,0 表示正交(无关),-1 表示相反
    """
    # np.dot:向量点积
    # np.linalg.norm:向量的 L2 范数(模长)
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

def euclidean_distance(a: np.ndarray, b: np.ndarray) -> float:
    """欧氏距离:两个向量在空间中的直线距离,越小越相似"""
    return np.linalg.norm(a - b)

# 示例:比较几个句子的语义相似度
sentences = [
    "今天天气很好",
    "天气不错,阳光明媚",
    "人工智能发展很快",
    "AI 技术进步迅猛",
]

# 向量化后(实际需要调用 Embedding API)
v1 = np.array([0.9, 0.1, 0.05, 0.02])  # "今天天气很好"
v2 = np.array([0.88, 0.12, 0.04, 0.03])  # "天气不错,阳光明媚"
v3 = np.array([0.05, 0.08, 0.91, 0.15])  # "人工智能发展很快"
v4 = np.array([0.04, 0.07, 0.89, 0.14])  # "AI 技术进步迅猛"

print(cosine_similarity(v1, v2))  # ~0.998,非常相似(同一话题)
print(cosine_similarity(v1, v3))  # ~0.15,很不相似(不同话题)
print(cosine_similarity(v3, v4))  # ~0.999,非常相似(都是讲 AI)

上面的代码验证了余弦相似度的工作方式:语义相近的句子("天气很好"vs"天气不错")余弦值接近 1;语义不同的句子("天气"vs"AI")余弦值接近 0。

余弦相似度 vs 欧氏距离的选择:

维度 余弦相似度 欧氏距离
衡量的是 向量方向的相似性 向量在空间中的距离
向量长度影响 不影响(只看方向) 有影响(长向量距离大)
适用场景 文本语义相似(长短文档都适用) 向量已归一化的场景
范围 [-1, 1],越大越相似 [0, +∞),越小越相似
主流向量数据库默认 是(大多数用余弦) 也支持

对于文本 Embedding,推荐用余弦相似度。一句话和一篇文章的 Embedding 向量长度差异很大,但余弦相似度不受向量模长影响,只看语义方向。

1.3 调用 OpenAI Embedding API

python
from openai import OpenAI
import numpy as np

client = OpenAI()

def get_embedding(text: str, model: str = "text-embedding-3-small") -> np.ndarray:
    """
    获取单条文本的 Embedding 向量
    text-embedding-3-small:1536 维,每百万 token $0.02(便宜但足够用)
    text-embedding-3-large:3072 维,每百万 token $0.13(精度更高)
    """
    # 清理文本:换行符会影响 Embedding 质量
    text = text.replace("\n", " ").strip()
    response = client.embeddings.create(input=text, model=model)
    return np.array(response.data[0].embedding)

def get_embeddings_batch(
    texts: list[str],
    model: str = "text-embedding-3-small"
) -> list[np.ndarray]:
    """
    批量获取 Embedding,一次 API 调用处理多条文本
    比逐条调用快很多,且节省 API 开销
    """
    texts = [t.replace("\n", " ").strip() for t in texts]
    response = client.embeddings.create(input=texts, model=model)
    # 按 index 排序,确保顺序和输入一致
    sorted_data = sorted(response.data, key=lambda x: x.index)
    return [np.array(item.embedding) for item in sorted_data]

# 计算两段文本的语义相似度
def semantic_similarity(text1: str, text2: str) -> float:
    """计算两段文本的语义相似度,返回 0 到 1 之间的分数"""
    emb1, emb2 = get_embeddings_batch([text1, text2])
    # 余弦相似度可能是负数,这里映射到 [0, 1]
    cosine = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
    return float((cosine + 1) / 2)

# 示例
score = semantic_similarity("苹果手机的价格", "iPhone 多少钱")
print(f"相似度:{score:.3f}")  # 约 0.95,非常相似

1.4 OpenAI Embedding 模型对比

模型 维度 价格(/百万 token) 特点
text-embedding-3-small 1536 $0.02 性价比高,大多数场景足够
text-embedding-3-large 3072 $0.13 精度更高,适合对精度要求高的场景
text-embedding-ada-002 1536 $0.10 旧模型,不推荐新项目使用

text-embedding-3 系列的一个重要特性是维度截断:可以指定比模型最大维度更低的输出维度,在精度和存储成本之间取平衡:

python
# 指定输出 256 维(而不是默认的 1536 维)
# 存储空间减少 6 倍,相似度精度小幅下降
response = client.embeddings.create(
    input="文本内容",
    model="text-embedding-3-small",
    dimensions=256,  # 截断到 256 维
)

1.5 中文 Embedding 模型

对于中文场景,OpenAI 的 Embedding 模型效果可用,但专门针对中文训练的模型通常有更好的表现:

模型 来源 维度 特点
BGE-M3 北京智源 1024 多语言,支持中英文,开源
BGE-large-zh 北京智源 1024 专为中文优化,MTEB 中文榜第一梯队
m3e-large 美团 768 中文语义搜索专用,开源
text2vec-large-chinese 社区 1024 中文场景效果好,开源

使用开源中文 Embedding 模型:

python
from sentence_transformers import SentenceTransformer
import numpy as np

# 加载 BGE 模型(首次会下载约 600MB)
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")

def get_chinese_embeddings(texts: list[str]) -> np.ndarray:
    """
    使用 BGE 中文模型获取 Embedding
    BGE 模型建议在查询文本前加 "为这个句子生成表示以用于检索相关文章:"
    这是 BGE 的特定训练指令,能提升检索效果
    """
    # 对查询文本加前缀(对文档文本不需要加)
    texts_with_instruction = [
        f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts
    ]
    embeddings = model.encode(texts_with_instruction, normalize_embeddings=True)
    return embeddings

1.6 典型应用:语义搜索

1.6.1 Embedding 在 RAG 中的核心作用

Embedding 是 RAG(检索增强生成)系统的核心组件。理解 Embedding 如何在 RAG 中工作,能帮助理解为什么 RAG 能有效缓解 LLM 幻觉问题。

RAG 的基本流程:

  1. 索引阶段:把知识库中的所有文档转换成 Embedding 向量,存入向量数据库
  2. 查询阶段:把用户问题也转换成 Embedding 向量
  3. 检索阶段:用余弦相似度找出知识库中语义最接近的文档片段
  4. 生成阶段:把检索到的文档作为上下文,和用户问题一起送给 LLM 生成回答

这个流程的关键在于:检索到的文档是基于语义相似度而不是关键词匹配,所以即使用户的问法和文档的表述方式不同,只要语义相近,就能找到相关内容。

以下是一个完整的语义搜索实现,不依赖向量数据库,适合小数据量场景:

python
import numpy as np
from dataclasses import dataclass
from openai import OpenAI

client = OpenAI()

@dataclass
class Document:
    id: str
    content: str
    embedding: np.ndarray | None = None

class SimpleSemanticSearch:
    """
    基于 Embedding 的简单语义搜索
    小数据量(<10万条)用暴力搜索就够了,不需要向量数据库
    """

    def __init__(self, model: str = "text-embedding-3-small"):
        self.model     = model
        self.documents: list[Document] = []

    def add_documents(self, docs: list[dict]) -> None:
        """添加文档并生成 Embedding(批量处理)"""
        texts    = [d["content"] for d in docs]
        embeddings = self._get_embeddings(texts)
        for doc, emb in zip(docs, embeddings):
            self.documents.append(Document(
                id=doc["id"],
                content=doc["content"],
                embedding=emb,
            ))

    def search(self, query: str, top_k: int = 5) -> list[dict]:
        """
        语义搜索:返回最相似的 top_k 篇文档
        时间复杂度 O(n),n 是文档数量
        """
        query_emb = self._get_embeddings([query])[0]

        # 计算查询向量与所有文档向量的余弦相似度
        scores = []
        for doc in self.documents:
            score = np.dot(query_emb, doc.embedding) / (
                np.linalg.norm(query_emb) * np.linalg.norm(doc.embedding)
            )
            scores.append((score, doc))

        # 按相似度降序排列,取前 top_k
        scores.sort(key=lambda x: x[0], reverse=True)
        return [
            {"id": doc.id, "content": doc.content, "score": float(score)}
            for score, doc in scores[:top_k]
        ]

    def _get_embeddings(self, texts: list[str]) -> list[np.ndarray]:
        response = client.embeddings.create(input=texts, model=self.model)
        sorted_data = sorted(response.data, key=lambda x: x.index)
        return [np.array(item.embedding) for item in sorted_data]

# 使用示例
searcher = SimpleSemanticSearch()
searcher.add_documents([
    {"id": "1", "content": "Python 是一门动态类型的编程语言"},
    {"id": "2", "content": "Java 是静态类型的面向对象语言"},
    {"id": "3", "content": "机器学习需要大量的数据"},
    {"id": "4", "content": "深度学习是机器学习的子领域"},
    {"id": "5", "content": "PyTorch 是常用的深度学习框架"},
])

results = searcher.search("AI 训练需要什么", top_k=3)
for r in results:
    print(f"[{r['score']:.3f}] {r['content']}")
# [0.912] 机器学习需要大量的数据
# [0.876] 深度学习是机器学习的子领域
# [0.834] PyTorch 是常用的深度学习框架

向量语义搜索的完整流程:

查询阶段

索引阶段

原始文档

文本预处理
(分块、清洗)

Embedding API
(批量转换)

向量数据库
(存储 + 索引)

用户查询文本

Embedding API
(查询向量化)

向量相似度搜索
(余弦相似度)

Top-K 结果
(文档 + 相似度分数)

1.7 小结

Embedding 把文本的语义编码进数字向量:训练过的模型让意思相近的文本在向量空间中距离更近。余弦相似度是计算文本语义相近程度的标准方法,不受向量长度影响。

模型选型上:英文场景首选 OpenAI text-embedding-3-small(性价比高),中文场景 BGE-large-zh 或 m3e-large 效果更好。数据量小于 10 万条,暴力遍历计算余弦相似度就够了;超过这个量级,需要接入 Faiss(Facebook开源的高效向量检索库)、Chroma、Pinecone 等向量数据库(专门用于存储和检索高维向量的数据库)做近似最近邻(ANN,Approximate Nearest Neighbor,在海量向量中快速找出最相似的几个,而不是精确遍历)搜索。

下一篇介绍 LLM 上下文窗口管理:当文档或对话超出模型的 token 上限时,有哪些策略处理长文本,以及如何在不损失关键信息的前提下压缩对话历史。

本页目录