RAG 系列 1:Embeddings 从向量到语义相似度


LangChain 系列已经完成模型接入、消息、提示词、结构化输出和聊天应用。前面的示例主要使用聊天模型:向模型发送一段文字,再得到一段可以直接阅读的回答。

从本文开始进入独立的 RAG 系列。RAG 的第一步不是直接搭建知识库,而是先理解文本怎样变成可以检索和比较的向量。

本文讨论的是文本 Embedding 模型。它的工作方式与聊天模型完全不同:输入仍然是文字,但输出不再是回答,而是一串浮点数:

输入:苹果是一种常见的水果。
输出:[0.012, -0.037, 0.084, ...]

这串数字称为嵌入向量。程序可以比较不同文本的向量,判断它们在语义上是否接近。

本文先不加载真正的模型,而是从向量、One-Hot 和余弦相似度开始,把 Embeddings 最基础的原理讲清楚。下一篇再下载 Qwen3 Embedding 模型进行真实测试。

1. Embedding 模型和聊天模型有什么区别

聊天模型的目标是生成内容:

输入:请介绍杭州西湖。
输出:杭州西湖位于浙江省杭州市,是著名的自然与人文景观……

Embedding 模型的目标是把内容转换成向量:

输入:请介绍杭州西湖。
输出:[0.018, -0.026, 0.041, ...]

两类模型的输出用途不同:

  • 聊天模型的输出主要交给人阅读。
  • Embedding 模型的输出主要交给程序计算。
  • 聊天模型回答“这段文字说了什么”。
  • Embedding 模型帮助程序判断“这段文字和另一段文字有多接近”。

Embedding 模型不是聊天模型的另一种调用参数。它是为向量表示和相似度比较专门训练的模型。

2. 什么是向量

在程序中,可以先把向量理解成一个数字列表:

vector = [0.9, 0.1]

这个向量有两个数字,因此它是一个二维向量。下面这个向量有四个数字,因此它是四维向量:

vector = [0.2, -0.3, 0.7, 0.1]

真实 Embedding 模型通常会生成几百维或几千维向量。例如,下一篇使用的 Qwen3-Embedding-0.6B 默认生成 1024 维向量。

二维和三维向量可以画在平面或空间中,高维向量虽然无法直接画出来,但数学计算方式没有改变。计算距离时,通常把文本向量看作空间中的点;计算余弦相似度时,更关注向量从原点出发的方向。

下面的图只用于解释两种编码方式的差别。右侧的二维位置是高维语义空间的概念投影,不是真实模型生成的坐标。

One-Hot 与 Embedding 表达方式

语义相近的文本,通常会被模型映射到比较接近的位置;语义差别较大的文本,位置和方向通常也会有明显差别。

需要注意,这里说的是整体向量表达的语义。不能简单认为第一个数字表示“水果”、第二个数字表示“颜色”。大多数情况下,单个维度没有这种可以直接阅读的固定含义。

3. One-Hot 如何把词语变成向量

在学习 Embeddings 之前,可以先看一种更简单的编码方式:One-Hot。

假设词表中只有三个词:

[苹果, 香蕉, 汽车]

为每个词分配一个位置:

苹果 -> [1, 0, 0]
香蕉 -> [0, 1, 0]
汽车 -> [0, 0, 1]

每个向量只有一个位置是 1,其余位置都是 0,因此被称为 One-Hot。

3.1 编写 One-Hot 示例

代码文件为 01_one_hot_encoding.py:

# 这个文件演示 One-Hot 编码的生成方式,以及它无法表达词语语义关系的问题。
# 示例只使用 Python 标准库,运行时不需要加载任何模型。

from math import sqrt


# 词表中的每个词都占据一个固定位置。
vocabulary = ["苹果", "香蕉", "汽车"]


def one_hot(word: str) -> list[int]:
    """把词语转换成 One-Hot 向量。"""
    if word not in vocabulary:
        raise ValueError(f"词语不在词表中:{word}")
    return [1 if item == word else 0 for item in vocabulary]


def cosine_similarity(vector_a: list[int], vector_b: list[int]) -> float:
    """计算两个向量的余弦相似度。"""
    dot_product = sum(a * b for a, b in zip(vector_a, vector_b))
    norm_a = sqrt(sum(value * value for value in vector_a))
    norm_b = sqrt(sum(value * value for value in vector_b))
    if norm_a == 0 or norm_b == 0:
        raise ValueError("余弦相似度不能用于零向量")
    return dot_product / (norm_a * norm_b)


apple_vector = one_hot("苹果")
banana_vector = one_hot("香蕉")
car_vector = one_hot("汽车")

print("词表:", vocabulary)
print("苹果:", apple_vector)
print("香蕉:", banana_vector)
print("汽车:", car_vector)
print("苹果和香蕉:", cosine_similarity(apple_vector, banana_vector))
print("苹果和汽车:", cosine_similarity(apple_vector, car_vector))

运行:

python rag/p01_embedding_basics/01_one_hot_encoding.py

真实输出为:

词表: ['苹果', '香蕉', '汽车']
苹果: [1, 0, 0]
香蕉: [0, 1, 0]
汽车: [0, 0, 1]
苹果和香蕉: 0.0
苹果和汽车: 0.0

人可以看出“苹果”和“香蕉”都是水果,应该比“苹果”和“汽车”更接近。但 One-Hot 只记录了词语在词表中的身份,两个不同词的向量互相垂直,所以两组相似度都是 0。

3.2 One-Hot 的主要问题

One-Hot 有几个明显限制。

第一,向量长度依赖词表大小。词表有 3 个词,向量就是 3 维;词表有 10 万个词,向量就会达到 10 万维。

第二,向量非常稀疏。无论词表多大,每个向量通常只有一个 1,其余位置全是 0。

第三,它无法直接表达语义关系。“苹果”和“香蕉”、“苹果”和“汽车”在数学上没有区别。

第四,词表以外的新词没有预先分配的位置,需要增加词表并改变向量长度。示例代码遇到词表外词语时会直接抛出异常,避免把它误表示成没有任何有效位置的零向量。

4. Embeddings 解决了什么问题

Embedding 模型不会只给每个词分配一个互不相关的位置,而是通过训练学习文本之间的关系,把文本映射成稠密向量。

稠密向量中的大部分位置都有数值:

[0.018, -0.026, 0.041, 0.007, ...]

模型生成向量时会考虑训练过程中学到的语言规律,因此不同表达也可能得到相近的向量。例如:

今天天气很好,适合散步。
外面阳光明媚,可以出去走走。

这两句话没有使用完全相同的词,但表达的含义接近,Embedding 模型通常能让它们在向量空间中更接近。

Embedding 的重点不是把文字机械地换成数字,而是尽可能把文本中的语义关系保留在向量中。

5. 怎么比较两个向量

得到向量以后,还需要使用数学指标比较它们。常见方式有点积、欧氏距离和余弦相似度。

5.1 点积

两个向量对应位置相乘后再相加,就是点积:

[a1, a2] · [b1, b2] = a1 × b1 + a2 × b2

点积同时受到向量方向和长度影响。只有在向量来自同一空间、长度或归一化规则可比较时,点积大小才适合直接用于排序。

5.2 欧氏距离

欧氏距离可以理解为两个点之间的直线距离:

distance = sqrt((a1 - b1)² + (a2 - b2)²)

距离越小,两个点越接近。欧氏距离也会受到向量长度和数值尺度影响,因此比较前需要保证向量来自同一个模型和同一套处理方式。

5.3 余弦相似度

余弦相似度主要比较两个向量的方向:

cosine_similarity = 点积 / (向量 A 的长度 × 向量 B 的长度)

对于非零向量,余弦相似度的取值范围是 -1 到 1:方向相同接近 1,互相垂直接近 0,方向相反接近 -1。用于文本语义比较时,通常是数值越大越相似

还要区分余弦相似度和余弦距离。一种常见定义是:

cosine_distance = 1 - cosine_similarity

在这种定义下,余弦距离才是数值越小越接近。阅读模型或向量数据库的接口文档时,必须先确认它返回的是 similarity 还是 distance。

实际使用中经常先把 Embedding 向量归一化到长度为 1。归一化以后,两个向量的点积就等于它们的余弦相似度。

5.4 用二维向量观察三种计算方式

为了让结果容易理解,下面手工设置三个二维向量。这些数字只是教学示意,不是真实 Embedding 模型生成的结果。

代码文件为 02_cosine_similarity.py:

# 这个文件使用二维教学向量,演示点积、欧氏距离和余弦相似度。
# 这些二维向量是为了方便画图而手工设置的,不是真实 Embedding 模型的输出。

from math import sqrt


def dot_product(vector_a: list[float], vector_b: list[float]) -> float:
    """计算两个向量的点积。"""
    return sum(a * b for a, b in zip(vector_a, vector_b))


def euclidean_distance(vector_a: list[float], vector_b: list[float]) -> float:
    """计算两个向量之间的直线距离。"""
    return sqrt(sum((a - b) ** 2 for a, b in zip(vector_a, vector_b)))


def cosine_similarity(vector_a: list[float], vector_b: list[float]) -> float:
    """计算两个向量方向的相似程度。"""
    norm_a = sqrt(sum(value * value for value in vector_a))
    norm_b = sqrt(sum(value * value for value in vector_b))
    if norm_a == 0 or norm_b == 0:
        raise ValueError("余弦相似度不能用于零向量")
    return dot_product(vector_a, vector_b) / (norm_a * norm_b)


# 苹果和香蕉方向接近;汽车向量的方向与它们明显不同。
apple = [0.9, 0.1]
banana = [0.8, 0.2]
car = [-0.1, 0.9]

for name, vector in [("香蕉", banana), ("汽车", car)]:
    print(f"苹果与{name}")
    print(f"  点积:{dot_product(apple, vector):.4f}")
    print(f"  欧氏距离:{euclidean_distance(apple, vector):.4f}")
    print(f"  余弦相似度:{cosine_similarity(apple, vector):.4f}")

真实输出为:

苹果与香蕉
  点积:0.7400
  欧氏距离:0.1414
  余弦相似度:0.9910
苹果与汽车
  点积:0.0000
  欧氏距离:1.2806
  余弦相似度:0.0000

在这个示意中,“苹果”和“香蕉”的方向非常接近,余弦相似度为 0.9910;“苹果”和“汽车”的方向差别很大,相似度为 0。

真正的 Embedding 向量会有更多维度,但比较方式仍然相同。

二维教学向量与三种比较指标

这张图和代码使用同一组二维向量。它说明三种指标的数值方向并不相同:点积越大通常表示方向和长度的综合匹配更强,欧氏距离越小表示端点越近,余弦相似度越大表示方向越接近。实际项目不能在不知道指标含义的情况下直接比较分数。

6. 稀疏向量和稠密向量

One-Hot 是典型的稀疏向量:

[0, 0, 0, 1, 0, 0, 0, 0, ...]

大部分位置都是 0。

本系列使用的 Embedding 是稠密向量:

[0.018, -0.026, 0.041, 0.007, ...]

大部分位置都有值。

稠密不代表维度一定很低。相对于可能包含几十万位置的 One-Hot,1024 维已经小很多,但它仍然属于人无法直接画出来的高维向量。

7. 在线模型和本地模型

使用 Embedding 模型通常有两种方式。

在线 API 由模型提供商负责运行模型。程序发送文本,接口返回向量。它不需要在本机保存模型,接入速度快,但通常需要密钥、网络和调用费用,文本也需要发送到外部服务。

本地开源模型需要先下载权重,再使用本机 CPU、GPU 或 Apple Silicon 运行。首次准备工作更多,也会占用磁盘和内存,但数据不需要离开本机,重复调用不产生按次费用。

LangChain 为不同提供商提供了统一的 Embeddings 接口,最重要的两个方法是:

embed_query(text)         -> 一条查询对应的向量
embed_documents(texts)    -> 多条文本对应的向量列表

下一篇会使用本地 Qwen3 Embedding,并通过这两个接口完成真实测试。

8. 初学者容易混淆的问题

8.1 Embedding 向量可以直接阅读吗

不可以。向量是给程序计算的数字表示,不是加密后的自然语言,也不能逐项翻译回原句。

8.2 维度越高一定越好吗

不一定。维度增加通常会提高存储和计算成本,效果还与模型训练、数据和任务有关,不能只根据维度判断模型质量。

8.3 不同模型的向量可以直接比较吗

通常不可以。不同模型学习出的向量空间并不相同。即使两个模型都输出 1024 维,也不能因此认为对应维度具有相同含义。

8.4 同一个模型更换配置后需要注意什么

查询和候选文本必须使用相互兼容的模型、维度和归一化方式。如果一部分向量来自旧模型,另一部分来自新模型,直接比较可能失去意义。

8.5 相似度高等于内容完全正确吗

不等于。相似度表示模型判断两段内容在当前向量空间中比较接近,它不是事实核验,也不能证明文本内容一定正确。

9. 小结

Embedding 模型把文本转换成固定长度的稠密向量,让程序可以通过数学计算比较文本语义。

One-Hot 只能区分词语身份,无法表示不同词语之间的语义关系;Embedding 则通过训练把语义关系保留在向量空间中。得到向量以后,可以使用点积、欧氏距离或余弦相似度进行比较,其中余弦相似度是文本向量中非常常见的选择。

本文使用的是手工向量。下一篇将下载 Qwen3-Embedding-0.6B,观察真实的 1024 维向量,并通过 LangChain 的标准 Embeddings 接口调用它。

知识点 输入 输出 主要特点
聊天模型 消息或提示词 自然语言回答 主要用于生成内容
Embedding 模型 文本 浮点数向量 主要用于语义表示和计算
One-Hot 词语 稀疏向量 只能区分身份,不能表达语义接近程度
稠密 Embedding 文本 稠密向量 可以保留模型学习到的语义关系
点积 两个向量 一个数值 同时受方向和长度影响
欧氏距离 两个向量 一个非负距离 越小表示空间位置越近
余弦相似度 两个非零向量 -1 到 1 越大表示方向越接近
余弦距离 两个非零向量 常见定义为 1 - 余弦相似度 越小表示方向越接近
embed_query() 一条查询文本 list[float] 生成单条查询向量
embed_documents() 多条文本 list[list[float]] 批量生成文档向量

文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录