模块 01 · 第 5 课

向量嵌入:把意思变成数字

用一个开源中文嵌入模型在本地把句子变成向量,用余弦相似度比较它们的意思,看看它擅长什么、分不清什么。这是后面做语义搜索和 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 课的内容。

有些模型要给问题加前缀。一些嵌入模型建议在查询前加一句固定的说明,比如"为这个句子生成表示以用于检索相关文章:",能提高检索效果。要不要加、加什么,看模型的说明文档。本课为了简单没有加。

练习

  1. sentences 里加几句你自己想的话:一句换了说法但意思完全相同的("httpx 请求等太久怎么办"),一句关键词相同但意思不同的("超时费用怎么计算")。它们会排在哪?
  2. 把查询换成英文 "How to configure a proxy in httpx?",看看排名怎么变。
  3. 如果你有百炼、智谱或者 OpenAI 的密钥,用 client.embeddings.create(model=..., input=[...]) 调用它们的嵌入接口,算出同样几句话的相似度,和本地模型的结果比较。注意分数范围可能完全不同,比较排名就好。

自测

1. 为什么余弦相似度只看向量的方向,不看长度?

我们关心的是两段文字的意思是否相近,而不是文字的长短或者其他无关的量。余弦相似度除以了两个向量的长度,只保留方向信息。把向量提前归一化成长度 1 之后,点积就直接等于余弦相似度,计算起来更快。

2. 用户问 httpx 的问题,嵌入搜索却把 requests 库的文档排在了第一。为什么?怎么办?

嵌入模型捕捉的是整体意思,"某个库怎么设置超时"这个意思很接近,而库名只是一个次要的差别,所以两者得分都很高。解决办法是把嵌入搜索和关键词搜索结合起来(关键词搜索对"httpx"这种精确的词很敏感),或者限定只在 httpx 的文档里搜。

3. 你把一篇 5000 字的文章直接交给 bge-small-zh-v1.5 算向量,会发生什么?

它只会读前 512 个词元,后面的内容被截断丢掉,而且不报错。算出来的向量只代表文章开头的意思。所以长文档要先切成小段,每段分别算向量。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…