Embedding向量化原理-语义搜索的数学基础
> **本文适合谁**
Embedding 向量化原理:语义搜索的数学基础
本文适合谁
准备构建 RAG 系统或语义搜索功能的开发者。Embedding 是 RAG 的数学基础,理解它才能理解"为什么检索能找到意思相近的内容"。不需要线性代数背景,用直觉理解向量空间。
关键词搜索只能匹配字面上相同的词。搜索"苹果手机"不会返回包含"iPhone"的文档,搜索"价格便宜"也不会匹配"性价比高"。语义搜索要解决的是另一个问题:理解文本的含义,而不只是找词的匹配。
Embedding(嵌入向量)是实现语义搜索的核心技术:把文本转换成一个数字向量(比如用1536个数字来表示一句话的含义),意思相近的文本在向量空间中的距离也近。
1.1 从词袋模型到稠密向量
1.1.1 为什么词袋模型失败了:稀疏与语义缺失
理解 Embedding 之前,先看它解决了什么问题。
图 6.10:Embedding 向量空间——语义相似的词在高维空间中距离更近
想象这样一个场景:你有一个文档数据库,想找出所有"讨论苹果公司产品"的文章。如果用关键词搜索,你必须知道所有可能的关键词:"Apple"、"iPhone"、"iPad"、"Mac"、"Tim Cook"……哪怕漏掉一个,相关文档就会被遗漏。
更根本的问题是:关键词搜索无法理解语义等价性。"这款手机价格实惠"和"这款手机性价比高"表达的是相同的意思,但在关键词层面完全不同。传统搜索系统没有能力发现这种语义上的等价。
词袋模型(Bag of Words) 是最早的文本表示方法:把文档表示成词频向量。词汇表有 10 万个词,每个文档就是一个 10 万维的向量,每个维度是对应词在文档中出现的次数。
# 词袋模型示例
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"和"苹果手机"经常出现在讨论科技产品的文章中)就会被映射到向量空间的相近位置。
# 稠密向量示例(实际是 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 越相似):
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
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 系列的一个重要特性是维度截断:可以指定比模型最大维度更低的输出维度,在精度和存储成本之间取平衡:
# 指定输出 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 模型:
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 的基本流程:
- 索引阶段:把知识库中的所有文档转换成 Embedding 向量,存入向量数据库
- 查询阶段:把用户问题也转换成 Embedding 向量
- 检索阶段:用余弦相似度找出知识库中语义最接近的文档片段
- 生成阶段:把检索到的文档作为上下文,和用户问题一起送给 LLM 生成回答
这个流程的关键在于:检索到的文档是基于语义相似度而不是关键词匹配,所以即使用户的问法和文档的表述方式不同,只要语义相近,就能找到相关内容。
以下是一个完整的语义搜索实现,不依赖向量数据库,适合小数据量场景:
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 是常用的深度学习框架
向量语义搜索的完整流程:
1.7 小结
Embedding 把文本的语义编码进数字向量:训练过的模型让意思相近的文本在向量空间中距离更近。余弦相似度是计算文本语义相近程度的标准方法,不受向量长度影响。
模型选型上:英文场景首选 OpenAI text-embedding-3-small(性价比高),中文场景 BGE-large-zh 或 m3e-large 效果更好。数据量小于 10 万条,暴力遍历计算余弦相似度就够了;超过这个量级,需要接入 Faiss(Facebook开源的高效向量检索库)、Chroma、Pinecone 等向量数据库(专门用于存储和检索高维向量的数据库)做近似最近邻(ANN,Approximate Nearest Neighbor,在海量向量中快速找出最相似的几个,而不是精确遍历)搜索。
下一篇介绍 LLM 上下文窗口管理:当文档或对话超出模型的 token 上限时,有哪些策略处理长文本,以及如何在不损失关键信息的前提下压缩对话历史。