cd ../back to blog
$Guide//June 4, 2026//8 min read

嵌入向量與語意檢索:用 Brievio 打造 RAG 檢索流程

用 OpenAI 相容的 /v1/embeddings 產生向量、以餘弦相似度計分、用 pgvector 儲存查詢,並接進 RAG 檢索流程。

關鍵字搜尋比對的是字串。語意檢索比對的是 意義 —「I forgot my login」就算和標題為「Resetting your password」的文章一個字都不重疊,照樣能找到它。背後的機制是嵌入向量: 模型把每一段文字轉成一個由數千個數字組成的向量,意義相近的文字就會 在這個空間裡彼此靠近。把這些向量產生一次、儲存起來,你就能依相關性 為任何東西排序 — 也就是每一套 RAG 系統的檢索那一半。

Brievio 把 /v1/embeddings 開放成可直接替換的 OpenAI 相容端點,所以 SDK 呼叫和你早就寫慣的完全一樣 — 只有 base_url 變了。嵌入向量很便宜,你付的是誠實的權杖數, 而失敗的 4xx/5xx 呼叫一毛都不收。這篇文章走的是務實路線: 產生向量、 用餘弦相似度計分、用 pgvector 儲存並查詢,再把結果接進檢索流程 — 連同那些真的會咬人的眉角。

產生嵌入向量

一次呼叫。傳入一串字串,依相同順序拿回一串向量。永遠要批次處理 — 每個請求送上數百筆輸入,每權杖的成本和一筆一筆送完全相同,卻替你 省下數百次往返:

embed.py
# 用 OpenAI SDK 產生嵌入向量 — 同樣的呼叫,只換 base_url。
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

EMBED_MODEL = "text-embedding-3-large"   # 選定一個模型,就一路用到底

def embed(texts: list[str]) -> list[list[float]]:
    # 每次呼叫批次處理數百筆輸入 — 大幅減少往返次數,
    # 每權杖單價不變。回應會保留輸入的順序。
    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
    return [row.embedding for row in resp.data]

vecs = embed(["How do I reset my password?", "Where is my invoice?"])
print(len(vecs), "x", len(vecs[0]))     # 2 x 3072  (維度依模型而定)

# 用量誠實回報 — 嵌入向量跟其他呼叫一樣按權杖計量
print(resp.usage.prompt_tokens, "tokens billed")

向量的長度(它的維度)由模型固定 — 常見為 768、 1536 或 3072。維度越高,能捕捉的細微差異略多,但儲存與比對的成本也 越高。最重要的一條規則: 選定一個嵌入模型,永遠別混用。 來自不同模型的向量,活在不同且互不相容的空間裡 — 拿來互相比較只會 產生雜訊。可上線的 模型目錄看看有哪些嵌入模型及其維度; 各模型的計價則列在 定價頁

用餘弦相似度計分

要找出最接近的配對,你會拿查詢向量去和已儲存的向量比對。標準的度量 就是餘弦相似度: 它量的是兩個向量之間的夾角,並忽略 它們的長度,因此它在意的是方向(意義)而非大小。1.0 代表方向相同, 0 代表毫不相關:

search.py
# 語意檢索 = 把查詢嵌入向量,跟每一個已儲存的向量計分,
# 回傳最接近的。計分函式就是餘弦相似度。
import numpy as np

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    # 1.0 = 方向完全相同,0 = 毫不相關,-1 = 完全相反。
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

# 許多嵌入模型本來就回傳單位長度的向量。一旦如此,
# 餘弦相似度就簡化成單純的內積 — 大規模下更省:
def cosine_unit(a: np.ndarray, b: np.ndarray) -> float:
    return float(a @ b)

query = np.array(embed(["I forgot my login"])[0])
corpus = np.array(embed(documents))                 # (N, dim)

scores = corpus @ query / (
    np.linalg.norm(corpus, axis=1) * np.linalg.norm(query)
)
top = scores.argsort()[::-1][:5]                    # 前 5 名最佳配對的索引
for i in top:
    print(round(float(scores[i]), 3), documents[i][:60])

兩個務實的提醒。第一,許多嵌入模型本來就回傳單位長度的向量 — 一旦 如此,餘弦相似度就只是內積,這也正是為什麼對數萬個向量做暴力搜尋還 能快到足以完全省掉資料庫。第二,暴力迴圈在大約低至數萬個向量以內都 沒問題。再往上,每次查詢都掃過每一個向量就會變慢,這時你就會想要一個 附帶索引的真正向量資料庫。

用 pgvector 儲存與查詢

如果你的資料本來就放在 Postgres,你不需要另外架一個向量資料庫 — pgvector 擴充功能會新增一個 vector 欄位型別與最近鄰運算子。你把每一段 chunk 嵌入 一次,把向量存在它的文字旁邊,再讓 Postgres 去做搜尋:

store.sql
-- pgvector 把 Postgres 變成向量資料庫。啟用一次後,把每一段
-- chunk 的嵌入向量,連同它的文字與中介資料一起存起來。
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id        bigserial PRIMARY KEY,
    doc_id    text NOT NULL,
    content   text NOT NULL,
    embedding vector(3072)            -- 必須與你模型的維度一致
);

-- 近似最近鄰索引。在 pgvector 中 <=> 是餘弦距離
-- (0 = 最接近)。索引要在大量載入「之後」建立,而非之前。
CREATE INDEX ON chunks
    USING hnsw (embedding vector_cosine_ops);

-- 檢索: 把查詢嵌入向量當成參數($1)傳入,取出最接近的
-- 列。依距離遞增排序 = 最相似的排最前面。
SELECT id, doc_id, content, 1 - (embedding <=> $1) AS similarity
FROM   chunks
WHERE  doc_id = ANY($2)               -- 可選的中介資料預先篩選
ORDER  BY embedding <=> $1
LIMIT  5;

<=> 運算子是餘弦距離(0 = 完全相同), 所以 1 - (embedding <=> $1) 會把相似度分數還給你。hnsw 索引讓搜尋變成近似但快速 — 對大多數檢索負載來說, 那一點點的召回率取捨根本看不出來,而且你要在大量載入「之後」建立它, 絕不能在之前。那個可選的 WHERE 子句是低調的超能力: 在 向量搜尋「之前」先依租戶、文件、語言或日期篩選,這樣你就永遠不會 檢索到使用者無權看到的鄰居。

接進 RAG

檢索增強生成(RAG)是把兩個步驟縫在一起: 先用語意檢索找出相關脈絡, 再用生成模型依那段脈絡作答。檢索那一側就是上面所有的內容。生成那一側 則是一次普通的對話補全 — 而這正是你把便宜的嵌入模型,搭配強大的推理 模型的地方:

  • 先切塊,再嵌入。把文件切成大約數百個權杖、帶一點 重疊的段落,逐段嵌入後儲存。切塊是多數團隊最該投入卻投入不足的 槓桿: 塊太大,答案會被埋進雜訊;塊太小,又會失去讓它有意義的脈絡。 請在你自己的資料上調校它。
  • 查詢時檢索。同一個模型嵌入使用者的問題, 取出前 3~8 段 chunk,把它們的文字當成脈絡貼進提示裡。
  • 生成答案。把那段脈絡連同問題,送給一個夠強的模型 — 透過同一個 Brievio 端點使用 Claude 或 Gemini — 並指示它只依所提供的 脈絡作答,並標明它用了哪一段 chunk。

這套經濟模型行得通,是因為兩半的價位天差地別。把你整個語料庫嵌入是 一次性、低成本的批次作業;只有在內容變動時才需要重新嵌入。每次查詢的 成本是由生成步驟而非檢索所主導 — 這正是為什麼我們在 AI API 成本最佳化攻略 裡那些控制成本的技巧(對靜態指令做提示快取、為輸出設上限、為每個任務 挑對模型)所帶來的成效,遠遠大過嵌入那一側的任何手段。

那些真的會咬人的眉角

  • 向量無法跨模型互通。一旦換掉嵌入模型,你就必須把 整個語料庫重新嵌入 — 查詢向量與已儲存向量必須來自同一個模型,否則 分數毫無意義。把模型選擇當成一個 schema 決策來看待。
  • 維度是儲存與速度的取捨。一個 3072 維的向量,位元組 數是 768 維的四倍,比對起來也更慢。對你的任務而言,越大不會自動越好 — 在付費換最大的模型之前,先在你自己的查詢上量一量召回率。
  • 切塊品質勝過模型品質。多數糟糕的 RAG 答案,根源都 回到糟糕的 chunk,而非孱弱的嵌入模型。尊重文件結構 — 依標題與段落 切分,別盲目按字元數切。
  • 語意檢索可能漏掉精確詞彙。產品 SKU、錯誤碼與名稱, 有時用關鍵字搜尋反而比對得更好。實務上最強的系統都是混合式的: 把向量相似度與經典的全文檢索或 BM25 分數結合起來。
  • 嵌入向量也有脈絡上限。超過模型輸入上限的文字會被 無聲地截斷 — 這時你的「嵌入向量」就只代表一段過長 chunk 的前面那 一部分。把 chunk 保持在上限之內、留點餘裕。

具體的結論

語意檢索由四個運轉的零件構成: 一個嵌入模型、一個相似度度量、一個 儲存,以及一套切塊策略。選定一個嵌入模型並死守它;用餘弦相似度;先從 暴力 NumPy 起步,等語料庫大到單次掃描撐不住時,再升級到 pgvector 的 HNSW 索引;並把你的調校預算花在切塊上,因為檢索品質的成敗就在這裡 決定。透過 Brievio,嵌入呼叫就是你早已熟悉的 OpenAI SDK,只改一行, 按誠實的權杖數計量,而它就坐在你會用來做生成步驟的 Claude 與 Gemini 模型旁邊 — 一把金鑰、一個 base_url,整套 RAG 流程都在 其中。端點參考與一份可直接執行的快速上手,都收在 文件裡。