歡迎來到第十一天!昨天我們跨出了巨大的一步:成功將第一個知識點 (keyPoint
) 轉化為向量,並存入了 Supabase 這個雲端知識庫,順便還嘴了一下 Google 的文件沒寫好。我們的 AI 終於有了一個可以長期儲存記憶的地方!
但只有儲存是不夠的,一樣回到圖書查詢的例子,這就像是我們建立了一座宏偉的圖書館塞滿了書,卻還沒有任何有效率的搜尋方式,如果管理員只會把整排書架的書都搬出來,讓讀者自己一本本翻,那這座圖書館的體驗肯定糟透了,圖書管理員大概率隔天就不幹了。我們需要教會他如何根據讀者的問題,快速、準確地找出相關的書籍。
在我們開始打造聰明的「圖書館管理員」之前,有兩件前置作業必須完成:
充實我們的圖書館:Day 10 的腳本一次只能上一本書,效率太低。我們需要一個「批量上傳」的腳本,將 questions.json
中所有的知識點一次性上架。
確保搜尋的精準度:我們的搜尋必須是精準的,當使用者在回答「JavaScript」的問題時,我們不希望撈到「React」的參考資料。
完成這兩項準備後,我們就能正式打造 RAG 流程中最核心的武器——Supabase 資料庫函式,一個能實現高效語意搜尋的超級引擎。
撰寫一個批量處理腳本,將所有 keyPoints
向量化並存入 Supabase。
學習如何使用 pgvector 的 <=> 運算子,達到我們之前手刻的餘弦相似函數更好的效果。
設計並建立一個精準的 SQL 資料庫函式,能根據特定問題 ID 進行向量搜尋。
撰寫測試腳本,驗證我們的搜尋函式能準確地從完整的知識庫中,檢索出最相關的資訊。
首先,讓我們來解決 Day 10 腳本只能處理單一資料的問題。一個完整的知識庫才能讓我們後續的搜尋測試更有意義。
請在你的 scripts/ 資料夾下,建立一個新檔案 seed-all-vectors.js。這個腳本將會讀取整個 questions.json
,並將每一個 keyPoint
都變成向量存入資料庫。
import { createClient } from '@supabase/supabase-js';
import { GoogleGenAI } from '@google/genai';
import dotenv from 'dotenv';
import questions from '../data/questions.json' with { type: "json" };
dotenv.config({ path: './.env.local' });
// 輔助函數:將陣列分割成指定大小的區塊
function chunkArray(array, chunkSize) {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
}
async function seedAll() {
// 初始化客戶端 (使用 Day 10 設定好的環境變數)
const gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
);
console.log('正在清空舊的 documents 資料...');
const { error: deleteError } = await supabase.from('documents').delete().neq('id', 0);
if (deleteError) {
console.error('清空資料失敗:', deleteError.message);
return;
}
console.log('舊資料已清空。');
console.log('開始處理所有 questions.json 中的 keyPoints...');
// 1. 將所有 keyPoints 攤平成一個列表
const allKeyPoints = questions.flatMap(q => {
// 只處理有 keyPoints 且其為陣列的題目
if (q.keyPoints && Array.isArray(q.keyPoints)) {
return q.keyPoints.map(kp => ({
questionId: q.id,
content: kp,
}));
}
// 如果沒有 keyPoints,就回傳空陣列,flatMap 會自動忽略它
return [];
});
console.log(`總共找到 ${allKeyPoints.length} 個 keyPoints 待處理。`);
// 2. 將資料分塊,避免一次送出太多請求
const chunks = chunkArray(allKeyPoints, 5); // 一次處理 5 筆
try {
for (const [index, chunk] of chunks.entries()) {
console.log(`- 正在處理第 ${index + 1} / ${chunks.length} 批資料...`);
const contents = chunk.map(kp => kp.content);
// 3. 一次性產生多個 Embedding
const response = await gemini.models.embedContent({
model: 'gemini-embedding-001',
contents: contents,
config: { outputDimensionality: 768 },
});
const embeddings = response.embeddings.map(e => e.values);
// 4. 準備要插入的資料
const dataToInsert = chunk.map((kp, i) => ({
content: kp.content,
embedding: embeddings[i],
question_id: kp.questionId,
}));
// 5. 一次性插入多筆資料
const { error } = await supabase.from('documents').insert(dataToInsert);
if (error) {
console.error(` 寫入此批資料失敗: ${error.message}`);
} else {
console.log(` 成功寫入 ${chunk.length} 筆資料。`);
}
}
console.log('🎉 所有 KeyPoints 已成功寫入 Supabase!');
} catch (error) {
console.error('批量處理過程中發生嚴重錯誤:', error.message);
}
}
seedAll();
keyPoints
分批 (chunk) 處理,並利用 embedContent API 可以一次處理多個字串的特性,減少了請求的次數免得我們不小心超過免費流量額度。keyPoints
時先做了個確認,因為目前questions.json
中其實只有概念題有keyPoints
,程式實作題則是完全依賴testCases有沒有通過,但我當初規劃結構時並沒有想得這麼全面,之後整合時我們會再修改介面與未來資料庫的內容。現在,執行這個腳本:
node scripts/seed-all-vectors.js
順利的話你應該會在終端機看到以下的訊息印出:
正在清空舊的 documents 資料...
舊資料已清空。
開始處理所有 questions.json 中的 keyPoints...
總共找到 10 個 keyPoints 待處理。
- 正在處理第 1 / 2 批資料...
成功寫入 5 筆資料。
- 正在處理第 2 / 2 批資料...
成功寫入 5 筆資料。
🎉 所有 KeyPoints 已成功寫入 Supabase!
腳本順利的如我們預期的完成了!馬上切回到 Supabase 的 Table Editor,你會發現 documents 表已經被我們完整的知識庫填滿了!
![]() |
---|
圖1 :批量資料新增成功畫面 |
知識庫準備就緒,現在來打造我們的搜尋引擎。我們要建立一個 SQL 函式,它不僅要能搜尋向量,還要能根據我們指定的 question_id
來過濾,實現精準打擊。
不過,該做的說明還是要做,到底什麼是 資料庫函式, Supabase列表中還有個 Edge Functions 這又是什麼? 是一樣東西嗎? 問得好!作為一個前端仔你不知道也是情有可原的!馬上根據 Supabase 的文獻總結以下兩者的差異,請參考以下的表格快速了解兩者的定義吧!
特性 | Database Functions (資料庫函式) | Edge Functions (邊緣函式) |
---|---|---|
執行環境 | 在你的 PostgreSQL 資料庫內部執行,離資料最近 | 在全球分佈的 Deno 伺服器邊緣節點上執行,離使用者最近 |
主要語言 | SQL, PL/pgSQL (或其他資料庫支援的程序化語言) | TypeScript / JavaScript (WASM 也支援) |
核心用途 | 資料密集型操作:複雜的資料查詢、資料驗證、多表交易 (Transaction) | 低延遲的業務邏輯:需要快速回應的 API、處理 Webhooks、與第三方服務 (如 Stripe) 整合 |
外部 API 呼叫 | 困難。原生不支援,需要額外擴充才能做到。 | 非常容易。這是其主要設計用途之一,可以輕鬆 fetch 任何外部服務 |
觸發方式 | 可透過 Client SDK 的 rpc() 呼叫,或是由資料庫事件 (如 INSERT , UPDATE ) 自動觸發 (Triggers) |
透過標準的 HTTP 請求 (GET, POST, etc.) 觸發,就像一個標準的 API 端點 |
前端類比 | 像是資料庫內建的、高效的「預存程序 (Stored Procedure)」 | 像是你熟悉的 Next.js API Route 或任何 Serverless Function |
何時選用? | 當你需要確保資料一致性、執行複雜的 SQL 計算 (像我們的向量搜尋),或是在一次操作中修改多張表時。 | 當你需要與外部服務溝通、提供一個公開的 API 端點給前端或其他服務呼叫,或是希望全球使用者都有極低的延遲時。 |
大致概念理解後就回到主題吧,我們要創建一個資料庫函數在我們的後端服務中呼叫,去決定我們想取得多相近的資料、又想取得幾筆的這些細節,如果全部撈出來再一筆筆的算餘弦距離那肯定會很沒效率,有好用的工具就不要客氣吧! 照 Supabase 官方文件的說法,建立資料庫函數有數種方式,我們這邊選最簡單的直接用 Sql語法建立。
現在,我們要執行一些 SQL 指令來賦予我們的資料庫搜尋能力。這會分成兩部分:首先,建立一個聰明的「搜尋函式」;接著,為這個函式建立「索引」,大幅提升查詢速度。
請回到 Supabase 的「SQL Editor」,點擊「New query」,貼上只有建立函式的程式碼:
-- 建立一個函式,用來搜尋特定問題下,語意最相關的知識點
create or replace function public.match_documents (
query_embedding vector(768), -- 用來查詢的向量
p_question_id text, -- 【關鍵】要匹配的問題 ID
match_threshold double precision, -- 相似度的門檻值
match_count int -- 最多回傳幾筆結果
)
returns table ( -- 定義回傳的格式
id bigint,
content text,
similarity double precision
)
language sql
stable
as $$
select
d.id,
d.content,
(1 - (d.embedding <=> query_embedding)) as similarity
from public.documents as d
-- 【關鍵】過濾條件:確保只在當前問題的 keyPoints 中搜尋
where d.question_id = p_question_id
and (1 - (d.embedding <=> query_embedding)) > match_threshold
-- 根據相似度由高到低排序
order by similarity desc
-- 限制回傳的筆數
limit match_count;
$$;
keyPoints
範圍內進行。點擊「RUN」執行,應該會在下方的視窗看到 Success 的字眼!我們的 AI 大腦就學會了第一個技能:精準回憶!
切到 Database => Functions 的頁面,檢查一下是否有這個新建立的函數,有看到下圖的畫面就大功告成囉!
![]() |
---|
圖2 :剛建好的資料庫函數 |
函式建好了,但如果資料量變大,沒有索引的搜尋會非常慢。現在,讓我們為它加上索引來提升效能。同樣在「SQL Editor」中,你可以開一個新的查詢,或者直接刪掉舊的內容,貼上只有建立索引的程式碼:
-- 索引 1:為向量搜尋建立 HNSW 索引,大幅加速餘弦距離計算
create index if not exists documents_embedding_hnsw
on public.documents
using hnsw (embedding vector_cosine_ops);
-- 索引 2:為 question_id 建立標準 B-Tree 索引,加速範圍過濾
create index if not exists documents_question_id_idx
on public.documents (question_id);
再次點擊「RUN」。這次執行可能會花幾秒鐘,因為資料庫正在為我們現有的所有資料建立索引。完成後,我們的搜尋引擎就正式準備就緒了!
第一段:
第二段:
綜合起來就是:
補充說明:
在目前資料量極少的情況下,坦白講全表搜尋還是會比索引搜尋快速,你實際執行搜尋時不用到索引的可能性也很高!但未來或是一個真正上線的產品不可能只有這點資料量,因此雖然我也只是這方面的菜雞,我也還是想盡可能的提一下並作基礎的示範。
函式建好了,必須立刻測試它。我們來寫一個新的測試腳本,驗證它是否真的能做到精準搜尋。
在 scripts/ 資料夾下建立 test-search.js:
// scripts/test-search.js
import { createClient } from '@supabase/supabase-js';
import { GoogleGenAI } from '@google/genai';
import dotenv from 'dotenv';
dotenv.config({ path: './.env.local' });
async function testSearch() {
// 1. 模擬使用者回答 hoisting 問題
const userAnswer = "變數宣告會被拉到程式碼最上面,但賦值會留在原地";
const targetQuestionId = 'js-con-001'; // 我們要針對 hoisting 問題進行搜尋
console.log(`[測試] 使用者回答: "${userAnswer}"`);
console.log(`[測試] 搜尋目標問題 ID: ${targetQuestionId}`);
// 2. 初始化客戶端
const gemini = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_SERVICE_KEY
);
try {
// 3. 將使用者回答轉換成查詢向量
console.log('[測試] 正在產生查詢向量...');
const response = await gemini.models.embedContent({
model: 'gemini-embedding-001',
contents: [userAnswer],
config: { outputDimensionality: 768 },
});
const queryEmbedding = response.embeddings[0].values;
console.log(`[測試] 查詢向量產生成功 (維度: ${queryEmbedding.length})`);
// 4. 呼叫資料庫函式進行搜尋!
console.log('[測試] 正在呼叫 Supabase 資料庫函式 "match_documents"...');
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding, // 傳入我們的查詢向量
p_question_id: targetQuestionId, // 【關鍵】傳入我們要搜尋的問題 ID
match_threshold: 0.7, // 設定一個合理的相似度門檻
match_count: 5, // 最多找 5 筆
});
if (error) {
throw new Error(`RPC 呼叫失敗: ${error.message}`);
}
console.log('✅ 成功從資料庫中檢索到以下相關資料:');
if (data && data.length > 0) {
console.table(data);
} else {
console.log('找不到任何相關資料,可以試著調低 match_threshold 看看。');
}
} catch (error) {
console.error('測試過程中發生錯誤:', error.message);
}
}
testSearch();
執行這個腳本:
node scripts/test-search.js
如果一切順利,你應該會在 console 中看到一個漂亮的表格,像是這樣的輸出結果:
[測試] 使用者回答: "變數宣告會被拉到程式碼最上面,但賦值會留在原地"
[測試] 搜尋目標問題 ID: js-con-001
[測試] 正在產生查詢向量...
[測試] 查詢向量產生成功 (維度: 768)
[測試] 正在呼叫 Supabase 資料庫函式 "match_documents"...
✅ 成功從資料庫中檢索到以下相關資料:
┌─────────┬────┬────────────────────────────────────────────────────────────────────────────────┬───────────────────┐
│ (index) │ id │ content │ similarity │
├─────────┼────┼────────────────────────────────────────────────────────────────────────────────┼───────────────────┤
│ 0 │ 3 │ '只有宣告被提升,賦值不會' │ 0.853378674637268 │
│ 1 │ 2 │ '變數和函數宣告會被提升到其作用域的頂部' │ 0.843688937046709 │
│ 2 │ 4 │ 'let 和 const 也有 hoisting,但因存在暫時性死區 (TDZ),在宣告前存取會拋出錯誤' │ 0.71679967555255 │
└─────────┴────┴────────────────────────────────────────────────────────────────────────────────┴───────────────────┘
裡面列出的 content 全部都是 關於 hoisting (js-con-001) 的 keyPoints
,並按照相似度分數由高到低排序。這證明了我們的搜尋引擎不僅能運作,而且非常精準!未來概念問題測驗時,只要將使用者的回答與向量資料庫比對,我們就可以模擬語意理解的行為,讓 AI 更好判斷使用者的回答是否真的有打到要點上,而不是靠模糊的比對甚至猜測!
今天我們完成了 RAG 流程中至關重要的一步,為我們的 AI 大腦裝上了高效且精準的「記憶檢索系統」。
✅ 我們學會了如何批量地將知識向量化並存入資料庫。
✅ 我們掌握了如何利用資料庫函式,將繁重的計算任務交給資料庫處理。
✅ 我們成功打造了一個能進行精準語意搜尋的函式,確保了檢索結果的相關性。
✅ 我們完成了 RAG 藍圖中,最核心的「R (Retrieval)」步驟!
檢索 (Retrieval) 的問題解決了,我們的後端現在有能力找出最相關的參考資料。下一步,就是將這些資料與使用者的回答組合起來,交給 AI 進行「生成 (Generation)」,並將結果即時地回傳給使用者,這樣大致上概念問題的回答已經可以處理的八九不離十了,剩下串接與測試而已。
明天 (Day 12),我們會將目光移開 Supabase 與向量相關的東西,我們要開始做程式碼執行的基本處理,未來兩天我們需要理解怎麼讓使用者的程式碼安全的執行,我們又該怎麼判斷他是否正確並給出回饋!
我們明天見!
今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-11
Supabase 官方文件 - Database Function
Supabase 官方文件 - Edge Functions