Embeddings 與相似度搜尋:match_documents 實戰 | Supabase 完整教學

2026/10/25
Embeddings 與相似度搜尋:match_documents 實戰 | Supabase 完整教學

上一篇《pgvector 向量入門》我們學會了怎麼用 vector(1536) 欄位存 embedding、用三個距離運算子比對相似度。但有個關鍵環節還沒補上——那串 embedding 到底怎麼來的?這一篇,我們就把整條「相似度搜尋」的線接起來:用模型(OpenAI text-embedding-3-small 或開源 gte-small)把文字產生成 embedding、存進 pgvector,寫一個 match_documents SQL 函式(餘弦相似度 + 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 是把文字丟給一個模型、由它算出來的。有兩條主流路線:

路線代表模型維度特點
雲端 APIOpenAI text-embedding-3-small1536品質穩、一行 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_count
  • supabase.rpc:前端呼叫函式,拿回排序好、含 similarity 分數的結果
  • Edge Function 內查:把產 query embedding 搬到伺服器端,API key 不外洩
  • 鐵律不變:存與查用同一個模型,維度處處對齊

有了「產 embedding + 相似度搜尋」這對能力,我們已經握住了語意搜尋的完整迴路。下一篇《用 Supabase 打造 RAG》,我們要把這條迴路接上大型語言模型——先用 match_documents 檢索出最相關的文件片段,再把它們塞進 prompt 交給 LLM 生成回答,做出一個真正「基於你自己知識庫」回答問題的 RAG 應用。相似度搜尋,是 RAG 的心臟。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →