向量索引與自動 embeddings:HNSW、IVFFlat 與零漂移管線 | Supabase 完整教學
上一篇《用 Supabase 打造 RAG》我們把整條端到端管線跑通了,但也留下兩顆未爆彈:知識庫一大,
match_documents每次查詢就得全表掃描算距離、慢如龜爬;而且文件更新後 embedding 得手動重產,一漏掉就造成資料漂移。這一篇專治這兩件事。前半場,我們用 HNSW 與 IVFFlat 兩種向量索引把相似度搜尋從秒級加速回毫秒級,並搞懂m/ef_construction/lists建立參數、ef_search/probes查詢調參,以及最容易踩坑的 ops class(vector_cosine_ops等)。後半場,我們用 pgmq、pg_cron、pg_net 搭配 Edge Function,打造一條「資料一變更、embedding 自動補上」的零漂移管線。這也是 SB-4 運算與 AI 段的收尾。
前言
一句話定義本篇的兩個主角:向量索引(Vector Index)就是為高維向量建立的「近似最近鄰」(ANN,Approximate Nearest Neighbor)加速結構,用犧牲極少量召回率換取數十到數百倍的查詢速度;而自動 embeddings 管線就是「資料一插入或更新,資料庫自己把 embedding 重算、補回去」的自動化流程,讓向量永遠跟著文字走,不漂移。前者解決「慢」,後者解決「舊」。
先接住上一篇《用 Supabase 打造 RAG》的脈絡。那篇的 match_documents 裡有一行 order by dc.embedding <=> query_embedding——沒有索引時,這行的代價是「把整張表每一列的向量都跟 query 算一次距離,再全部排序」,也就是全表掃描(Sequential Scan)。幾千筆時你感覺不到,但長到十萬、百萬筆,每次查詢都得算幾十萬次高維距離,延遲直接從幾毫秒惡化到好幾秒,線上服務直接崩。本篇的向量索引,就是把這個「每次都全表算」變成「只沿著預先建好的圖/分群跳幾步就找到」。
用一個類比理解「有索引 vs 沒索引」。想像你要在一座沒有目錄的巨型圖書館找一本主題最接近的書:沒索引就是「一本一本翻過去比對」(全表掃描);HNSW 像是館方預先幫每本書拉好「相似書籍的捷徑連線」,你從入口沿著捷徑幾跳就到目標區(導航式圖搜尋);IVFFlat 則像先把書按主題分成幾百個「書架分區」,查詢時只走進最相關的那幾個分區翻(分群後只掃部分)。兩者都不保證找到「絕對最相似」那一本,但幾乎每次都命中前幾名——這就是「近似」的含義,也是速度的來源。
本篇你會學到:
- HNSW vs IVFFlat:兩種索引的建立語法、
m/ef_construction/lists參數、召回與速度/記憶體的取捨、各自「何時該建」 - 查詢調參:用
hnsw.ef_search與ivfflat.probes在「快」與「準」之間拉動旋鈕 - ops class 對應運算子:
vector_cosine_ops/vector_l2_ops/vector_ip_ops必須和查詢的<=>/<->/<#>對齊,否則索引失效 - 自動 embeddings 管線:用 pgmq 佇列 + pg_cron 排程 + pg_net/Edge Function,讓資料變更自動補 embedding,永不漂移
本篇聚焦「索引加速」與「自動化管線」,不重述 embedding 產生與 RAG 組裝的細節(那是第 036、037 篇的主題)。以下索引參數的預設值、
util輔助函式介面、pgvector 版本行為都可能隨版本調整,正式決策前請以官方文件為準。
核心概念
向量索引與自動管線是兩個獨立的主題,但都是讓 RAG「從能動升級到又快又不漂移」的關鍵。先把兩張心智圖建立起來。
一、HNSW vs IVFFlat 對照
先講清楚:兩者都是 ANN(近似最近鄰)索引,目的都是避免全表掃描。差別在「用什麼結構加速」以及「取捨點落在哪」。
| 面向 | HNSW(推薦) | IVFFlat |
|---|---|---|
| 全名 | Hierarchical Navigable Small Worlds(階層式可導航小世界圖) | Inverted File with Flat quantization(倒排檔+扁平量化) |
| 加速原理 | 建一張多層「相似向量捷徑圖」,查詢時沿捷徑導航 | 先把向量分成 lists 個群,查詢時只掃最近的幾群 |
| 查詢召回率(recall@10) | 高(約 0.98,數字僅供參考) | 中高(約 0.94,數字僅供參考) |
| 查詢延遲 | 低(更快) | 中 |
| 建立時間 | 較長 | 較短 |
| 索引大小/記憶體 | 較大 | 較小 |
| 能否空表先建 | 可以(邊插入邊維護) | 不行(必須先有足夠資料才能分群) |
| 適用場景 | 生產環境、線上服務、高召回要求 | 快速原型、記憶體吃緊、需極快建好 |
一句話總結取捨:HNSW 用「更多記憶體與更長建立時間」換「更高召回與更低延遲」,還能在空表上先建;IVFFlat 省記憶體、建得快,但召回略低、且必須先有資料。 絕大多數 RAG/語意搜尋應用直接選 HNSW 就對了。
這裡有個常被忽略但極其重要的細節——ops class(運算子類別)必須對應你查詢時用的距離運算子。索引是綁定「某一種距離度量」建立的,三者一定要成套對齊:
| 距離度量 | 查詢運算子 | HNSW ops class | IVFFlat ops class |
|---|---|---|---|
| 餘弦距離(Cosine,最常用) | <=> | vector_cosine_ops | vector_cosine_ops |
| 歐幾里得距離(L2) | <-> | vector_l2_ops | vector_l2_ops |
| 負內積(Inner Product,正規化向量最快) | <#> | vector_ip_ops | vector_ip_ops |
| 餘弦距離(halfvec 半精度) | <=> | halfvec_cosine_ops | (IVFFlat 支援有限,以官方為準) |
只要 ops class 與查詢運算子對不上,Postgres 就無法使用這個索引,只能退回全表掃描——你以為建了索引,其實白建。 這是本篇最該記住的一條鐵律。
二、自動 embeddings 管線示意
第二個主題解決「漂移」。上一篇攝取時是「手動呼叫 ingest」——但如果有人直接改了 documents 的內容、卻忘了重新 embed,向量就跟文字對不上了(drift)。理想狀態是:資料庫自己盯著資料,一有變更就自動把 embedding 補上。 Supabase 用五個資料庫原生元件把這條管線串起來:
文件 INSERT / UPDATE
│ ① SQL Trigger(觸發器:偵測到資料變更)
▼
pgmq(訊息佇列:把「這列要重算 embedding」的工作丟進佇列)
│ ② 工作先入佇列,不阻塞寫入、可重試
▼
pg_cron(排程器:每隔幾秒醒來一次)
│ ③ 定時批次撈出佇列裡待處理的工作
▼
pg_net(在 SQL 內發非同步 HTTP 請求)
│ ④ 把整批工作 POST 給 Edge Function
▼
Edge Function(Deno 無伺服器環境)
│ ⑤ 呼叫 embedding 模型,算出新向量
▼
UPDATE 寫回 embedding 欄位(漂移歸零)
拆解這條管線的設計哲學:觸發器只負責「入佇列」不負責「算」——因為算 embedding 要打外部 API、可能慢、可能失敗,若在觸發器裡同步做,會拖垮每一次寫入。所以改成「觸發器秒速把工作丟進 pgmq 佇列就返回」,真正的重活交給 pg_cron 定時、批次、非同步地處理。這帶來三個好處:寫入不被拖慢(解耦)、失敗可重試(pgmq 的工作沒被確認消費就會重新可見)、批次省錢(多列工作合併成一次 API 呼叫)。
三、關鍵術語速查
| 術語 | 白話解釋 |
|---|---|
| ANN(近似最近鄰) | 不保證找到「絕對最相似」,但幾乎每次命中前幾名,用一點召回換大量速度 |
| recall(召回率) | 索引找回的結果,有多少比例落在「真正最相似的前 k 名」裡;越高越準 |
| ops class(運算子類別) | 索引綁定的距離度量,必須跟查詢運算子(<=>/<->/<#>)成套對齊 |
m(HNSW) | 圖中每個節點的最大連線數;越大召回越高,但索引越大、建得越慢 |
ef_construction(HNSW) | 建索引時的候選集大小;越大品質越好,但建立越慢 |
ef_search(HNSW) | 查詢時的候選集大小;越大越準但越慢,是線上調參的主旋鈕 |
lists(IVFFlat) | 分群數量,建立時固定,通常設 sqrt(資料筆數) 附近 |
probes(IVFFlat) | 查詢時掃描的群數;越大越準但越慢,預設只掃 1 群往往召回不足 |
| pgmq | Postgres 原生訊息佇列,承載「待重算 embedding」的工作、支援重試 |
| drift(漂移) | 文字改了、embedding 沒跟著改,導致向量與內容不一致 |
實作範例
下面走一遍:建 HNSW/IVFFlat 索引(正確 ops class)→ 用 EXPLAIN 驗證真的走索引 → 查詢調參 → 自動 embeddings 管線骨架。沿用上一篇的 document_chunks 表(含 embedding vector(1536) 欄位)。
一、建立 HNSW 索引(生產首選)
最常用的是「餘弦距離 + HNSW」。因為查詢時 match_documents 用的是 <=>(餘弦距離),所以 ops class 必須是 vector_cosine_ops——成套對齊:
-- HNSW + 餘弦距離:ops class 必須對應查詢用的 <=> 運算子
create index on document_chunks
using hnsw (embedding vector_cosine_ops);
-- 想調高召回,可帶參數(值僅示意,實際依資料與需求調整)
create index on document_chunks
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
兩個建立參數的意義:
m:圖中每個節點最多連幾條「捷徑」。越大,圖越密、召回越高,但索引體積越大、建立越慢。常見起點在 16 附近(預設值以官方為準)。ef_construction:建索引時每個節點考慮的候選鄰居數。越大,圖蓋得越好、查詢品質越高,但建立時間拉長。常見起點在 64 附近(預設值以官方為準)。
若查詢用的是別的距離運算子,ops class 就要換:
-- 若查詢用歐幾里得距離 <->
create index on document_chunks using hnsw (embedding vector_l2_ops);
-- 若向量已正規化、查詢用負內積 <#>(速度最快)
create index on document_chunks using hnsw (embedding vector_ip_ops);
生產環境務必加 concurrently,否則建索引期間會鎖住整張表、擋掉寫入:
-- 不鎖表地建索引(生產環境建議;建立時間會更長)
create index concurrently on document_chunks
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
二、建立 IVFFlat 索引(先有資料才建)
IVFFlat 靠「分群」加速,所以必須在表裡已有足夠資料時才建(空表建了分群沒意義、召回會很差)。lists(分群數)通常設在 sqrt(資料筆數) 附近:
-- IVFFlat + 餘弦距離:一樣用 vector_cosine_ops 對應 <=>
-- lists 建議 ≈ sqrt(資料筆數)。例如 10 萬筆:sqrt(100000) ≈ 316
create index on document_chunks
using ivfflat (embedding vector_cosine_ops)
with (lists = 316);
-- 生產環境同樣建議 concurrently
create index concurrently on document_chunks
using ivfflat (embedding vector_cosine_ops)
with (lists = 316);
lists 的直覺:分群越多,每群越小,查詢時掃到的候選越少(越快),但也越容易「該相似的向量被切到隔壁群而漏掉」(召回下降)。所以 lists 不是越大越好,sqrt(n) 只是常見起點,實測後微調。
三、驗證索引真的被使用(EXPLAIN)
建了索引不代表查詢就會用它。 最可靠的驗證是 EXPLAIN ANALYZE——看執行計畫裡出現的是 Index Scan(走索引)還是 Seq Scan(全表掃描):
-- 用一個真實向量測試(這裡直接抓表裡第一筆的向量當 query)
explain analyze
select id, content, 1 - (embedding <=> (select embedding from document_chunks limit 1)) as similarity
from document_chunks
order by embedding <=> (select embedding from document_chunks limit 1)
limit 5;
如果計畫裡是 Index Scan using ..._embedding_idx,恭喜,索引生效了。如果看到的是 Seq Scan on document_chunks,那就是索引沒被用到——最常見原因就是 ops class 與查詢運算子對不上(例如索引建成 vector_cosine_ops,查詢卻寫成 <->)。這一步千萬別跳過。
四、查詢調參:ef_search 與 probes
索引建好後,「快」與「準」還能用 session 層級參數微調。HNSW 調 hnsw.ef_search(查詢時的候選集大小,越大越準越慢):
-- HNSW:拉高 ef_search 提升召回(預設值較低,以官方為準)
set hnsw.ef_search = 100;
-- 接著跑你的 match_documents / 相似度查詢,就會套用這個設定
select * from match_documents(
query_embedding := '[...]'::extensions.vector(1536),
match_threshold := 0.7,
match_count := 5
);
IVFFlat 調 ivfflat.probes(查詢時掃描的群數,預設常是 1,往往太低、召回不足):
-- IVFFlat:預設 probes = 1 通常召回太差,往上調(常見設為 lists 的 5~10%)
set ivfflat.probes = 10;
-- 再跑相似度查詢
select * from match_documents(
query_embedding := '[...]'::extensions.vector(1536),
match_threshold := 0.7,
match_count := 5
);
這兩個都是 session 層級設定:只影響「當前連線」,不改索引本身。實務上你會在 RAG 查詢連線裡先 set 好,再跑檢索。調參心法很簡單:先設一個值 → 用 EXPLAIN ANALYZE 看延遲、抽樣看召回 → 太慢就往下、召回不夠就往上,在你的資料上找到甜蜜點。
五、自動 embeddings 管線骨架(pgmq + pg_cron + pg_net + Edge Function)
現在接上第二個主題:讓資料一變更就自動補 embedding。完整實作牽涉 Supabase 提供的 util 輔助函式,這裡給出可理解的骨架,介面細節以官方文件為準。
第一步,啟用所需擴充套件:
-- 向量、佇列、排程、HTTP 四件套
create extension if not exists vector with schema extensions;
create extension if not exists pgmq; -- 訊息佇列
create extension if not exists pg_cron; -- 排程器
create extension if not exists pg_net with schema extensions; -- SQL 內發 HTTP
第二步,用觸發器把「待重算」工作丟進佇列(觸發器只入佇列、不算,才不會拖慢寫入)。以下用 Supabase util.queue_embeddings 輔助函式示意:
-- 插入新文件時:排程一筆 embedding 工作
create trigger embed_on_insert
after insert on documents
for each row
execute function util.queue_embeddings('embedding_input', 'embedding');
-- 只在 title 或 content 變更時:重新排程(避免無關欄位更新也觸發)
create trigger embed_on_update
after update of title, content on documents
for each row
execute function util.queue_embeddings('embedding_input', 'embedding');
第三步,用 pg_cron 定時、批次地把佇列工作 POST 給 Edge Function(透過 pg_net 非同步發 HTTP):
-- 每 10 秒醒來一次,把佇列裡待處理的 embedding 工作交給 Edge Function
select cron.schedule(
'process-embedding-queue',
'10 seconds',
$$
select net.http_post(
url := current_setting('app.supabase_url') || '/functions/v1/process-embeddings',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || current_setting('app.service_role_key')
),
body := '{}'::jsonb -- Edge Function 自己從佇列讀取一批工作
);
$$
);
第四步,Edge Function 收到觸發後,從佇列讀一批、逐筆算 embedding、寫回資料庫(骨架):
// supabase/functions/process-embeddings/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 supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!, // 伺服器端才有的 service role key
)
// 1. 從 pgmq 佇列讀取一批待處理工作(visibility timeout 內未確認會自動重試)
const { data: jobs } = await supabase.rpc('read_embedding_jobs', { batch_size: 10 })
if (!jobs || jobs.length === 0) {
return new Response(JSON.stringify({ processed: 0 }), {
headers: { 'Content-Type': 'application/json' },
})
}
// 2. 逐筆算 embedding、寫回(用 allSettled,單筆失敗不拖垮整批)
const results = await Promise.allSettled(
jobs.map(async (job: { id: number; input: string; table: string; column: string }) => {
const embedding = await embed(job.input) // 呼叫模型
const { error } = await supabase
.from(job.table)
.update({ [job.column]: embedding }) // 寫回 embedding
.eq('id', job.id)
if (error) throw new Error(error.message) // 失敗就丟出,讓工作留在佇列重試
return job.id
}),
)
const done = results.filter((r) => r.status === 'fulfilled').length
return new Response(JSON.stringify({ processed: done }), {
headers: { 'Content-Type': 'application/json' },
})
})
// 呼叫 embedding 模型(與攝取階段「同一個」模型,維度才對得上)
async function embed(text: string): Promise<number[]> {
const res = 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: text }),
})
const { data } = await res.json()
return data[0].embedding
}
第五步,驗證管線——正常插入一筆文件,幾秒後 embedding 應自動填入:
-- 插入時「不必」自己給 embedding,管線會補上
insert into documents (title, content)
values ('什麼是向量索引', '向量索引是為高維向量建立的近似最近鄰加速結構...');
-- 等幾秒後查詢,has_embedding 應為 true
select id, title, embedding is not null as has_embedding
from documents
order by created_at desc
limit 1;
這條管線的價值在最後一步顯現:你(或你的協作者、後台)只要正常改資料,embedding 永遠自動跟上,不會漂移。 攝取邏輯完全從應用層下沉到資料庫,一次設定、長久受用。
常見錯誤與最佳實踐
坑一:資料量還很少就急著建 IVFFlat(甚至在空表上建)。 IVFFlat 靠對「現有資料」分群加速,空表或資料太少時分群毫無意義,召回會慘不忍睹。正確做法:小資料集(約一萬筆以下)先別建任何向量索引,線性掃描反而又快又準;先把資料灌進去,等資料量成長到查詢明顯變慢,再建索引。要建 IVFFlat,務必在表裡已有足夠資料之後;HNSW 則沒這限制,可以空表先建、邊插入邊維護。
坑二:ops class 跟查詢運算子對不上,索引形同虛設。
這是本篇最致命也最隱蔽的坑——用 vector_cosine_ops 建索引、查詢卻寫 <->(L2),或反過來。Postgres 不會報錯,只會默默退回全表掃描,你以為建了索引、其實完全沒生效。正確做法:三者成套對齊——餘弦距離用 <=> 配 vector_cosine_ops、L2 用 <-> 配 vector_l2_ops、內積用 <#> 配 vector_ip_ops。建完一定用 EXPLAIN ANALYZE 確認執行計畫出現的是 Index Scan 而非 Seq Scan。
坑三:以為建了索引就萬事大吉,從不驗證、從不調參。
索引建了、查詢卻慢或不準,往往是查詢調參太保守:HNSW 的 ef_search、IVFFlat 的 probes(預設常是 1)沒調,召回不足。正確做法:查詢前用 set hnsw.ef_search = 100(或依需求)、set ivfflat.probes = 10(常見設為 lists 的 5~10%);並用 EXPLAIN ANALYZE 邊看延遲、邊抽樣看召回,在你的資料上找甜蜜點。快與準永遠是一組旋鈕,沒有一體適用的魔法數字。
坑四:生產環境建索引不加 concurrently,直接鎖表擋掉線上寫入。
在有流量的表上執行普通 create index,會鎖住整張表直到建完,期間所有寫入被卡住。大表建 HNSW 可能要好幾分鐘,等於服務中斷。正確做法:生產環境一律 create index concurrently ...(建立時間更長但不鎖表);能安排維護窗口就更保險。
坑五:在觸發器裡「同步」算 embedding,拖垮每一次寫入。
若在 after insert 觸發器裡直接呼叫外部 embedding API,每一筆插入都得等一次網路往返、還可能因 API 失敗而讓整個交易 rollback。正確做法:觸發器只負責「秒速把工作丟進 pgmq 佇列」就返回,真正的重活交給 pg_cron 定時、pg_net 非同步、Edge Function 批次處理——這正是自動管線「解耦 + 重試 + 批次」三大好處的來源。
最佳實踐總結:
- 索引何時建:小資料先別建(線掃更快);資料量成長到變慢再建,生產首選 HNSW
- ops class 成套對齊:
<=>↔vector_cosine_ops、<->↔vector_l2_ops、<#>↔vector_ip_ops,建完用EXPLAIN ANALYZE驗證走索引 - IVFFlat 先有資料:靠分群加速,空表建無意義;
lists從sqrt(n)起步 - 查詢調參:
hnsw.ef_search/ivfflat.probes拉動快與準的旋鈕,預設probes = 1通常太低 - 生產加 concurrently:不鎖表建索引
- 自動管線解耦:觸發器只入佇列,pgmq + pg_cron + pg_net + Edge Function 非同步批次補 embedding,永不漂移
- 細節以官方為準:索引參數預設值、
util輔助函式介面、pgvector 版本行為都可能調整
小結
這是 Supabase 系列教學的第 038 篇,也是 SB-4「運算與 AI」段的收尾。上一篇《用 Supabase 打造 RAG》我們把「分塊 → embed → 存 pgvector → match_documents 檢索 → 組 prompt → LLM 串流生成」的端到端管線跑通了,但留下「查詢慢」與「embedding 會漂移」兩顆未爆彈。這一篇正好把它們拆彈:前半用 HNSW/IVFFlat 向量索引把檢索從全表掃描加速回毫秒級,後半用 pgmq、pg_cron、pg_net 搭 Edge Function 打造零漂移的自動 embeddings 管線。
核心觀念濃縮成一句話:向量索引是「用一點召回換大量速度」的近似最近鄰結構——生產首選 HNSW(可空表先建),資料多才建 IVFFlat(靠分群、需先有資料);不論哪種,ops class(vector_cosine_ops 等)都必須跟查詢運算子(<=> 等)成套對齊,否則索引白建;查詢時再用 ef_search/probes 微調快與準。而自動 embeddings 管線讓資料一變更就由觸發器入佇列、pg_cron 定時、pg_net 呼叫 Edge Function 補上 embedding,徹底告別漂移。
- HNSW vs IVFFlat:前者召回高、延遲低、可空表先建;後者省記憶體、建得快,但需先有資料、召回略低
- ops class 鐵律:
<=>↔vector_cosine_ops、<->↔vector_l2_ops、<#>↔vector_ip_ops,用EXPLAIN ANALYZE驗證走索引 - 查詢調參:
hnsw.ef_search、ivfflat.probes拉動「快 vs 準」,session 層級設定 - 自動管線:觸發器入佇列(pgmq)→ pg_cron 定時 → pg_net 呼叫 Edge Function → 寫回 embedding,解耦、可重試、批次
- 索引何時建:小資料線掃更快,資料成長到變慢再建;生產一律
concurrently
到這裡,SB-4 運算與 AI 段(Edge Functions、embeddings、RAG、向量索引與自動化)就完整了——你的 Supabase 已經能又快又聰明地處理 AI 工作負載。但寫到現在,所有 SQL 與函式都還是在雲端儀表板上敲的,缺了一套順手的本機開發流程。下一篇《Supabase CLI 與本機開發》,我們把戰場搬回你自己的電腦:用 Supabase CLI 在本機跑一整套 Supabase、用 migration 管理 schema 版本、把今天的索引與函式全部寫成可版控、可重現的程式碼,讓開發體驗再上一層樓。