精華筆記

· @aihub.tw

RAG 系統實作

Embedding:把語意變成向量

Embedding:把語意變成向量

第 1 課說過,RAG 的流程是「把文件切塊 → 存進向量庫 → 使用者問問題時撈最相關的塊 → 丟給 LLM 生答案」。這條流程的樞紐在「撈最相關的塊」那一步:你怎麼知道哪塊最相關?

用關鍵字比對的話,使用者問「加班費怎麼算」,文件寫的是「延長工時工資標準」,就完全抓不到。用 BM25 這種傳統檢索也類似——它算的是詞頻,遇到換句話說就失靈。RAG 真正需要解決的是語意相關,不是字面相關。這件事的底層工具就是 embedding。

這堂課適合誰 適合:想從頭實作 RAG 系統、或想搞懂向量搜尋底層原理的後端/全端工程師(本課程屬工程師專區)。需要基礎:Python 基礎、會用 pip 裝套件、會在終端機設環境變數。前置課:第 1 課(RAG 是什麼:長 context 不是萬靈丹)。

這堂學什麼

  • Embedding 的白話解釋:語意座標是什麼、為什麼有效
  • Cosine 相似度的直覺、公式、Python 純手工推導與套件寫法
  • 2026 年主流模型評比:OpenAI text-embedding-3 系列、Voyage-4 系列的定價、維度與繁中表現
  • 手把手實戰:用 Python 算一批句子的相似度矩陣,用 seaborn 熱力圖讓結果一目了然
  • 維度數量對成本與精準度的影響,選型決策樹

觀念一:Embedding 是語意座標

你有沒有在地圖上查過「我附近的拉麵店」?地圖能找到是因為你的位置被記成座標,拉麵店也各有自己的座標,系統算的是座標之間的距離。

Embedding 做的事完全一樣,只是把「地理位置」換成「語意」。一個 embedding 模型把一段文字輸入,輸出一個向量——一組 1,000 多個浮點數,例如 [0.032, -0.187, 0.041, ...]。這組數字就是這段文字在「語意空間」裡的座標。模型在訓練時學會:語意相近的句子會落在空間裡相近的位置,語意無關的句子則相距甚遠。

語意空間座標示意:HR、技術、美食三群句子各自聚成一群,群內距離近、群間距離遠

實際上當然不是 2D,而是 1,536 維或更高——人類看不懂,但向量運算能精確捕捉語意的細微差異。重要觀念是:你不需要讀懂那些數字,只需要知道「近」就代表「語意像」

觀念二:Cosine 相似度

有了向量座標,下一步是「怎麼量兩個向量有多像」。直覺上可以用歐式距離(兩點直線距離),但在高維空間裡歐式距離受向量長度影響很大,容易失準。RAG 系統一律用 cosine 相似度

Cosine 相似度直覺圖:夾角越小 cosine 越接近 1,越大越接近 0 或 -1,並附公式

公式長這樣:

$$ \text{cosine}(A, B) = \frac{A \cdot B}{|A| \cdot |B|} $$

翻成白話:分子是兩個向量的點積(對應元素相乘後加總),分母是各自的長度相乘。結果在 -1 到 1 之間,實務上 embedding 向量通常已經 L2 normalize 過,這時點積就等於 cosine 相似度——所以你會看到很多向量庫直接用點積而不另外計算 cosine,結果是一樣的。

用 Python 從頭推導只要三行:

import numpy as np

def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

為什麼不用歐氏距離? 假設文件 A 和文件 B 語意一樣,但 B 只是 A 的摘要(更短),歐氏距離會因為向量長度不同而顯示「不相似」,但 cosine 只看方向,完全不受影響。這就是 RAG 場景首選 cosine 的原因。

觀念三:2026 主流模型怎麼選

2026 年市面上 embedding API 主要三個陣營:OpenAI、Voyage AI(已被 MongoDB 收購)、Google。以下只看最常進 RAG 系統的選項。

2026 主流 embedding 模型比較:OpenAI 與 Voyage 五款模型的維度、定價與最佳用途卡片

模型 維度 定價($/1M tokens) 特點
OpenAI text-embedding-3-small 1,536 $0.02 整合最廣、入門首選
OpenAI text-embedding-3-large 3,072 $0.13 英文精準度最高
Voyage-4-lite 512 起 $0.02 前 200M token 免費
Voyage-4 1,024 起 $0.06 多語言、支援 MoE 混搭
Voyage-4-large 2,048 起 $0.12 Voyage 旗艦、前 200M 免費

定價來源:OpenAI 官方 API 文件(2026-07)、Voyage AI 官方定價頁(2026-07)。API 價格會動,上線前務必再查一次官方。

繁中怎麼選? 這是許多台灣開發者的大哉問。實測和社群評測的結論如下:

  • text-embedding-3-small 的中文能力在多語言 MTEB 子集上略遜於英文,但對大多數繁中企業文件場景已夠用,而且台灣開發者生態工具配套最齊。
  • Voyage-4 系列是目前 API 提供商裡多語言最強的之一,voyage-multilingual-2 和 voyage-4 在日、韓、中文的 MTEB 測試都優於 OpenAI-large。如果你的文件以中文為主、且需要極高精準度,Voyage-4 值得試。
  • 如果你是第一次做 RAG:先用 text-embedding-3-small 跑通整個流程,再根據實際 recall 品質決定要不要換模型。不要在還沒跑起來的時候就在模型選型上卡關。

兩個服務都提供 Matryoshka Representation Learning(MRL)支援:你可以要求輸出更低維度的向量(例如 256 維而不是 1,536 維),精準度只掉一點點,但儲存成本和查詢速度都大幅改善。

觀念四:維度與成本

「維度」就是向量裡有幾個數字。3,072 維的向量比 1,536 維的向量精準,但也多用兩倍儲存空間、查詢時計算量翻倍、帳單也翻倍。

維度與成本精準度取捨圖:精準度邊際遞減、成本線性上升,甜蜜點落在 1024–1536 維

實務決策原則:

  • 10 萬個 chunk 以下、中文繁體文件:從 text-embedding-3-small(1,536 維)或 voyage-4-lite(1,024 維)開始,成本幾乎可以忽略。
  • 百萬 chunk 以上:換算儲存成本,1M 個 1,536 維 float32 向量約 6 GB,生產環境才需要開始認真考慮降維。
  • Matryoshka truncation:兩家 API 都支援,指定 dimensions=512dimensions=256,回傳的是截斷版,比重新 embed 低維版更省事。

手把手實戰

目標:取一批中英混合句子的 embedding,算出兩兩 cosine 相似度,用熱力圖讓你直覺看到哪些句子「語意在一起」。

環境需求

pip install openai numpy matplotlib seaborn python-dotenv

在專案根目錄建 .env:

OPENAI_API_KEY=sk-...

Step 1:取得一批句子的 embedding

# embed_demo.py
import os
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

MODEL = "text-embedding-3-small"

def get_embeddings(texts: list[str]) -> np.ndarray:
    """批次呼叫 API,回傳 shape (n, dim) 的 numpy 陣列。"""
    response = client.embeddings.create(input=texts, model=MODEL)
    # response.data 是 list,順序與 input 對應
    vectors = [item.embedding for item in response.data]
    return np.array(vectors, dtype=np.float32)

一次呼叫可以傳多個字串進 input(官方限制每次最多 2,048 個,單字串最多 8,191 token),API 回傳的順序和你傳入的順序保證一致。這樣做比逐條呼叫快很多,也省 API overhead。

Step 2:自己算 cosine 相似度矩陣

def cosine_matrix(embeddings: np.ndarray) -> np.ndarray:
    """
    L2 normalize 後做矩陣乘法,得到 (n, n) 的 cosine 相似度矩陣。
    對角線是 1.0(自己和自己最像),其餘 0~1。
    """
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)  # shape (n, 1)
    normalized = embeddings / norms                              # L2 normalize
    return normalized @ normalized.T                            # (n, n)

這段程式碼值得逐行看清楚:

  1. np.linalg.norm(embeddings, axis=1, keepdims=True):對每一列(每個向量)算長度,保持維度方便廣播。
  2. embeddings / norms:每個向量除以自己的長度,讓所有向量長度變成 1(L2 normalize)。
  3. normalized @ normalized.T:矩陣乘法。normalize 後的點積 = cosine 相似度,這樣一次算出所有對。

Step 3:準備測試句子,跑完整流程

# 設計三個語意群:HR、技術、美食
sentences = [
    # HR 群
    "如何申請年假?",
    "公司的請假規定是什麼?",
    "員工特休計算方式",
    # 技術群
    "Python 怎麼解析 JSON?",
    "用 json.loads() 把字串轉成字典",
    "requests 套件發送 HTTP POST",
    # 美食群
    "台北哪裡有好吃的拉麵?",
    "我今天想吃牛肉麵",
]

print(f"取得 {len(sentences)} 個句子的 embedding...")
embeddings = get_embeddings(sentences)
print(f"向量維度:{embeddings.shape[1]}")  # 應輸出 1536

sim = cosine_matrix(embeddings)
print("Cosine 相似度矩陣(前 3x3 預覽):")
print(np.round(sim[:3, :3], 3))

執行結果你會看到前 3×3 的 HR 群相似度普遍在 0.85 以上,而 HR 和美食的相似度通常落在 0.5~0.6 左右。這就是模型「懂語意」的直接證據。

Step 4:用熱力圖視覺化

def plot_similarity_heatmap(sim: np.ndarray, labels: list[str], savepath: str = "heatmap.png"):
    fig, ax = plt.subplots(figsize=(10, 8))

    # 截短標籤避免版面爆掉
    short_labels = [s[:10] + "…" if len(s) > 10 else s for s in labels]

    sns.heatmap(
        sim,
        annot=True,        # 在格子裡印數值
        fmt=".2f",
        cmap="YlOrRd",     # 黃→橘→紅,越紅越像
        xticklabels=short_labels,
        yticklabels=short_labels,
        ax=ax,
        vmin=0.4,          # 調低下限讓差異更明顯
        vmax=1.0,
        square=True,
        linewidths=0.5,
    )
    ax.set_title("Cosine 相似度矩陣", fontsize=14, pad=12)
    plt.xticks(rotation=30, ha="right", fontsize=9)
    plt.yticks(rotation=0, fontsize=9)
    plt.tight_layout()
    plt.savefig(savepath, dpi=150)
    print(f"圖片已存到 {savepath}")

plot_similarity_heatmap(sim, sentences)

相似度熱力圖示意結果:8x8 格中三個語意群沿對角線形成三個紅色方塊,跨群格子偏淺黃

執行完整腳本:

python embed_demo.py

你會在工作目錄得到 heatmap.png。圖上的三個紅色方塊正好對應三個語意群——HR、技術、美食——這個畫面讓你直覺理解 embedding 在做什麼,比讀任何解釋都直接。

Step 5(選做):換用 Voyage-4

如果你的文件主要是繁中,可以試試 Voyage-4:

pip install voyageai
import voyageai
import os

vo = voyageai.Client(api_key=os.environ["VOYAGE_API_KEY"])

def get_embeddings_voyage(texts: list[str], model: str = "voyage-4") -> np.ndarray:
    """
    input_type 告訴模型這批文字是「文件」還是「查詢」。
    文件端用 "document",查詢端用 "query"——
    非對稱 embedding 的兩端要分開 embed,這是常見坑。
    """
    result = vo.embed(texts, model=model, input_type="document")
    return np.array(result.embeddings, dtype=np.float32)

前 200M token 免費,原型開發期間幾乎不花錢。

常見坑

坑 1:RateLimitError: Error code: 429 — you exceeded your current quota

剛申請的 OpenAI 帳號在還沒加付款方式之前,呼叫 embedding API 就會看到這個錯誤。解決方式:OpenAI 後台 → Billing → Add payment method,加完後等幾分鐘再試。另一個觸發原因是單次批次句子數太多、速度超過 tier 的 TPM 限制:把批次切小(每次 100 句以下),或在迴圈裡加 time.sleep(1)

坑 2:混用不同模型的向量存進同一個向量庫

# 搜尋結果全錯,但程式沒有報錯
results = collection.query(query_embeddings=[query_vec], n_results=5)
# 傳回完全不相關的文件

症狀是查詢結果莫名其妙——明明問 HR 問題,撈回來都是技術文件。根本原因:你用 text-embedding-3-small embed 了文件,後來換成 voyage-4 embed 查詢,兩個模型的向量空間根本不同,座標毫無意義。解法:embed 文件和查詢必須用同一個模型。如果你決定換模型,就要把向量庫裡所有文件全部重新 embed 一遍。在程式裡把模型名稱設成常數(例如 EMBED_MODEL = "text-embedding-3-small"),整個系統只有一個地方宣告。

坑 3:對稱 vs 非對稱 embedding 的 input_type 填錯

Voyage AI 的 API 有 input_type 參數,可以填 "document""query"。如果你兩邊都填 "document",搜尋精準度會比應有的低一截,但不會報錯——這種無聲的效能退化最難發現。正確用法:embed 知識庫內容時填 "document",embed 使用者問題時填 "query"。OpenAI 的 API 不分這兩種,但設計上也鼓勵非對稱使用。

坑 4:中文字數估算 token 失準,帳單爆炸

英文大概 1 token = 4 個字元,但中文通常 1 個字就是 1 token,有時一個字還拆成 2 個 token(取決於 tokenizer)。你估算成本時如果用英文的規則,會嚴重低估。正確做法:用 OpenAI 的 tiktoken 套件實際數你的語料:

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")  # text-embedding-3 系列用這個 tokenizer
sample = "如何申請年假?員工特休的規定是什麼?"
token_count = len(enc.encode(sample))
print(f"字元數:{len(sample)},token 數:{token_count}")
# 輸出:字元數:20,token 數:21  ← 比字元多

算出平均每個 chunk 多少 token,乘上總 chunk 數和 API 單價,才是真實成本。

坑 5:向量沒有 L2 normalize 就直接存,之後查詢結果不穩定

很多向量庫(Chroma、pgvector)預設用 cosine 相似度時會在查詢時自動 normalize,但如果你直接存入未 normalize 的原始向量,然後用 IP(inner product)模式查詢,結果就是向量長度影響排序——長文件的向量長度通常比短文件大,排序會偏向長文件。最安全的做法:存之前先 normalize:

def l2_normalize(v: np.ndarray) -> np.ndarray:
    return v / np.linalg.norm(v, axis=-1, keepdims=True)

embeddings_normalized = l2_normalize(embeddings)
# 存這個進向量庫,查詢時也 normalize query vector

作業

  1. 跑完本課的 embed_demo.py,產出一張熱力圖。換幾個句子玩:加入近義詞對(例如「休假」和「離職」),觀察相似度數值。
  2. 在上面的測試集裡加一個「語意模糊」的句子(例如「公司最近怎麼了?」),看它和三個群落的相似度落在哪個範圍,思考為什麼。
  3. 選做:把 OpenAI 換成 Voyage-4,跑同一批句子,比較兩組相似度矩陣有沒有差異。
  4. 進階:用 dimensions=256 參數取得縮短的向量(text-embedding-3-small 支援),和原始 1,536 維的結果比較,看精準度掉了多少。

下一課預告

現在你會把文字變成向量了。下一個問題是:幾千、幾萬個向量要存在哪裡、怎麼快速找到最相近的那幾個?

第 3 課《向量資料庫:選型與實作》會帶你看清楚 pgvector(PostgreSQL 外掛)、Chroma(本地輕量)、Pinecone/Qdrant(雲端托管)的差異——它們本質都是「超快的 k-NN 搜尋引擎」,但選型錯了你可能在幾萬 chunk 的規模就遇到速度瓶頸。我會帶你在本機把 Chroma 跑起來、塞一批向量進去、查詢,用真實程式碼感受一次完整的 embed → 存 → 查流程。

#RAG#Embedding#向量#OpenAI#Voyage AI#Python

← 回所有文章