iT邦幫忙

2025 iThome 鐵人賽

DAY 9
0

前言

歡迎來到第九天!昨天我們透過了解了 RAG 的基本概念並透過 Gemini Embedding API 實際看到了文字是怎麼轉為向量陣列的,我自己相當喜歡昨天文章的內容,雖然大大超出了我的舒適圈,但同時也帶給我相當新鮮的感覺,讀各種文獻的過程讓我想到當初寫論文的時光,算是出社會後久違的感覺!回到文章的話題,雖然我們將文字轉為一組768個神祕數字,但我們根本不知道該怎麼使用這個向量陣列,也不知道這個陣列是怎麼被用來判斷是否語意相近,一知半解的感覺挺差的,我們今天就要學習如何利用這些向量陣列在語意上有多們接近,並實際撰寫相關的函數讓你親眼見證,我真的是比較傾向實際上看到成果的學習者,總認為這樣印象會更為深刻!

開始前先做個友情提示,今天會稍微講到一點點數學的東西,但你都願意寫 code了,拜託不要看到數學算式就跑走!就只有一點點點點數學罷了,稍微了解一些底層的原理對你我都有幫助的。

今日目標

  • 理解如何用「餘弦相似度」量化兩個句子的語意相似度。
  • 親手撰寫一個 JavaScript 函數來計算餘弦相似度,揭開其神秘面紗。
  • 建立一個獨立的測試腳本,結合 Gemini Embedding API 與我們的計算函數,親眼見證語意搜尋的魔力。
  • 看懂完整的 RAG 流程圖,為明天的資料庫實戰建立清晰的藍圖。

核心問題:如何「比較」向量?

我們在 Day 8 產生了「變數提升」和「Hoisting」的向量。我們肉眼看得出它們意思相近,但程式要怎麼「知道」它們很近?
答案就是計算它們在 768 維語意空間中的「方向」有多相似。而用來衡量這個方向相似度的最常用數學工具,就是餘弦相似度 (Cosine Similarity)。想像一下你想推薦電影給朋友,要怎麼知道他會不會喜歡?你可以比較你們對不同電影類型的喜好程度。
假設我們用三個數字代表對「科幻、喜劇、愛情」三種類型的喜好度(-5 到 5分):

你的喜好向量:[5, 2, -3] (超愛科幻、喜歡喜劇、討厭愛情)

朋友A的向量:[4, 3, -4] (品味跟你很像!)

朋友B的向量:[-4, -2, 5] (品味跟你完全相反)

朋友C的向量:[-1, 5, 1] (跟你沒啥共同點,只愛喜劇)

餘弦相似度衡量的不是你們喜歡的「程度」有多強,而是你們喜好的「模式」或「方向」有多像。

  • 你和朋友A:喜好模式非常接近(科幻和喜劇都是正分、愛情是負分)。你們的向量在「喜好空間」中指向非常相似的方向,夾角很小,所以餘弦相似度會接近 1。
  • 你和朋友B:喜好模式完全相反。你們的向量指向相反的方向,夾角接近 180 度,餘弦相似度會接近 -1。
  • 你和朋友C:喜好模式幾乎無關。你們的向量方向幾乎是垂直的(90度),餘弦相似度會接近 0。
    Embedding 模型做的事情一樣,只是它用了 768 個更抽象的「語意特徵」來取代電影類型。
    因此,RAG 框架中「R」(Retrieval) 這一步的本質,就是找出與使用者問題向量夾角最小(也就是餘弦相似度分數最高)的那些知識點向量,之後再根據你設置的標準,將符合你設置門檻。

完整 RAG 流程藍圖

在我們一頭栽進程式碼之前,先來看一張藍圖。這張圖涵蓋了我們今天(Day 9)和明天(Day 10)要完成的完整 RAG 流程,包含了「事前準備」和「即時查詢」兩個階段:

graph TD
    subgraph prep["事前準備:建立向量索引"]
        A[我們的知識庫<br/>data/questions.json] --> B[遍歷每個 KeyPoint]
        B --> C[Gemini Embedding API]
        C --> D[產生語意向量]
        D --> E[Supabase<br/>pgvector 資料庫]
        E --> F[儲存 KeyPoint 與其向量]
    end
    
    subgraph query["即時查詢:RAG 核心流程"]
        G[使用者提交答案] --> H[Next.js API Route]
        H --> I[1.將使用者答案<br/>轉換成查詢向量]
        I --> C2[Gemini Embedding API]
        C2 --> J[2.拿查詢向量到資料庫<br/>進行相似度搜尋]
        J --> E2[Supabase<br/>pgvector 資料庫]
        E2 -->|回傳最相關的 KeyPoints| K[3.組合最終 Prompt]
        K -->|問題 + 答案 + 相關KeyPoints| L[Gemini LLM]
        L -->|產生更精準的回饋| M[回傳結果給前端]
    end
    
    style E fill:#f9f,stroke:#333,stroke-width:2px
    style E2 fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#bbf,stroke:#333,stroke-width:2px
    style C2 fill:#bbf,stroke:#333,stroke-width:2px
    style L fill:#bfb,stroke:#333,stroke-width:2px

從圖中可以看到,整個系統分為兩大部分:

  • 事前準備 (One-time Setup): 這部分我們會在 Day 10 實作。我們會先將題庫 questions.json中所有的 keyPoints 取出,透過 Gemini Embedding API 將它們全部轉換成向量,然後存進 Supabase 這種專門的向量資料庫。這就像是預先為圖書館裡的所有書建立好索引卡。
  • 即時查詢 (Real-time Query): 這是我們今天的重點!當使用者提交答案後,我們會將其答案轉換成一個「查詢向量」,然後拿這個向量去資料庫裡找出語意最接近的 keyPoints,最後把這些精選的資料餵給 AI 。

好,藍圖已經非常清楚了。現在,讓我們動手來實作一個能證明這一切的測試腳本吧!

小試身手:親手實現語意搜尋

我們要建立一個獨立的 Node.js 腳本,它會呼叫 Gemini API 產生向量,然後用我們自己寫的函式來計算相似度,讓你徹底搞懂背後的原理。

Step 1: 建立 test-similarity.js

在你的專案根目錄下,建立一個新檔案 test-similarity.js

Step 2: 撰寫餘弦相似度函數

首先,讓我們來揭開餘弦相似度的神秘面紗。它背後的數學原理其實一點都不困難,我們先看一下數學公式本身。

$$
\cos(\theta) = \frac{\mathbf{A} \cdot \mathbf{B}}{||\mathbf{A}|| \times ||\mathbf{B}||} = \frac{\sum_{i=1}^{n} A_i \times B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \times \sqrt{\sum_{i=1}^{n} B_i^2}}
$$

所以實際上就是把每組向量兩兩相乘累加,然後去除以兩者的距離累加值就行了,這完全可以用基礎的 JavaScript 寫出來。將以下程式碼貼到你的 test-similarity.js 中。

/**
 * 計算兩個向量之間的餘弦相似度
 * @param {number[]} vecA - 第一個向量 (一個數字陣列)
 * @param {number[]} vecB - 第二個向量 (一個數字陣列)
 * @returns {number} - 介於 -1 和 1 之間的相似度分數
 */
function cosineSimilarity(vecA, vecB) {
  // 步驟 0: 確認兩個向量長度相同,否則無法比較
  if (vecA.length !== vecB.length) {
    throw new Error('Vectors must be of the same length');
  }

  // 步驟 1: 計算點積 (Dot Product)
  // A · B = Σ (A[i] * B[i])
  let dotProduct = 0.0;
  for (let i = 0; i < vecA.length; i++) {
    dotProduct += vecA[i] * vecB[i];
  }

  // 步驟 2: 計算每個向量的範數 (Norm)
  // ||A|| = sqrt(Σ A[i]^2)
  let normA = 0.0;
  let normB = 0.0;
  for (let i = 0; i < vecA.length; i++) {
    normA += vecA[i] * vecA[i];
    normB += vecB[i] * vecB[i];
  }
  normA = Math.sqrt(normA);
  normB = Math.sqrt(normB);

  // 步驟 3: 計算餘弦相似度
  // Cosine Similarity = (A · B) / (||A|| * ||B||)
  if (normA === 0 || normB === 0) {
    return 0; // 避免除以零的錯誤
  }
  
  return dotProduct / (normA * normB);
}

你看,一點都不複雜吧!就算不靠 AI 寫也是輕輕鬆鬆,雖然這種被驗證已久的公式交給 AI 寫肯定是更快更好就是了!

Step 3: 結合 Gemini API 進行測試

接著,在同一個檔案下方,加入呼叫 Gemini API 並執行比較的程式碼。

import { GoogleGenAI } from '@google/genai';

const apiKey = '你的API Key';
if (!apiKey) {
  throw new Error('GEMINI_API_KEY is not set');
}
const ai = new GoogleGenAI({ apiKey });

function cosineSimilarity(vecA, vecB) {
  // 步驟 0: 確認兩個向量長度相同,否則無法比較
  if (vecA.length !== vecB.length) {
    throw new Error('Vectors must be of the same length');
  }

  // 步驟 1: 計算點積 (Dot Product)
  // A · B = Σ (A[i] * B[i])
  let dotProduct = 0.0;
  for (let i = 0; i < vecA.length; i++) {
    dotProduct += vecA[i] * vecB[i];
  }

  // 步驟 2: 計算每個向量的範數 (Norm)
  // ||A|| = sqrt(Σ A[i]^2)
  let normA = 0.0;
  let normB = 0.0;
  for (let i = 0; i < vecA.length; i++) {
    normA += vecA[i] * vecA[i];
    normB += vecB[i] * vecB[i];
  }
  normA = Math.sqrt(normA);
  normB = Math.sqrt(normB);

  // 步驟 3: 計算餘弦相似度
  // Cosine Similarity = (A · B) / (||A|| * ||B||)
  if (normA === 0 || normB === 0) {
    return 0; // 避免除以零的錯誤
  }

  return dotProduct / (normA * normB);
}

// 執行我們的測試
async function run() {
  console.log('--- 語意相似度測試 ---');

  // 我們要比較的詞彙:包含相關和不相關的概念
  const texts = [
    '變數提升', // 查詢目標
    'Hoisting', // 語意極度相似
    '閉包 (Closure)', // 相關但不同
    '非同步 (Async)', // 相關但更遠
    'CSS 樣式', // 不太相關
  ];

  console.log(`正在為 [${texts.join(', ')}] 產生 Embeddings...`);

  try {
    const response = await ai.models.embedContent({
      model: 'gemini-embedding-001',
      contents: texts,
    });

    const embeddings = response.embeddings;

    console.log('\n--- 計算相似度分數 ---');
    // 使用巢狀迴圈,比較每對詞彙的相似度
    for (let i = 0; i < texts.length; i++) {
      for (let j = i + 1; j < texts.length; j++) {
        const vecA = embeddings[i].values;
        const vecB = embeddings[j].values;

        const score = cosineSimilarity(vecA, vecB);

        console.log(`'${texts[i]}' vs '${texts[j]}': ${score.toFixed(4)}`);
      }
    }
  } catch (error) {
    console.error('產生 Embedding 時出錯:', error);
  }
}

run();

Step 4: 執行並分析結果

打開你的終端機,執行這個腳本:

node test-similarity.js

稍待片刻,你應該會看到類似這樣的輸出:

--- 語意相似度測試 ---
正在為 [變數提升, Hoisting, 閉包 (Closure), 非同步 (Async), CSS 樣式] 產生 Embeddings...

--- 計算相似度分數 ---
'變數提升' vs 'Hoisting': 0.7367
'變數提升' vs '閉包 (Closure)': 0.6874
'變數提升' vs '非同步 (Async)': 0.6091
'變數提升' vs 'CSS 樣式': 0.5996
'Hoisting' vs '閉包 (Closure)': 0.5960
'Hoisting' vs '非同步 (Async)': 0.5306
'Hoisting' vs 'CSS 樣式': 0.5470
'閉包 (Closure)' vs '非同步 (Async)': 0.6460
'閉包 (Closure)' vs 'CSS 樣式': 0.5994
'非同步 (Async)' vs 'CSS 樣式': 0.6338

你可以發現語意接近的字確實有著比較高的相似度分數,比方說變數提升跟Hoisting有著較高的分數、Hoisting跟CSS 樣式則沒有,根據你用的模型與維度設置這個值還會再有更明顯的差異!這個簡單的腳本,讓我們親手驗證了語意可以被量化這件事。

補充說明:

眼尖的讀者可能有發現我們今天使用的向量模型突然變成gemini-embedding-001了,這是官方網站上所列出目前最新的模型,昨天用的text-embedding-004理論上也是相當新的模型,但我今天在使用時發現他在某些中文字下得出的向量都變得完全一模一樣,我也不是很清楚為什麼,只好先換官方建議的模型,之後再回頭看一下問題在哪。

今日回顧

今天我們把抽象的理論變成了可以運行的程式碼,這是非常關鍵的一步。

✅ 我們理解了 RAG 檢索的核心是餘弦相似度。
✅ 我們親手寫出了 cosineSimilarity 函數,不再把它當成黑盒子。
✅ 我們透過一個獨立腳本,成功地將文字轉換成向量,並計算出它們的語意相似度分數。
✅ 我們看懂了完整的 RAG 流程圖,為下一步打下了堅實的基礎。

明天預告

今天我們實現了語意搜尋,但你可能也發現一個問題:每次要比較,我們都得重新把所有文字拿去產生一次 Embedding。如果我們的知識庫有上千條,這會變得非常慢且昂貴!我們需要一個更專業的「向量圖書館管理員」,它能預先把所有書的「語意座標」存好,並用超光速找到最近的書。

明天(Day 10),我們就要來認識這位管理員:Supabase pgvector 向量資料庫!我們將會設定好 Supabase 專案,並將我們的第一個向量存入雲端!

我們明天見!🚀


上一篇
AI 的開卷考試:初探 RAG 與 Embedding
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言