理解 RAG、搜索、推荐共同的数学基础
先跑一段代码看输出
理论先放一放。下面这段代码大概 5 分钟能跑完,看完输出再回过头讲它做了什么。
打开终端:
pip install sentence-transformers
新建一个 hello_embedding.py:
from sentence_transformers import SentenceTransformer
# 第一次跑会下载约 2 GB 的模型权重,需要等几分钟
model = SentenceTransformer("BAAI/bge-m3")
texts = [
"如何重置密码",
"忘记密码怎么办",
"今天的午饭吃什么",
]
# 把文字变成向量(一段 1024 维的浮点数数组)
vectors = model.encode(texts, normalize_embeddings=True)
print("向量形状:", vectors.shape)
# 算两两相似度(归一化向量的点积 = 余弦相似度)
similarity = vectors @ vectors.T
print("相似度矩阵:")
print(similarity.round(3))
运行:
python hello_embedding.py
你会看到类似这样的输出:
向量形状: (3, 1024)
相似度矩阵:
[[1. 0.823 0.214]
[0.823 1. 0.197]
[0.214 0.197 1. ]]
逐行看这个矩阵——
第一行第二列的 0.823,是"如何重置密码"和"忘记密码怎么办"之间的相似度。这两句话没有一个相同的词,一个用"重置"、一个用"忘记",模型仍然给出了 0.823 这个数值。
第一行第三列的 0.214,是"如何重置密码"和"今天的午饭吃什么"之间的相似度。两个话题不相干,数值就低。
embedding 做的事情就是这一件——把文字编码成数组,让两段文字"像不像"用数组之间的距离表达。语义搜索、RAG 知识库、推荐系统、客服工单去重都建立在这一件事上。
下面把这件事拆开讲,每一步都配一段可跑的代码。
向量到底是什么:从 2 维开始
上面那段代码里 1024 维向量太抽象。先用 2 维给你建立直觉,再扩展。
想象你要在二维坐标系里给电影打标签。横轴是"动作程度"(0 = 完全没有打斗、1 = 全程打斗),纵轴是"喜剧程度"(0 = 严肃、1 = 全程搞笑)。三部电影的位置可能这样:
喜剧程度
1.0 │
│ • 喜剧之王 (0.2, 0.95)
0.8 │
│
0.6 │ • 死侍 (0.85, 0.7)
│
0.4 │
│ • 速度与激情 (0.95, 0.2)
0.2 │
│
0.0 └──────────────────────────── 动作程度
0.0 0.2 0.4 0.6 0.8 1.0
每部电影变成了 (动作度, 喜剧度) 这样一个 2 维向量。"喜剧之王"和"死侍"在喜剧度上接近、距离不远;而"喜剧之王"和"速度与激情"两个轴都对不上、距离很远。距离近 = 像、距离远 = 不像。
文本 embedding 就是同样的思路,只是把维度从 2 个扩展到几百到几千个,每个维度模型自己决定代表什么——可能是"是否技术话题"、"情感正负"、"语气正式程度"等等。这些维度对人类不可解释,但对模型是有效的内部坐标。
回到刚才的代码:1024 维向量就是 1024 个数字组成的数组,每个文本对应一个这样的数组,文本和文本之间的"像不像"通过这些数组的距离来度量。
怎么算"距离":余弦相似度,亲手算一遍
最常用的相似度叫 余弦相似度(cosine similarity)——把两个向量看成从原点出发的箭头,比较它们的夹角。完全同向是 1,垂直是 0,反向是 -1。文本场景下值通常在 0~1 之间。
公式两行:
sim(a, b) = (a · b) / (|a| × |b|)
其中:
a · b = 两个向量逐元素相乘后求和(点积)
|a| = 向量 a 的长度(每个元素平方求和再开根号)
亲手跑一遍:
import numpy as np
# 两个 3 维向量
a = np.array([1.0, 2.0, 3.0])
b = np.array([2.0, 3.0, 1.0])
dot = np.dot(a, b) # 1*2 + 2*3 + 3*1 = 11
norm_a = np.linalg.norm(a) # √(1+4+9) ≈ 3.742
norm_b = np.linalg.norm(b) # √(4+9+1) ≈ 3.742
sim = dot / (norm_a * norm_b) # 11 / (3.742 * 3.742) ≈ 0.786
print(f"点积={dot}, |a|={norm_a:.3f}, |b|={norm_b:.3f}, 相似度={sim:.3f}")
输出:
点积=11.0, |a|=3.742, |b|=3.742, 相似度=0.786
写成函数就是这个:
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
实际写代码时几乎不用自己实现这个公式——大多数 embedding 模型在输出向量时已经做了"归一化"。下一节专门说这件事。
什么是归一化
归一化(normalization)做的事就一句话:把向量的长度调整到 1,方向不变。
举个具体的。一个 3 维向量 a = [3, 4, 0],它的长度是:
|a| = √(3² + 4² + 0²) = √25 = 5
归一化的做法是把每一个分量都除以这个长度:
a_norm = [3/5, 4/5, 0/5] = [0.6, 0.8, 0]
新向量的长度:
|a_norm| = √(0.6² + 0.8² + 0²) = √1 = 1
长度变成 1,但方向跟原来完全一样——[3, 4, 0] 和 [0.6, 0.8, 0] 都指向同一个方向,只是一个比另一个长 5 倍。几何上想象就是:原来的向量是从原点出发的一支长箭头,归一化之后变成同方向但长度刚好 1 的短箭头。所有归一化后的向量末端都落在一个半径为 1 的球面上。
代码:
import numpy as np
a = np.array([3.0, 4.0, 0.0])
a_norm = a / np.linalg.norm(a)
print(a_norm) # [0.6 0.8 0.]
print(np.linalg.norm(a_norm)) # 1.0
为什么要做这件事?主要是简化余弦相似度的计算。回到上一节的公式:
sim(a, b) = (a · b) / (|a| × |b|)
如果 |a| = |b| = 1(都被归一化过),分母就是 1,整个公式简化成纯点积:
sim(a, b) = a · b
少一次除法、少两次开根号。一次没什么,向量库里上百万次比较加起来差距可观。所以主流的 embedding 模型几乎都提供"归一化输出"的选项——传 normalize_embeddings=True,下游算相似度直接用点积:
# 归一化向量直接用点积就行
sim = float(np.dot(vec_a, vec_b))
一个常见的坑:归一化只在比较方向的时候有意义。在 embedding 检索这个场景里,向量长度不携带任务信息(模型训练时优化的就是"语义方向"),归一化掉长度刚好把无用的那部分扔掉。但如果你打算用欧式距离做检索,归一化反而会让所有向量挤到同一个球面上、距离区分度变差。文本检索 99% 用余弦相似度,所以默认归一化不会错。
一个能用的语义搜索(30 行)
把向量和相似度拼起来,下面这段代码就是一个最小的语义搜索——给一组候选文本和一个查询,返回相关性最高的几条:
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3")
# 假装这是你的"知识库"
docs = [
"Python 的列表推导式让代码更简洁",
"如何在 macOS 上安装 Homebrew",
"FastAPI 是一个现代的 Python Web 框架",
"煮意大利面要在水里加盐",
"用 Pandas 处理 CSV 文件的最佳实践",
"今天去爬山看到了一只松鼠",
"asyncio 提供了 Python 的异步编程模型",
]
# 启动时把所有文档编码一次,存在内存里
doc_vecs = model.encode(docs, normalize_embeddings=True)
def search(query: str, top_k: int = 3):
q_vec = model.encode([query], normalize_embeddings=True)[0]
sims = doc_vecs @ q_vec # 一次性算 query 和所有文档的相似度
idx = np.argsort(-sims)[:top_k] # 按相似度从高到低取前 k 个
return [(docs[i], float(sims[i])) for i in idx]
for q in ["Python 异步怎么写", "做饭技巧"]:
print(f"\n查询:{q}")
for text, score in search(q):
print(f" [{score:.3f}] {text}")
输出:
查询:Python 异步怎么写
[0.682] asyncio 提供了 Python 的异步编程模型
[0.512] FastAPI 是一个现代的 Python Web 框架
[0.481] Python 的列表推导式让代码更简洁
查询:做饭技巧
[0.624] 煮意大利面要在水里加盐
[0.198] 今天去爬山看到了一只松鼠
[0.187] 如何在 macOS 上安装 Homebrew
这两个查询的输出值得说三件事——
"Python 异步怎么写"召回了 asyncio 那条。注意候选文本里没有"异步"也没有"怎么写"这两个词,传统的关键词匹配在这种场景下完全失效,但 embedding 给出了 0.682。这是它相对 BM25 这类基于词频的检索方法的关键差别——它做的是语义匹配,不是字面匹配。
FastAPI 那条相似度是 0.512,排在第二。比 asyncio 低,但比纯语法话题"列表推导式"高。原因是 FastAPI 在训练语料里大概率和"Python Web 异步"这组词频繁共现,所以在向量空间里离"Python 异步"这个查询不远。
"做饭技巧"召回的 top-1 只有 0.624,剩下两条掉到 0.2 以下。这是一个有用的信号——top-1 相似度低于 0.5,通常意味着库里没有真正相关的文档,系统应该明确返回"找不到",而不是把不相关的内容硬塞给 LLM。这条经验在调 RAG 时反复用到。
把这个 demo 里内存里的 numpy 数组换成向量数据库(番外 14 讲),就是生产级 RAG 的检索层了。
API 路线:不想下载模型怎么办
sentence-transformers 第一次跑需要下载约 2 GB 的模型权重,硬盘空间不够、网速慢、或者只是想先试试看的话,用 API 路线更省心。所有主流厂商的 embedding API 都兼容 OpenAI 协议——你写一份代码,换 base_url 就能切换不同厂商。
国内最方便的是智谱 AI 的 embedding-3(注册送几百万 token),国外用 OpenAI 的 text-embedding-3-small:
import os
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv() # 从 .env 文件读 ZHIPU_API_KEY
client = OpenAI(
api_key=os.getenv("ZHIPU_API_KEY"),
base_url="https://open.bigmodel.cn/api/paas/v4/",
)
def embed(texts: list[str]) -> list[list[float]]:
"""传一组文本,一次返回所有向量。务必批量调用,不要循环单个调。"""
resp = client.embeddings.create(
model="embedding-3",
input=texts,
)
return [item.embedding for item in resp.data]
vecs = embed(["如何重置密码", "忘记密码怎么办", "今天午饭吃什么"])
print(f"返回 {len(vecs)} 个向量,每个 {len(vecs[0])} 维")
输出:
返回 3 个向量,每个 2048 维
智谱 embedding-3 是 2048 维,OpenAI text-embedding-3-small 是 1536 维(且支持手动降到更小,详见番外 12)。维度只是模型设计的差异,不影响你的代码——你不需要在意维度具体是多少,只要保证同一个 embedding 模型用始终如一就行。
注意:千万不要循环单个调 API
API 通常允许一次传一批文本(最多几百条一批),共用一次 HTTP 开销。下面这两段功能一样,但右边比左边快几十倍:
# 慢——每条都一次 HTTP 往返
for text in texts:
vec = embed([text])[0]
save(vec)
# 快——一次 HTTP 拿到全部
vecs = embed(texts)
for vec in vecs:
save(vec)
API 路线和本地路线选哪个?简单的判断:原型阶段、文档少、不在乎数据出网,用 API;要长期跑、量级大、有合规要求,用本地。两个都很简单,先把项目跑起来更重要,技术选型可以后期再切换。
跑代码时常见的坑
初学者第一次跑这套代码时,最容易踩三个坑——
模型下载特别慢或失败。SentenceTransformer("BAAI/bge-m3") 会从 HuggingFace 下载,国内访问慢甚至卡死。两条出路:用 HF 的国内镜像(设环境变量 HF_ENDPOINT=https://hf-mirror.com)、或者直接换更小的模型(比如 BAAI/bge-small-zh-v1.5,约 100 MB)先把代码跑通:
import os
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com" # 必须在 import sentence_transformers 之前
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-m3")
内存不够 / OOM。bge-m3 加载需要约 2 GB 内存,老笔记本可能无法承载。换 bge-small-zh-v1.5(约 200 MB)能解决大部分问题。或者直接走 API 路线,本地不存模型。
相似度怎么样才算"高"。0.5 算近吗?不同模型的相似度分布范围差别很大——bge-m3 的相关文档相似度通常在 0.6~0.85 之间,OpenAI 的 text-embedding-3 通常在 0.3~0.6 之间。不要凭直觉定阈值,而是在你自己的数据上跑一遍,看真实分布。番外 15 详细讲怎么测。
模型选哪个:起手 bge-m3,其他先放着
embedding 模型选型是个大话题(番外 12 专门讲),新手记一条原则就够:BAAI/bge-m3 起手,其他不用看。它中文 / 多语言 / 长文本三个都强,1024 维、8K 上下文,开源免费,社区文档最齐全。90% 的中文场景用它都不会错。
不能自托管的话用 OpenAI text-embedding-3-small,价格 $0.02/M token、英中通用,调一行代码就能用。
只有当你的项目跑了一段时间、有了真实数据和评测基线之后,才值得去研究 Voyage、Cohere、自己微调 bge 这些更复杂的路径。不要在没有 baseline 之前过早选型。
当文档量大起来:向量数据库
到目前为止我们把所有向量放在内存里的 numpy 数组里。文档少(< 10 万条)这么做没问题。但当文档量到百万、千万级,三个问题会同时出现——
每次查询要算 N 次点积,N 千万就是几秒钟(一个用户都等不起);千万 × 1024 维 × 4 字节 ≈ 40 GB 单机内存放不下;想加几条新文档你要重建整个数组吗?
向量数据库就是为这个场景而生的。它做两件事:用近似最近邻(ANN)算法把搜索复杂度从 O(N) 降到 O(log N) 级别(百万级文档查询毫秒级返回);提供持久化、并发、增量更新的工程接口。
主流选项:Chroma(嵌入式、像 SQLite 那样轻、原型期最方便)、Qdrant(生产环境的甜区、Docker 一行起)、Milvus(超大规模、企业级)、PgVector(已经在用 PostgreSQL 时最方便)、Pinecone(纯托管 SaaS)。
下一篇 RAG 实战我们用 Chroma——它的体验和 SQLite 一样轻,一个 Python 库 + 一个文件、不需要起任何服务。详细的向量库选型与 Qdrant 实战看番外 14。
本篇要点
embedding 做的事:把文本编码成一组数字(向量),用向量距离表达语义相似。语义搜索、RAG、推荐、去重这几类"找东西"的应用都基于这一件事。
实战记几条:用 bge-m3 起手;编码时加 normalize_embeddings=True 让后续相似度计算更简单;批量编码而不是循环单条;文档量到百万级再考虑向量库(Chroma 起步);相似度阈值在自己数据上测出来,不要照搬别人的经验值。
想再深入的话
- 番外 09——Embedding 的来龙去脉:从 one-hot 到向量空间的几何直觉
- 番外 10——Embedding 演化实战:TF-IDF / word2vec / BERT / LLM token 表
- 番外 11——从 One-Hot 到 LLM:自然语言处理三十年的完整演化
- 番外 12——Embedding 模型选型:OpenAI / BGE / M3E / Voyage 怎么挑
- 番外 13——文本切分策略:chunk_size 和 overlap 怎么定
- 番外 14——向量数据库 Qdrant 实战 + 混合检索 + Reranker
- 番外 15——RAG 评估方法:怎么衡量你的系统真的好
下一篇
第 06 篇是真正的实战——把这一篇里的所有零件拼成一个能跑的 RAG 知识库:你给它一堆 PDF 或 markdown,它能回答关于这些文档的问题,并且每个回答都标出引用了哪些段落。这是目前应用最广泛、商业价值最直接的 LLM 应用形态。
参考资料
- Sentence Transformers 官方文档 — 本地 embedding 库的事实标准
- BGE 模型仓库 (BAAI) — 中文最好的开源 embedding 系列
- HuggingFace 国内镜像 — 模型下载慢时的救命稻草
- OpenAI Embedding 指南
- MTEB 排行榜 — embedding 模型在多任务上的权威评测
- Chroma 文档
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:Embedding 与向量:把文字变成数字
本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/05-Embedding与向量/
本文最后一次更新为 天前,文章中的某些内容可能已过时!