向量嵌入:把意思变成数字
用一个开源中文嵌入模型在本地把句子变成向量,用余弦相似度比较它们的意思,看看它擅长什么、分不清什么。这是后面做语义搜索和 RAG 的基础。
- 约 35 分钟
- 难度:入门
- 实测:2026-09-14 bge-small-zh-v1.5,sentence-transformers
假设你在做 httpx 的答疑助手。用户问"httpx 怎么设置请求超时?",文档里写的却是 "timeout configuration",用户问"怎么让请求等久一点再报错",文档里一个"超时"都没有。按关键词搜,这几种说法都搜不到对应的段落。
你需要一种能比较"意思"而不是比较"字面"的办法。向量嵌入(embedding)就是干这个的。
嵌入是什么
嵌入模型是另一类模型。它不生成文字,只做一件事:输入一段文字,输出一串固定长度的数字,也就是一个向量。训练它的目标是:意思相近的文字,得到的向量也相近。
"相近"用一个数来衡量,最常用的是余弦相似度:两个向量方向越一致,值越接近 1;毫不相关,接近 0。可以用两维的例子直观感受一下:
import numpy as np
def cosine(a, b):
return a @ b / (np.linalg.norm(a) * np.linalg.norm(b))
print(cosine(np.array([1, 2]), np.array([2, 4]))) # 方向完全相同,长度不同
print(cosine(np.array([1, 2]), np.array([2, -1]))) # 互相垂直
0.9999999999999998
0.0
[1, 2] 和 [2, 4] 长度不一样,但方向完全相同,所以相似度是 1(打印出来的 0.9999999999999998 是浮点数计算的微小误差,可以当成 1)。余弦相似度只看方向,不看长度,这正是我们想要的:一句话写得长一点短一点,不应该影响它的意思。
真实的嵌入向量有几百上千维,没法画出来,但计算方法完全一样。
DeepSeek 没有嵌入接口
截至 2026 年 9 月,DeepSeek 的 API 只提供对话模型,不提供嵌入模型。我用 OpenAI SDK 调它的嵌入接口,直接返回 404。
有两个办法:
- 用别家的嵌入 API。阿里云百炼、智谱、OpenAI 等都提供嵌入接口,调用方式和对话接口一样走 OpenAI SDK(
client.embeddings.create(...)),具体的模型名以各家文档为准。 - 在本地运行开源嵌入模型。嵌入模型比对话模型小得多,普通电脑的 CPU 就能跑。
这门课用第二种。选的是北京智源研究院(BAAI)开源的 bge-small-zh-v1.5:专门针对中文训练,只有 2400 万个参数,下载下来 92MB,完全免费,不用联网就能用,也不用担心资料泄露给第三方。
动手:比较几句话的意思
装一个包:
uv add sentence-transformers
sentence-transformers 是专门运行嵌入模型的库。第一次运行会从 Hugging Face 下载模型,国内下载慢的话,先设置镜像:
export HF_ENDPOINT=https://hf-mirror.com
然后运行这个脚本:
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
query = "httpx 怎么设置请求超时?"
sentences = [
"How do I set a timeout for requests in httpx?",
"httpx 的 timeout 参数默认是 5 秒。",
"给 httpx 客户端配置代理服务器",
"requests 库如何设置超时时间",
"今天中午吃了一碗牛肉面",
]
# normalize_embeddings=True 把每个向量的长度缩放成 1,这样点积就等于余弦相似度
vectors = model.encode([query] + sentences, normalize_embeddings=True)
print(f"每句话变成了一个 {vectors.shape[1]} 维的向量")
print(f"第一句的前 5 个数:{np.round(vectors[0][:5], 4)}")
print()
q, rest = vectors[0], vectors[1:]
scores = rest @ q
print(f"和「{query}」的相似度:")
for score, text in sorted(zip(scores, sentences), reverse=True):
print(f" {score:.3f} {text}")
运行结果(这段代码不调用任何 API,你应该得到一样的数字):
每句话变成了一个 512 维的向量
第一句的前 5 个数:[-0.0232 0.0017 0.0812 0.0058 0.0116]
和「httpx 怎么设置请求超时?」的相似度:
0.777 requests 库如何设置超时时间
0.673 httpx 的 timeout 参数默认是 5 秒。
0.669 How do I set a timeout for requests in httpx?
0.659 给 httpx 客户端配置代理服务器
0.202 今天中午吃了一碗牛肉面
每句话被变成了 512 个数。单独看这些数没有意义,有意义的是向量之间的相似度。
结果里有什么
意思相关的句子分数高,不相关的分数低。四句和 HTTP 请求有关的句子都在 0.65 以上,"今天中午吃了一碗牛肉面"只有 0.2。这个区分很清楚。
跨语言也能匹配。英文的 "How do I set a timeout for requests in httpx?" 和中文问题一个字都不重合,得分 0.669。这就是嵌入比关键词搜索强的地方。
但它分不清是哪个库。得分最高的居然是"requests 库如何设置超时时间",比两句讲 httpx 的都高。在嵌入模型看来,"XX 库怎么设置超时"这个句式和意思最重要,至于是 requests 还是 httpx,只是一个次要的差别。可对答疑助手来说,这恰恰是最关键的差别:拿 requests 的文档去回答 httpx 的问题,就答错了。
同样,"给 httpx 客户端配置代理服务器"讲的是代理,不是超时,也拿到了 0.659,和讲超时的句子只差一点点。
这说明嵌入擅长捕捉"大意",但对专有名词、函数名、版本号这类精确的东西不敏感。04 模块第 4 课会把嵌入和关键词搜索结合起来,专门解决这个问题。
分数没有绝对的意义。0.67 算高还是低?没有标准答案。不同的嵌入模型分数范围不一样,同一个模型在不同领域也不一样。有用的是比较:同一个问题下,哪几段排在前面。别在代码里写死"相似度大于 0.7 才算相关"这种规则,除非你用自己的数据验证过这个阈值。
嵌入能用来做什么
- 语义搜索:把所有文档段落的向量提前算好存起来,用户提问时算出问题的向量,找最相似的几段。这是 RAG 的核心,04 模块第 3 课会从零写一个。
- 去重:两段文字的向量几乎一样,多半是重复内容。
- 分类和聚类:把用户反馈、issue 按意思自动分组。
- 推荐:找和用户看过的内容意思相近的其他内容。
使用时的几个坑
问题和文档必须用同一个模型。不同嵌入模型的向量完全不兼容,就像两种不同的坐标系。换了嵌入模型,所有文档都要重新算一遍。
有长度上限。bge-small-zh-v1.5 最多只能读 512 个词元,超过的部分会被直接丢掉,而且不会报错。所以长文档要先切成小段,再分别计算向量,这是 04 模块第 2 课的内容。
有些模型要给问题加前缀。一些嵌入模型建议在查询前加一句固定的说明,比如"为这个句子生成表示以用于检索相关文章:",能提高检索效果。要不要加、加什么,看模型的说明文档。本课为了简单没有加。
练习
- 往
sentences里加几句你自己想的话:一句换了说法但意思完全相同的("httpx 请求等太久怎么办"),一句关键词相同但意思不同的("超时费用怎么计算")。它们会排在哪? - 把查询换成英文 "How to configure a proxy in httpx?",看看排名怎么变。
- 如果你有百炼、智谱或者 OpenAI 的密钥,用
client.embeddings.create(model=..., input=[...])调用它们的嵌入接口,算出同样几句话的相似度,和本地模型的结果比较。注意分数范围可能完全不同,比较排名就好。
自测
1. 为什么余弦相似度只看向量的方向,不看长度?
我们关心的是两段文字的意思是否相近,而不是文字的长短或者其他无关的量。余弦相似度除以了两个向量的长度,只保留方向信息。把向量提前归一化成长度 1 之后,点积就直接等于余弦相似度,计算起来更快。
2. 用户问 httpx 的问题,嵌入搜索却把 requests 库的文档排在了第一。为什么?怎么办?
嵌入模型捕捉的是整体意思,"某个库怎么设置超时"这个意思很接近,而库名只是一个次要的差别,所以两者得分都很高。解决办法是把嵌入搜索和关键词搜索结合起来(关键词搜索对"httpx"这种精确的词很敏感),或者限定只在 httpx 的文档里搜。
3. 你把一篇 5000 字的文章直接交给 bge-small-zh-v1.5 算向量,会发生什么?
它只会读前 512 个词元,后面的内容被截断丢掉,而且不报错。算出来的向量只代表文章开头的意思。所以长文档要先切成小段,每段分别算向量。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…