Embeddings 與相似度搜尋:match_documents 實戰 | Supabase 完整教學
上一篇《pgvector 向量入門》我們學會了怎麼用
vector(1536)欄位存 embedding、用三個距離運算子比對相似度。但有個關鍵環節還沒補上——那串 embedding 到底怎麼來的?這一篇,我們就把整條「相似度搜尋」的線接起來:用模型(OpenAItext-embedding-3-small或開源gte-small)把文字產生成 embedding、存進 pgvector,寫一個match_documentsSQL 函式(餘弦相似度 +match_threshold+match_count),再用supabase.rpc從前端呼叫它,最後把「產 embedding」搬進 Edge Function 裡即時查詢。語意搜尋,這篇真的會動起來。
前言
一句話定義本篇要做的事:「相似度搜尋」就是把使用者的問題也轉成一個 embedding(query embedding),拿它去和資料庫裡每一筆文件的 embedding 算餘弦相似度,取出「意思最接近」的前幾筆。 而在 Supabase 上,這件事的標準做法是——把這段查詢封裝成一個叫 match_documents 的 Postgres 函式,再用 supabase.rpc() 呼叫。
先接住上一篇《pgvector 向量入門》的脈絡。那一篇我們把 pgvector 啟用起來、建好 vector 欄位、也認識了 <->、<=>、<#> 三個運算子——但我們是手動塞了幾筆已算好的迷你向量進去示範。真實世界沒人手算向量:embedding 是呼叫一個 AI 模型產生的。這一篇就要補上這關鍵的一步,並把「存」與「查」串成一個能跑的語意搜尋。
用一個類比理解整條流程。想像你在經營一間圖書館:「產生 embedding 並存入」 就像替每本書貼上一張記錄「這本書講什麼」的智慧標籤(一串座標);「相似度搜尋」 則是讀者走進來問「我想找關於某某主題的書」,你也把這句問題貼上同一種標籤,然後去書架上找標籤座標最接近的那幾本。關鍵在於——貼書的標籤機和貼問題的標籤機,必須是同一台(同一個模型),座標才對得上。
本篇你會學到:
- embedding 怎麼產生:用 OpenAI
text-embedding-3-small或開源gte-small把文字轉成向量陣列,並存進 pgvector match_documents函式:為什麼要包成 SQL 函式、如何用<=>算餘弦相似度、match_threshold與match_count各控制什麼- 用
supabase.rpc查詢:從前端把 query embedding 傳進去,拿回排序好的相似結果 - 在 Edge Function 內即時查:把「產 query embedding」搬到伺服器端,避免在前端暴露 API key
本篇聚焦「產 embedding + 相似度搜尋實作」,不重複 pgvector 的基礎(型別、運算子細節請回顧上一篇)。向量索引(HNSW/IVFFlat)與完整 RAG 架構,留給後續文章。以下模型名稱、維度、API 介面與函式行為請以官方文件為準。
核心概念
相似度搜尋看起來只是一句「找最像的」,但它其實由三個環節串成,缺一不可。先把這條流程的心智圖建立起來,後面寫程式就水到渠成。
一、整條相似度搜尋流程
把「存」和「查」拆開看,會清楚很多。整條線長這樣:
【建立階段(一次性,或文件更新時)】
文件文字
│ 呼叫 embedding 模型(text-embedding-3-small / gte-small)
▼
embedding 向量(number[],例如 1536 個浮點數)
│ 存進 pgvector 欄位
▼
documents 資料表(content + embedding)
【查詢階段(每次搜尋)】
使用者問題文字
│ 呼叫「同一個」embedding 模型
▼
query embedding(同維度的 number[])
│ 傳給 match_documents 函式,用 <=> 算餘弦相似度
▼
排序後的相似結果(含 similarity 分數)
看懂這張圖,就抓到三個重點:一、存和查用的是同一個模型;二、查詢也要先把問題轉成向量;三、真正的比對發生在資料庫裡,由 match_documents 函式執行。
二、embedding 是「呼叫模型」得到的
上一篇我們手填了 [0.9, 0.1, 0.0] 這種向量,那是為了教學。實務上,embedding 是把文字丟給一個模型、由它算出來的。有兩條主流路線:
| 路線 | 代表模型 | 維度 | 特點 |
|---|---|---|---|
| 雲端 API | OpenAI text-embedding-3-small | 1536 | 品質穩、一行 API、按量計費、需 API key |
| 開源自跑 | gte-small(Hugging Face) | 384 | 免費、可在 Edge Function 內跑、維度小查詢快 |
兩條路線的輸出都是一個 number[] 陣列——差別只在維度(1536 vs 384)和成本。你選哪個,就決定了上一篇那個 vector(n) 欄位要開幾維:OpenAI → vector(1536)、gte-small → vector(384)。這是延續上一篇的鐵律:欄位維度必須等於模型輸出維度。
三、為什麼查詢要包成 match_documents 函式
這是 Supabase 向量搜尋最關鍵、也最容易卡關的一點:Supabase 的 REST API 層(PostgREST)不支援直接使用向量運算子。 你沒辦法從前端這樣寫:
// ❌ 行不通:PostgREST 看不懂向量運算子
await supabase
.from('documents')
.select('content, embedding <=> queryEmbedding')
解法是把整段相似度查詢——算距離、過濾門檻、排序、限制筆數——封裝進一個 Postgres 的 Database Function,慣例命名為 match_documents,然後用 supabase.rpc() 呼叫。這個函式接收三個關鍵參數:
query_embedding:查詢向量(把使用者問題轉成的 embedding)match_threshold:相似度門檻,只回傳相似度高於此值的結果(過濾雜訊)match_count:最多回傳幾筆(limit)
函式內部的核心邏輯是:用 1 - (embedding <=> query_embedding) 把餘弦距離換算成餘弦相似度(越大越相似),拿它和 match_threshold 比對做過濾,最後 order by embedding <=> query_embedding asc(距離越小排越前面)取 match_count 筆。記住這個等價關係:相似度 = 1 − 餘弦距離,距離越小 = 相似度越大 = 越相似。
實作範例
下面走一遍完整最小流程:建表 → 寫 match_documents 函式 → 產 embedding 存入 → 用 rpc 查詢 → 把產 embedding 搬進 Edge Function。以 OpenAI text-embedding-3-small(1536 維)為主,最後補上開源 gte-small 的版本。
一、建立向量表
延續上一篇,先建一張存文件與其向量的表。這裡用 1536 維對應 text-embedding-3-small:
-- 啟用 pgvector(上一篇已介紹,這裡確保存在)
create extension if not exists vector with schema extensions;
-- 文件表:content 存原文,embedding 存向量
create table documents (
id bigint primary key generated always as identity,
content text not null,
embedding extensions.vector(1536), -- 對應 text-embedding-3-small
created_at timestamptz default now()
);
二、寫 match_documents 相似度搜尋函式
這是本篇的主角。把相似度查詢封裝成 SQL 函式,接收 query_embedding、match_threshold、match_count 三個參數,回傳每筆的 id、content 與 similarity 分數:
-- 相似度搜尋函式:用餘弦相似度找出最接近查詢向量的文件
create or replace function match_documents(
query_embedding extensions.vector(1536), -- 查詢向量,維度需與欄位一致
match_threshold float default 0.78, -- 相似度門檻(官方預設 0.78)
match_count int default 10 -- 最多回傳幾筆
)
returns table (
id bigint,
content text,
similarity float
)
language sql stable
as $$
select
documents.id,
documents.content,
-- 餘弦相似度 = 1 - 餘弦距離;越大越相似
1 - (documents.embedding <=> query_embedding) as similarity
from documents
-- 只保留相似度「高於」門檻的結果
where 1 - (documents.embedding <=> query_embedding) > match_threshold
-- 依距離由小到大排序(距離越小 = 越相似)
order by documents.embedding <=> query_embedding asc
limit match_count;
$$;
逐段拆解這個函式的關鍵設計:
1 - (embedding <=> query_embedding):<=>是餘弦距離(越小越相似),減去 1 換算成餘弦相似度(越大越相似),比較符合直覺,也方便和match_threshold比對。where ... > match_threshold:這一行是過濾器。相似度沒過門檻的直接淘汰,避免把不相關的內容也撈回來。門檻預設0.78(Supabase 官方範例值)。order by ... <=> ... asc:排序用距離、升序(不是用相似度降序)——結果一樣,但直接對距離運算子排序,之後建了向量索引時才能被索引加速。limit match_count:控制回傳筆數,語意搜尋通常取前 5~10 筆餵給下游。language sql stable:純 SQL 函式,標記stable表示同樣輸入回同樣結果,利於查詢規劃。
三、產生 embedding 並存入(TypeScript)
現在補上「產 embedding」這一步。用 OpenAI SDK 把文字轉成向量,再用 Supabase JS SDK 存進去。注意:SDK 收 number[] 陣列即可,會自動處理向量格式轉換,不必自己拼 '[...]' 字串:
import OpenAI from 'openai'
import { createClient } from '@supabase/supabase-js'
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! })
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!, // 伺服器端用 service role key
)
// 把一段文字轉成 embedding 向量
async function embed(text: string): Promise<number[]> {
const res = await openai.embeddings.create({
model: 'text-embedding-3-small', // 1536 維,存與查都要用同一個
input: text,
})
return res.data[0].embedding // number[],長度 1536
}
// 攝取一份文件:先產 embedding,再存進 documents 表
async function ingest(content: string) {
const embedding = await embed(content)
const { error } = await supabase
.from('documents')
.insert({ content, embedding }) // 直接傳 number[],SDK 會處理
if (error) throw error
}
// 灌幾筆示範資料
await ingest('Supabase 是開源的 Firebase 替代方案,內建 Postgres 資料庫。')
await ingest('pgvector 讓 Postgres 能儲存向量並做相似度搜尋。')
await ingest('Row Level Security 讓你在資料列層級控制存取權限。')
四、用 supabase.rpc 執行相似度搜尋
存好之後,查詢分兩步:先把使用者問題也轉成 embedding,再用 supabase.rpc() 呼叫 match_documents。這就是整篇的收束點:
async function search(query: string) {
// 1. 把問題轉成 query embedding(務必用「同一個」模型)
const queryEmbedding = await embed(query)
// 2. 呼叫 match_documents 函式做相似度搜尋
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding, // number[]
match_threshold: 0.7, // 門檻可依場景調整
match_count: 5, // 取前 5 筆
})
if (error) throw error
return data // [{ id, content, similarity }, ...],已依相似度排序
}
// 使用者用「完全不同的字」也能搜到語意相近的文件
const results = await search('怎麼在 Postgres 做向量搜尋?')
for (const row of results) {
console.log(row.similarity.toFixed(3), row.content)
}
// 預期:講 pgvector / 向量搜尋那筆的 similarity 最高,排在最前面
重點在於:使用者問「怎麼在 Postgres 做向量搜尋?」,字面上和資料庫裡「pgvector 讓 Postgres 能儲存向量並做相似度搜尋」用字不完全相同,但因為兩者語意相近、embedding 座標靠近,match_documents 依然把它排在第一。這正是關鍵字搜尋做不到、而語意搜尋能做到的事。
五、在 Edge Function 內即時產 embedding 再查
上面的 embed() 直接在有 OPENAI_API_KEY 的環境跑——但如果搜尋是由前端觸發,你絕不能把 API key 放進瀏覽器。正解是把「產 query embedding + 查詢」整段搬進 Edge Function(Deno 無伺服器環境),前端只傳純文字問題,API key 安全地留在伺服器端:
// supabase/functions/search/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
serve(async (req: Request) => {
const { query } = await req.json() // 前端只傳純文字問題
// 1. 在伺服器端呼叫 OpenAI 產生 query embedding(API key 不外洩)
const embedRes = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small', // 與存入時同一個模型
input: query,
}),
})
const { data } = await embedRes.json()
const queryEmbedding = data[0].embedding
// 2. 呼叫 match_documents 做相似度搜尋
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
const { data: matches, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_threshold: 0.7,
match_count: 5,
})
if (error) {
return new Response(JSON.stringify({ error: error.message }), { status: 500 })
}
return new Response(JSON.stringify({ matches }), {
headers: { 'Content-Type': 'application/json' },
})
})
前端只需要一個乾淨的 fetch,完全碰不到 API key:
// 前端呼叫 Edge Function(不含任何 secret)
const res = await fetch('https://<project>.supabase.co/functions/v1/search', {
method: 'POST',
headers: {
'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: '怎麼在 Postgres 做向量搜尋?' }),
})
const { matches } = await res.json()
六、用開源 gte-small 免 API key
不想付 OpenAI 費用、或想完全自主?可以在 Edge Function 內用 Transformers.js 載入開源的 gte-small(384 維),完全不需要外部 API:
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.5.0'
// 載入 gte-small(384 維,開源免費)
const pipe = await pipeline('feature-extraction', 'Supabase/gte-small')
async function embedLocal(text: string): Promise<number[]> {
// normalize: true 會把向量正規化,方便之後用餘弦或內積
const output = await pipe(text, { pooling: 'mean', normalize: true })
return Array.from(output.data) as number[] // number[],長度 384
}
改用 gte-small 時,記得同步把兩處改成 384 維:建表的 vector(1536) 改成 vector(384)、match_documents 的 query_embedding vector(1536) 也改成 vector(384)。存與查一樣要用同一個模型,這條鐵律不會變。
常見錯誤與最佳實踐
坑一:查詢與文件用了「不同模型」產生 embedding。
最致命也最難察覺——程式不會報錯,但結果全錯。若你當初用 text-embedding-3-small(1536 維)灌資料,查詢時卻用 gte-small(384 維)產 query embedding,維度不合會直接報錯還算好;真正可怕的是兩個模型維度湊巧相同時,程式照跑、卻回傳一堆牛頭不對馬嘴的結果,因為兩個模型的向量空間座標系不同,算距離毫無意義。正確做法:把「用哪個模型」寫成單一的 embed() 函式,存與查都呼叫它;換模型就把整張表重新 embed 一次。
坑二:match_threshold 亂設,不是回傳 0 筆就是全是雜訊。
門檻設 0.9 太嚴,幾乎搜不到東西(常見的「明明有相關文件卻回傳空陣列」就是這個原因);設 0.4 太鬆,什麼都撈回來。正確做法:從官方預設 0.78 起步,一般文件問答想多抓候選可降到 0.6~0.7;把 match_threshold 做成參數傳進去(範例就是這樣),依實際資料調整,而不是寫死在函式裡。如果常回傳空結果,先降門檻,別急著懷疑資料或程式。
坑三:忘了正規化,卻用了 <#>(內積)。
<#>(負內積)只有在向量已正規化(長度為 1)時才等價於餘弦相似度。OpenAI 的輸出通常已正規化,但開源模型要看你有沒有加 normalize: true。若向量沒正規化就用 <#>,結果會被向量長度干擾而失準。正確做法:不確定有沒有正規化,就一律用 <=>(餘弦距離,天生不受長度影響,最安全)——本篇範例全程用 <=> 就是這個道理;除非你確定已正規化、且要壓榨效能,才換 <#>。
坑四:把 API key 放進前端。
在瀏覽器端直接呼叫 OpenAI 產 embedding,等於把金鑰公開給全世界。正確做法:把「產 query embedding + 查詢」放進 Edge Function(或你的後端),前端只傳純文字問題、只帶 anon key,OPENAI_API_KEY 與 service_role_key 都留在伺服器端(用 supabase secrets set 設定)。
坑五:match_threshold 跟「距離」比而非「相似度」。
新手常把過濾條件寫成 where embedding <=> query < match_threshold,卻又用 0.78 當門檻——這是拿「距離」去比一個「相似度」的數字,邏輯全反了。正確做法:想清楚你比的是哪個量。用相似度就寫 where 1 - (embedding <=> query) > match_threshold(越大越相似);若真要用距離過濾,門檻要換算成 < 1 - match_threshold,且別忘了距離是越小越相似。本篇統一用「相似度 > 門檻」的寫法,最不容易搞錯。
最佳實踐總結:
- 一個
embed()函式打天下:存與查共用,從源頭杜絕「模型不一致」 - 門檻當參數、從 0.78 起步:
match_threshold別寫死,依場景微調;空結果先降門檻 - 預設用
<=>:不確定正規化與否就用餘弦距離,最安全 - API key 留在伺服器:產 embedding 放 Edge Function,前端只傳文字
- 相似度 = 1 − 距離:過濾用相似度、排序用距離(升序),兩者別混
- 維度處處對齊:換模型時,建表欄位、
match_documents參數、embed()三處一起改 - 細節以官方為準:模型維度、API 介面、PostgREST 行為都可能調整,正式決策前查官方文件
小結
這是 Supabase 系列教學的第 036 篇,接續 AI/向量段 的第二棒。上一篇《pgvector 向量入門》我們學會用 create extension vector 啟用 pgvector、用 vector(1536) 欄位儲存 embedding、認識 <->/<=>/<#> 三個距離運算子——但向量是我們手動填進去的。這一篇補上了最關鍵的「產 embedding」與「把查詢跑起來」兩步,語意搜尋終於真正會動。
核心觀念濃縮成一句話:相似度搜尋就是「把使用者問題也用同一個模型轉成 query embedding,交給 match_documents 函式(用 1 - (embedding <=> query_embedding) 算餘弦相似度、以 match_threshold 過濾、依距離升序取 match_count 筆),再用 supabase.rpc() 呼叫」;產 embedding 這一步要放在 Edge Function 或後端,別讓 API key 外洩。
- 產 embedding:用 OpenAI
text-embedding-3-small(1536)或開源gte-small(384)把文字轉成number[] match_documents:包 SQL 函式繞過 PostgREST 限制,參數query_embedding/match_threshold/match_countsupabase.rpc:前端呼叫函式,拿回排序好、含similarity分數的結果- Edge Function 內查:把產 query embedding 搬到伺服器端,API key 不外洩
- 鐵律不變:存與查用同一個模型,維度處處對齊
有了「產 embedding + 相似度搜尋」這對能力,我們已經握住了語意搜尋的完整迴路。下一篇《用 Supabase 打造 RAG》,我們要把這條迴路接上大型語言模型——先用 match_documents 檢索出最相關的文件片段,再把它們塞進 prompt 交給 LLM 生成回答,做出一個真正「基於你自己知識庫」回答問題的 RAG 應用。相似度搜尋,是 RAG 的心臟。