iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0

前言

歡迎來到第十一天!昨天我們跨出了巨大的一步:成功將第一個知識點 (keyPoint) 轉化為向量,並存入了 Supabase 這個雲端知識庫,順便還嘴了一下 Google 的文件沒寫好。我們的 AI 終於有了一個可以長期儲存記憶的地方!
但只有儲存是不夠的,一樣回到圖書查詢的例子,這就像是我們建立了一座宏偉的圖書館塞滿了書,卻還沒有任何有效率的搜尋方式,如果管理員只會把整排書架的書都搬出來,讓讀者自己一本本翻,那這座圖書館的體驗肯定糟透了,圖書管理員大概率隔天就不幹了。我們需要教會他如何根據讀者的問題,快速、準確地找出相關的書籍。

在我們開始打造聰明的「圖書館管理員」之前,有兩件前置作業必須完成:

  1. 充實我們的圖書館:Day 10 的腳本一次只能上一本書,效率太低。我們需要一個「批量上傳」的腳本,將 questions.json 中所有的知識點一次性上架。

  2. 確保搜尋的精準度:我們的搜尋必須是精準的,當使用者在回答「JavaScript」的問題時,我們不希望撈到「React」的參考資料。

完成這兩項準備後,我們就能正式打造 RAG 流程中最核心的武器——Supabase 資料庫函式,一個能實現高效語意搜尋的超級引擎。

今日目標

  • 撰寫一個批量處理腳本,將所有 keyPoints 向量化並存入 Supabase。

  • 學習如何使用 pgvector 的 <=> 運算子,達到我們之前手刻的餘弦相似函數更好的效果。

  • 設計並建立一個精準的 SQL 資料庫函式,能根據特定問題 ID 進行向量搜尋。

  • 撰寫測試腳本,驗證我們的搜尋函式能準確地從完整的知識庫中,檢索出最相關的資訊。

Step 1: 充實我們的知識庫 - 批量上傳腳本

首先,讓我們來解決 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();

程式碼摘要說明:

  1. 我們在開始前先清空了 documents 表,確保每次執行腳本都是從乾淨的狀態開始,方便重複測試。
  2. 我們將所有 keyPoints 分批 (chunk) 處理,並利用 embedContent API 可以一次處理多個字串的特性,減少了請求的次數免得我們不小心超過免費流量額度。
  3. 我們在取出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
圖1 :批量資料新增成功畫面

Step 2: 撰寫「精準版」的 SQL 資料庫函式

知識庫準備就緒,現在來打造我們的搜尋引擎。我們要建立一個 SQL 函式,它不僅要能搜尋向量,還要能根據我們指定的 question_id 來過濾,實現精準打擊。

不過,該做的說明還是要做,到底什麼是 資料庫函式, Supabase列表中還有個 Edge Functions 這又是什麼? 是一樣東西嗎? 問得好!作為一個前端仔你不知道也是情有可原的!馬上根據 Supabase 的文獻總結以下兩者的差異,請參考以下的表格快速了解兩者的定義吧!

Database Functions vs Edge Functions 比較

特性 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;
$$;

語法解說:

  1. <=> 運算子:這是 pgvector 提供的核心武器,用來計算兩個向量之間的「餘弦距離」。距離越小(越接近 0),代表向量在方向上越相似。
  2. 1 - (...) as similarity:我們巧妙地用 1 減去餘弦距離,把它轉換成我們更熟悉的「相似度分數」(分數越高越相似),這樣 match_threshold 的設定就更直觀了。
  3. where documents.question_id = p_question_id:這就是精準搜尋的秘密武器!它確保我們的語意搜尋只會在指定問題的 keyPoints 範圍內進行。

點擊「RUN」執行,應該會在下方的視窗看到 Success 的字眼!我們的 AI 大腦就學會了第一個技能:精準回憶!

切到 Database => Functions 的頁面,檢查一下是否有這個新建立的函數,有看到下圖的畫面就大功告成囉!

圖2
圖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」。這次執行可能會花幾秒鐘,因為資料庫正在為我們現有的所有資料建立索引。完成後,我們的搜尋引擎就正式準備就緒了!

語法解說:

第一段:

  • create index if not exists documents_embedding_hnsw
    • 建立一個叫 documents_embedding_hnsw 的索引。
    • if not exists:如果已經建過就不報錯、直接略過(教學/部署最安全)。
  • on public.documents
    • 要在哪張表上建立?→ 在 public schema 的 documents 表。
  • using hnsw (embedding vector_cosine_ops)
    • 索引的演算法用 HNSW(Hierarchical Navigable Small World),這是針對向量最近鄰搜尋的高效資料結構。
    • 括號內指定「哪個欄位、用哪種相似度」:
      • embedding:你那個 vector(768) 欄位。
      • vector_cosine_ops:告訴資料庫此索引用餘弦相似度(跟你查詢裡的 <=> 距離一致)。

第二段:

  • create index if not exists documents_question_id_idx
    • 建立一個叫 documents_question_id_idx 的索引(同樣「已存在就略過」)。
  • on public.documents (question_id)
    • 在 documents 表的 question_id 欄位上建立預設的 B-Tree 索引。

綜合起來就是:

  1. HNSW + cosine 索引:讓「找最像的向量」從「全表掃描」變「走捷徑」。
  2. question_id 索引:讓「只看這一題的資料」這個範圍過濾超快。
  3. 兩者搭配:先縮小範圍,再在小範圍內做近似搜尋,速度飛起來。

補充說明:

在目前資料量極少的情況下,坦白講全表搜尋還是會比索引搜尋快速,你實際執行搜尋時不用到索引的可能性也很高!但未來或是一個真正上線的產品不可能只有這點資料量,因此雖然我也只是這方面的菜雞,我也還是想盡可能的提一下並作基礎的示範。

Step 3: 測試我們的「精準」搜尋引擎

函式建好了,必須立刻測試它。我們來寫一個新的測試腳本,驗證它是否真的能做到精準搜尋。

在 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


上一篇
為 AI 建立長期記憶:Supabase 向量資料庫實戰
下一篇
為 AI 裝上裁判之眼:初探 Judge0 與安全後端代理
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言