4. 向量与嵌入:文字怎么变成数字
这是什么:嵌入(Embedding) = 把任意一段文字变成一串数字,让「意思相近」变成「数字相近」。
为什么用:上一章切好了块,可怎么判断「报销」和「费用」说的是一件事?两个字面一个字不重合,检索却必须能找到。嵌入就是那把比较语义的尺子。
如何用:本章手算一次余弦相似度,弄懂维度与归一化,最后记住「换模型必须重建索引」这条铁律。
4.1 嵌入是什么
一段文字 → 一个固定长度的数字列表(向量):
"报销" → [0.021, -0.113, 0.045, ..., 0.087] # 一共 1536 个数
模型在训练时见过海量「意思相近的句子」,于是学会把相近的意思放在向量空间里相近的位置。方向相近 = 语义相近。
| 概念 | 说明 |
|---|---|
| 维度 | 向量里有多少个数。text-embedding-3-small 是 1536 维 |
| 向量空间 | 高维空间;每维本身没有含义,是整体表达语义 |
| 相似度 | 两个向量有多接近,用余弦相似度衡量 |
4.2 手算余弦相似度
余弦相似度衡量两个向量夹角的大小:完全同向 = 1,无关 = 0,方向相反 = −1。
import numpy as np
def cosine(a, b):
"""余弦相似度:1 = 同向,0 = 无关,-1 = 反向"""
a, b = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
拿三句话实测一下:
from openai import OpenAI
import os
client = OpenAI(api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1"))
sentences = [
"怎么申请报销?",
"差旅住宿的报销标准是多少?",
"公司年会在哪里举办?",
]
resp = client.embeddings.create(model="text-embedding-3-small", input=sentences)
vecs = [d.embedding for d in sorted(resp.data, key=lambda d: d.index)]
print("维度:", len(vecs[0])) # 1536
for i in range(len(sentences)):
for j in range(i + 1, len(sentences)):
print(f"{cosine(vecs[i], vecs[j]):.3f} {sentences[i]} × {sentences[j]}")
你会看到(示例):
维度: 1536
0.652 怎么申请报销? × 差旅住宿的报销标准是多少?
0.318 怎么申请报销? × 公司年会在哪里举办?
0.297 差旅住宿的报销标准是多少? × 公司年会在哪里举办?
前两句字面几乎不重合,向量却明显更近——这就是嵌入在做的事。
| 相似度 | 一般可理解为 |
|---|---|
| > 0.7 | 高度相关 |
| 0.4 ~ 0.7 | 相关 |
| < 0.3 | 基本无关 |
这个阈值因模型而异,别照抄。看到陌生模型时,先拿几组「该匹配 / 不该匹配」的样本量一量,定出自己的阈值。
4.3 维度:越多越好吗
不是。维度越高,存储和计算越贵,而收益递减:
| 模型 | 维度 | 特点 |
|---|---|---|
text-embedding-3-small |
1536 | 快、便宜,日常够用 |
text-embedding-3-large |
3072 | 略准,贵一些 |
OpenAI 的 v3 系列支持降维(Matryoshka 技术):指定更小的维度,质量略降但速度和体积明显变好。
resp = client.embeddings.create(model="text-embedding-3-small",
input=["测试"], dimensions=512)
print(len(resp.data[0].embedding)) # 512
降维必须在建索引时就定好,并且查询时用同样的维度——中途改会算错。
4.4 归一化:一个立刻能用的公式
把向量除以自己的长度,就得到单位向量:
def l2_normalize(v):
v = np.asarray(v, dtype=float)
return v / np.linalg.norm(v)
单位向量之间有个漂亮的恒等式:
‖a − b‖² = 2 − 2·cos(a, b) ⟹ cos(a, b) = 1 − ‖a − b‖² / 2
知道欧氏距离,就能算出余弦相似度。 第 5 章讲向量库时会直接用上这条。
4.5 铁律:换了嵌入模型,索引必须重建
不同模型的向量空间互不兼容——拿模型 A 建的索引,配模型 B 查询,算出来的相似度全是噪声。
| 你做了什么 | 必须做什么 |
|---|---|
| 换了嵌入模型 | 删掉索引,全量重建 |
只是调了 chunk_size |
重建(块变了,向量也得重算) |
| 只是加了元数据 | 不用重建(可以单独 update) |
# 重建的笨办法但最可靠:删库重来
import shutil
shutil.rmtree("./chroma_data", ignore_errors=True)
记住这句话:向量和模型是一套的。 生产环境里把「嵌入模型名 + 版本」一起记进索引的元数据里,一眼就能查清。
4.6 本地嵌入:不要 API Key 也能玩
有些服务不支持 /v1/embeddings,那就用本地模型:
pip install sentence-transformers
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") # 支持中文
vecs = model.encode(sentences, normalize_embeddings=True)
print(vecs.shape) # (3, 384)
本地模型的好处是免费、离线、数据不出本机;代价是首次要下载几百 MB 模型,且准确率通常略低于顶级的云端嵌入。
版本说明:
sentence-transformers6.1.0 依赖 PyTorch,而 PyTorch 目前只发布到 Python 3.14 的 wheel——需要本地嵌入时,请用 Python 3.12 ~ 3.14;用 3.15 就走 API 嵌入那条路。
4.7 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
| 相似度全在 0.5 上下,毫无区分度 | 查询和索引用了不同模型 | 重建索引(第 4.5 节) |
| 向量长度对不上 | 建索引用 1536 维、查询用 512 维 | 两边保持一致 |
| 中文检索效果远差于英文 | 用了纯英文训练的嵌入模型 | 换多语言模型,如上面的 MiniLM |
dimensions 参数报错 |
老模型不支持降维 | 去掉该参数 |
4.8 动手改
- 把第 4.2 节的三句话换成你自己的业务问句,量一组「该匹配 / 不该匹配」的相似度,定出你自己的阈值
- 用
dimensions=512再跑一遍,比较维度、相似度数值的变化 - 用
l2_normalize处理向量后,验证一下1 - d²/2算出的余弦和手写cosine()是否一致
4.9 小结
- 嵌入把文字变成高维向量,方向相近 = 语义相近
- 相似度用余弦衡量;不同模型的阈值不能照抄
- 维度越高越贵,v3 系列支持降维,但建索引和查询必须一致
- 单位向量满足
cos = 1 − d²/2,能从距离反推相似度 - 换嵌入模型必须重建索引——这是 RAG 最贵的坑之一
下一章:向量数据库,把向量存下来并高效检索 🐸