理解 RAG、搜索、推荐共同的数学基础

为什么要把文字变成向量

到目前为止我们和 LLM 的交互都是"文本进文本出"。但有一类更基础、看不见的能力支撑着所有"让 AI 在你的数据里找东西"的产品——把任意文字编码成一组固定长度的数字(向量),然后用数学运算比较两段文字的"相似度"。

举几个直观的应用场景:

  • 语义搜索:用户搜"如何重置密码",应该能匹配到标题为"忘记密码怎么办"的文档,而不只是字面一致
  • 推荐系统:用户读过文章 A,找出和 A "相似"的 B、C 推给他
  • 去重 / 聚类:把语义重复的客服工单合并到一起
  • RAG:让 LLM 在回答之前先从你的私有文档里找到相关段落

这些场景的共同核心就是 Embedding——把文本映射到一个高维向量空间,让"语义相似"这件事可以用"向量距离近"来表达。理解这一篇,你就理解了 RAG、搜索、推荐三大类应用的共同基础。

几何直觉:向量空间里的语义

想象一个三维空间(实际是几百到几千维,但三维便于脑补)。每段文字被映射成空间里的一个点:

  • "猫"和"狗"的点会很近,因为它们都属于"宠物 / 哺乳动物"
  • "猫"和"轮胎"的点会很远,因为它们语义不相干
  • 神奇的是,向量之间的算术运算也有意义:vec("国王") - vec("男") + vec("女") ≈ vec("女王"),这是早期 word2vec 时代就观察到的现象

衡量两段文字的相似度,最常用的是 余弦相似度(cosine similarity)——把两个向量看作从原点出发的箭头,比较它们的夹角。完全同向是 1,垂直是 0,反向是 -1。文本 embedding 中实际值通常在 0~1 之间。

import numpy as np

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 模型在输出时已经做了归一化(向量长度为 1),这种情况下余弦相似度就等价于点积,可以省掉除法:

# 归一化向量的余弦相似度 = 点积
similarity = float(np.dot(a, b))

调用 Embedding API

主流厂商都提供 embedding API,协议同样兼容 OpenAI。我们继续用 OpenAI SDK 指向不同后端。

注意 DeepSeek 目前不提供 embedding 接口,需要用其他服务。常用国内方案是 智谱 AIembedding-3硅基流动 转发的开源模型,国外用 OpenAItext-embedding-3-small

# embedding_api.py
import os
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

# 这里以智谱为例(注册后拿 key)
client = OpenAI(
    api_key=os.getenv("ZHIPU_API_KEY"),
    base_url="https://open.bigmodel.cn/api/paas/v4/",
)


def embed(text: str) -> list[float]:
    resp = client.embeddings.create(
        model="embedding-3",
        input=text,
    )
    return resp.data[0].embedding


def embed_batch(texts: list[str]) -> list[list[float]]:
    resp = client.embeddings.create(
        model="embedding-3",
        input=texts,
    )
    return [item.embedding for item in resp.data]


if __name__ == "__main__":
    vec = embed("你好世界")
    print(f"维度:{len(vec)}")
    print(f"前 5 维:{vec[:5]}")

返回的向量维度由模型决定:智谱 embedding-3 是 2048 维,OpenAI text-embedding-3-small 是 1536 维(且支持手动降到更小),开源的 BGE-large 是 1024 维。维度高一般效果好,但向量库存储和检索成本也线性上升。

批量调用的提醒:API 通常允许一次传一批文本(list of string),共用一次 HTTP 开销,吞吐量比逐条调高得多。处理大量文档时一定要走批量。

本地 Embedding:sentence-transformers

API 调用简单但有两个问题:每次调用都有费用、网络延迟(一两百毫秒),以及涉及私有数据时的合规顾虑。这些场景下用本地模型更合适。

sentence-transformers 是最成熟的本地 embedding 库,第一次运行时会自动下载模型权重到本地(通常几百 MB),之后纯本地推理:

pip install sentence-transformers
# embedding_local.py
from sentence_transformers import SentenceTransformer

# 第一次运行会下载模型,之后从缓存加载
# bge-m3 是当前中文 + 多语言场景综合最强的开源 embedding 模型
model = SentenceTransformer("BAAI/bge-m3")

texts = [
    "今天天气很好",
    "外面阳光明媚",
    "Python 是一门编程语言",
]

# 一次调用编码全部文本,返回 (n, dim) 的 numpy 数组
vectors = model.encode(texts, normalize_embeddings=True)
print(vectors.shape)  # (3, 1024)

几个关键点:

  • normalize_embeddings=True 让输出已经归一化,后续比较相似度直接用点积即可
  • 第一次运行时下载几百兆模型权重到 ~/.cache/huggingface/,之后离线可用
  • CPU 推理对短文本足够(几十毫秒一条),长文本或批量大建议有 GPU 或用 Apple Silicon 的 MPS
  • 中文场景目前推荐 BAAI/bge-m3BAAI/bge-large-zh-v1.5,多语言场景同样首选 bge-m3

第一个语义搜索 demo

把上面的全部内容合并,做一个最小可用的语义搜索——给一堆候选文本,输入查询返回最相关的几条:

# semantic_search.py
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) -> list[tuple[str, float]]:
    q_vec = model.encode([query], normalize_embeddings=True)[0]
    # 归一化向量的点积 = 余弦相似度
    sims = doc_vecs @ q_vec
    idx = np.argsort(-sims)[:top_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] 煮意大利面要在水里加盐
  ...

注意"Python 异步"匹配到了 asyncio 那条,即使候选文本里压根没有"异步"这个词,也没有"怎么写"。这就是 embedding 相对传统全文检索(基于词频的 BM25)的核心优势——它理解语义,而不是字面。

模型选型与维度的权衡

embedding 模型选型主要看三个维度:

1. 语种支持——纯中文用 bge-large-zh-v1.5,多语言或中英混合用 bge-m3。OpenAI 的 text-embedding-3 系列对中文也不错但要付费。

2. 维度——常见的有 384、768、1024、1536、2048。维度大的通常质量好但向量存储和搜索都更耗资源。bge-m3 是 1024 维,对绝大多数场景都够用。OpenAI text-embedding-3-large 支持手动降维,可以在质量和成本之间平衡。

3. 上下文长度——每段文本能编码的最大 token 数。bge-m3 支持 8192 tokens,对长文档友好。短上下文模型(512 tokens)需要在使用前先做切分,否则超长部分被截断,效果断崖式下降。

实战经验:先用 bge-m3 起步,验证业务效果之后如果吞吐不够,再考虑换更小的模型或加 GPU。不要在没有 baseline 之前就过早优化。

向量数据库:为什么要它

上面的 demo 把所有向量保存在内存的 numpy 数组里,文档少(<10 万条)这样足够。但当你的文档量到百万、千万级,问题来了:

  • 全量计算相似度太慢——每次查询要算 N 次点积,N 千万就要几秒
  • 内存放不下——千万 × 1024 维 × 4 字节 ≈ 40GB
  • 增删改查不方便——你想加几条新文档要重建整个数组吗?

向量数据库就是为这个场景而生的。它做两件事:用近似最近邻(ANN)算法把搜索复杂度从 O(N) 降到 O(log N) 级别;提供持久化和增量更新的工程接口。常见选项:

  • Chroma——纯 Python,本地嵌入式,最适合个人项目和原型。第 06 篇 RAG 实战会用它
  • Qdrant——Rust 实现的高性能开源数据库,可以本地或集群部署
  • Milvus——大规模生产场景的事实标准,但部署相对重
  • PostgreSQL + pgvector——已经在用 PG,加这个扩展就能存向量,运维最简单
  • Pinecone——纯托管 SaaS,零运维但要付费

下一篇 RAG 实战我们用 Chroma,它的体验和 SQLite 一样轻——一个 Python 库 + 一个文件,没有任何服务要起。

本篇要点

  • Embedding 把任意文本编码成固定长度向量,让"语义相似"可以用"向量距离近"度量
  • 余弦相似度是最常用的相似度指标,归一化向量的点积就是余弦相似度
  • API 路线和本地路线各有优势:API 省心、本地省钱并保护隐私
  • 中文场景目前推荐 BAAI/bge-m3,多语言通用,1024 维,8K 上下文
  • 文档量大要上向量数据库,原型阶段用 Chroma 即可
  • 永远批量调用,不要逐条 embed,吞吐量差几十倍

下一篇

第 06 篇是真正的实战——把这些零件拼成一个能跑的 RAG 知识库:你给它一堆 PDF 或 markdown 文档,它能回答关于这些文档的问题,并且每个回答都标出引用了哪些文档片段。这是目前应用最广泛、商业价值最直接的 LLM 应用形态之一。

参考资料

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

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

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

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

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