模組 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 點。內容經審核後公開。

正在載入討論…