理解 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 就能切换不同厂商。

国内最方便的是智谱 AIembedding-3(注册送几百万 token),国外用 OpenAItext-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")

内存不够 / OOMbge-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 应用形态。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:Embedding 与向量:把文字变成数字

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/05-Embedding与向量/

本文最后一次更新为 天前,文章中的某些内容可能已过时!