DEV Community

Cover image for 站內搜尋加上 AI:使用 Google Vertex AI Search(RAG)打造智慧問答型搜尋
Let's Write
Let's Write

Posted on • Originally published at letswrite.tw

站內搜尋加上 AI:使用 Google Vertex AI Search(RAG)打造智慧問答型搜尋

本篇要解決的問題

之前就一直想試試看 RAG(Retrieval-Augmented Generation) 要怎麼應用在實際場景,但卡在自建 RAG 架構的門檻不低(像是要處理向量資料庫、轉檔、索引……bla bla bla),又想說成本會不會很高?直到這幾天發現了 Google 有 Vertex AI Search 這功能,讓 Gemini 判斷了一下可能成本,發現,意外的蠻便宜的耶,就決定來應用一下。

簡單說明 RAG(以下為 Gemini 提供):

RAG 就像是為 AI 掛載了一份「即時參考資料」。它並非單一資料庫,而是包含三個核心動作,而 Vertex AI Search 已經把這些複雜的底層邏輯都封裝好了:

  1. 檢索(Retrieval):當使用者問問題時,系統去你的網站(資料庫)找出最相關的幾段文字。
  2. 增強(Augmented):把這些找出來的文字,連同原本的問題,一起塞進給 AI 的指令(Prompt)裡。
  3. 生成(Generation):AI 閱讀了你給它的「臨時參考資料」,最後產出正確的回答。

因為本站重點都在實作,因此本篇的使用情境設定如下:

當網站內容累積到一定程度後,傳統的關鍵字搜尋往往無法精準回應使用者的需求。透過 Vertex AI Search,可以實作 RAG 架構,讓站內搜尋不只是「找關鍵字」,而是能「閱讀」我們的網站內容並整理出答案。

而在打不過 AI 就加入它的時代,我們可以達成:

  • 自然語言提問:使用者問「怎麼實作 code review」,AI 能從多篇文章中彙整答案。
  • 低開發成本:不需自行維護向量資料庫 (Vector DB) 或處理複雜的資料清洗。
  • 高精準度:結合 Google 的語意搜尋與生成能力。

本篇實作成果,已經放上了首頁,以及每篇筆記文的文末,會看到一個「Vertex AI 搜尋」的輸入框,大家可以試一試喔,覺得好用或不好用,都歡迎留言。


建立 Google Vertex AI Search (RAG) 實作流程:資料匯入

首先,必須要有一個 Google Cloud 的帳號,有了帳號後新增專案,而這個專案必須是「付費帳戶」,就是要付 $$ 的。

有了一個付費帳戶的專案,接著開始以下幾個步驟。

  1. 進入 Vertex AI 後台:從 Google Cloud 控制台進入 Vertex AI 介面。

Google Cloud Vertex AI 控制台首頁介面

  1. 啟用 API:點擊「啟用所有建議的 API」以確保功能完整。

因為啟用不用 $$,之後有使用到才要,為了防止後續步驟陣亡的莫名其妙,這邊就給它全部打開。

啟用 Vertex AI 相關建議 API 畫面

  1. 選擇應用程式類型:點擊「運用 AI 模式打造站內搜尋服務」。

選擇 Vertex AI Search 搜尋應用程式類型

  1. 填寫應用程式資訊:輸入名稱並選取區域(建議選 global)。

填寫應用程式基本資訊與地理位置

  1. 建立資料儲存庫 (Data Store):點擊建立按鈕。

點擊建立資料儲存庫按鈕

  1. 選取資料來源:選擇「網站內容」。

因為本篇是應用在站內搜尋,因此是選「網站內容」,如果大家使用時目的不同,可以詢問 AI 用哪一個省成本。

選擇資料來源為網站內容

  1. 指定索引路徑:輸入網址模式,如 www.letswrite.tw/*

「自動檢索網址並持續更新」:建議打勾,之後網站有更新,Vertex 就會自動更新。

設定要建立索引的網址路徑模式

  1. 啟用強化功能:設定儲存庫名稱並建議勾選「文件處理設定」。

下面截圖中的選項要不要勾看網站的內容,一樣可以跟 AI 討論是否都勾。

設定資料儲存庫名稱與啟用文件處理設定

  1. 選取計費模式:初期選擇「一般計費模式」。

選擇搜尋應用程式的計費模式

  1. 連結 Data Store:勾選剛建好的儲存庫並點擊繼續。

將資料儲存庫連結至搜尋應用程式

  1. 監控進度:在資料頁面查看「正在建立初始索引」。

在後台查看初始索引建立進度

  1. 增加 Sitemap:手動新增 Sitemap 網址以確保文章完整收錄。

手動新增 Sitemap 網址以加速索引

  1. 索引完成:當狀態顯示「初始索引建立完成」時,可以看到目前文件的總體積與數量(本站約 340 MB,共 696 份文件)。

資料索引完成狀態,顯示 696 份文件與 339.84 MiB


將 Vertex AI Search 放上網站

  1. 調整回覆風格:在「設定」中將搜尋類型改為「搜尋答案」,並在操作說明加入「簡明扼要、不超過 100 字、繁體中文」等指令。

調整搜尋小工具 UI 設定為搜尋答案,並輸入自訂操作說明

  1. 整合權限設定:選取「公開存取權」並填入允許的網域。

設定公開存取網域並複製搜尋小工具程式碼


實作:iframe 與加到 WordPress

本站是用 WordPress 架站的,照著上圖直接照放程式碼,發現樣式會被影響,因此把 Vertex Search 的頁面做一個 HTML 檔案,然後在 WordPress 上嵌入。

iframe.html

我們將 Widget 放在新增的 HTML 檔案中,並處理 Shadow DOM 的 Enter 事件以動態撐開高度。

<!doctype html>
<html lang="zh-TW">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vertex AI Search - Let's Write</title>
    <style>
      body {
        margin: 0;
        padding: 0;
        width: 100%;
        font-family: sans-serif;
      }
      #searchWidgetTrigger {
        width: 100%;
        padding: 12px;
        box-sizing: border-box;
        border: 1px solid #ccc;
        border-radius: 8px;
        -webkit-appearance: none; /* 移除 iOS 預設樣式 */
      }
      gen-search-widget {
        display: block;
        margin-top: 10px;
      }
    </style>
  </head>
  <body>
    <input id="searchWidgetTrigger" inputmode="search" />

    <gen-search-widget
      configId="9a35bc9d-f398-4b2b-bfeb-28091c39e3f6"
      triggerId="searchWidgetTrigger"
      anchorsTarget="_blank"
      alwaysOpened
      placeholder="Vertex AI 搜尋"
    ></gen-search-widget>

    <script src="https://cloud.google.com/ai/gen-app-builder/client?hl=zh_TW"></script>

    <script>
      const widget = document.querySelector("gen-search-widget");
      const searchInput = document.getElementById("searchWidgetTrigger");
      let triggered = false;

      // 處理 Enter 按下時的邏輯
      const onEnterPressed = () => {
        // 防止重複觸發
        if (triggered) return;
        triggered = true;

        // 通知父頁面調整 iframe 高度
        window.parent.postMessage(
          { type: "setHeight", height: 500 },
          "https://www.letswrite.tw",
        );

        // 2 秒後重置觸發狀態,允許下次搜尋
        setTimeout(() => {
          triggered = false;
        }, 2000);
      };

      // 深度遍歷 shadow DOM,為所有 input 和 button 添加事件監聽
      const setupDeepListener = (root, depth = 0) => {
        // 防止無限遞迴
        if (depth > 5) return;

        const inputs = root.querySelectorAll("input");
        const buttons = root.querySelectorAll("button");

        // 為所有 input 元素添加事件監聽
        inputs.forEach((input) => {
          // 避免重複添加監聽器
          if (input.dataset.listenerAdded) return;
          input.dataset.listenerAdded = "true";

          // 桌機:Enter 鍵 (keyup)
          input.addEventListener("keyup", (e) => {
            if (e.key === "Enter" || e.keyCode === 13) {
              onEnterPressed();
            }
          });

          // 手機:Enter 鍵 (keypress)
          input.addEventListener("keypress", (e) => {
            if (e.key === "Enter" || e.keyCode === 13) {
              onEnterPressed();
            }
          });

          // 手機:輸入變化時觸發 (失焦或完成輸入)
          input.addEventListener("change", () => {
            setTimeout(onEnterPressed, 100);
          });
        });

        // 為所有 button 元素添加點擊監聽 (手機端主要觸發方式)
        buttons.forEach((button) => {
          if (button.dataset.listenerAdded) return;
          button.dataset.listenerAdded = "true";

          button.addEventListener("click", () => {
            onEnterPressed();
          });
        });

        // 遞迴檢查嵌套的 shadow DOM
        root.querySelectorAll("*").forEach((el) => {
          if (el.shadowRoot) {
            setupDeepListener(el.shadowRoot, depth + 1);
          }
        });
      };

      // 設置 shadow DOM 監聽器
      const setupShadowListener = () => {
        try {
          const shadow = widget.shadowRoot;
          if (!shadow) return false;

          // 初始化深度監聽
          setupDeepListener(shadow);

          // 使用 MutationObserver 監聽動態添加的元素
          const observer = new MutationObserver((mutations) => {
            mutations.forEach((mutation) => {
              mutation.addedNodes.forEach((node) => {
                if (node.nodeType === 1) {
                  // 如果新增節點有 shadow root,遞迴設置監聽
                  if (node.shadowRoot) {
                    setupDeepListener(node.shadowRoot);
                  }
                  // 如果新增 input 或 button,重新掃描整個 shadow DOM
                  if (node.tagName === "INPUT" || node.tagName === "BUTTON") {
                    setupDeepListener(shadow);
                  }
                }
              });
            });
          });

          observer.observe(shadow, {
            childList: true,
            subtree: true,
          });

          return true;
        } catch (e) {
          return false;
        }
      };

      // 等待 widget 定義並設置監聽
      customElements.whenDefined("gen-search-widget").then(() => {
        setTimeout(() => {
          // 每 500ms 嘗試設置監聽,直到成功
          const interval = setInterval(() => {
            if (setupShadowListener()) {
              clearInterval(interval);
            }
          }, 500);

          // 15 秒後停止嘗試
          setTimeout(() => clearInterval(interval), 15000);
        }, 1000);
      });

      // 外層 input 的 Enter 鍵監聽 (keyup)
      searchInput.addEventListener("keyup", (e) => {
        if (e.key === "Enter" || e.keyCode === 13) {
          onEnterPressed();
        }
      });

      // 外層 input 的 Enter 鍵監聽 (keypress)
      searchInput.addEventListener("keypress", (e) => {
        if (e.key === "Enter" || e.keyCode === 13) {
          onEnterPressed();
        }
      });

      // 監聽整個文檔的 Enter 鍵 (keyup,捕獲階段)
      document.addEventListener(
        "keyup",
        (e) => {
          if (
            e.target.tagName === "INPUT" &&
            (e.key === "Enter" || e.keyCode === 13)
          ) {
            onEnterPressed();
          }
        },
        true,
      );

      // 監聽整個文檔的 Enter 鍵 (keypress 捕獲階段)
      document.addEventListener(
        "keypress",
        (e) => {
          if (
            e.target.tagName === "INPUT" &&
            (e.key === "Enter" || e.keyCode === 13)
          ) {
            onEnterPressed();
          }
        },
        true,
      );

      // 監聽所有按鈕點擊 (捕獲階段 主要針對手機端)
      document.addEventListener(
        "click",
        (e) => {
          if (e.target.tagName === "BUTTON" || e.target.closest("button")) {
            setTimeout(onEnterPressed, 100);
          }
        },
        true,
      );
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

WordPress 裡嵌入 iframe 頁面

透過 WP Code 等插件,可以選擇在標題上方,或各文章頁底部自動插入 iframe。

以下是範例。

(function() {
    const target = document.querySelector('.bwp-section-header-separator');
    if (target && !document.getElementById('vertexIframe')) {
        const htmlString = `
            <div class="vertex-ai-container" style="width: 100%; margin: 20px 0;">
                <iframe id="vertexIframe" 
                        src="/iframe.html" 
                        style="width: 100%; border: none; overflow: hidden; transition: height 0.3s ease; height: 85px;" 
                        scrolling="no">
                </iframe>
            </div>`;
        target.insertAdjacentHTML('beforebegin', htmlString);
    }

    window.addEventListener('message', function(event) {
        // 建議加入來源網域檢查(因 iframe 同源可省略,但若跨網域則必須)
        // if (event.origin !== "https://YOUR_IFRAME_DOMAIN") return;
        if (event.data && event.data.type === 'setHeight') {
            const iframe = document.getElementById('vertexIframe');
            if (iframe) {
                iframe.style.height = (event.data.height + 10) + 'px';
            }
        }
    }, false);
})();
Enter fullscreen mode Exit fullscreen mode

成本分析預估

基於目前的網站規模(約 700 份文件),August 預計在官網測試一個月多來評估,主要是成本考量。

以下是「Gemini 3 思考型」做的成本預估:

項目 估計單價 (USD) 說明
資料儲存 (Storage) 約 $1.00 / GB / 月 340 MB 體積產生的月費微乎其微。
企業版查詢 (Enterprise) $4.00 / 1,000 次查詢 包含 RAG 生成式回答與語意搜尋。
Gemini 2.5 Flash Token 依流量計費 由於 Flash 價格極低,主要成本集中在查詢次數費。

如果每月有 1,000 次有效查詢,預估成本約為 $4 ~ $10 美金

一個月 5 美金以下,就還可以接受,超過 5 美金……就要看本站能不能帶來一些收入了,不然一直造成支出也不是辦法。

Top comments (0)