本篇要解決的問題
之前就一直想試試看 RAG(Retrieval-Augmented Generation) 要怎麼應用在實際場景,但卡在自建 RAG 架構的門檻不低(像是要處理向量資料庫、轉檔、索引……bla bla bla),又想說成本會不會很高?直到這幾天發現了 Google 有 Vertex AI Search 這功能,讓 Gemini 判斷了一下可能成本,發現,意外的蠻便宜的耶,就決定來應用一下。
簡單說明 RAG(以下為 Gemini 提供):
RAG 就像是為 AI 掛載了一份「即時參考資料」。它並非單一資料庫,而是包含三個核心動作,而 Vertex AI Search 已經把這些複雜的底層邏輯都封裝好了:
- 檢索(Retrieval):當使用者問問題時,系統去你的網站(資料庫)找出最相關的幾段文字。
- 增強(Augmented):把這些找出來的文字,連同原本的問題,一起塞進給 AI 的指令(Prompt)裡。
- 生成(Generation):AI 閱讀了你給它的「臨時參考資料」,最後產出正確的回答。
因為本站重點都在實作,因此本篇的使用情境設定如下:
當網站內容累積到一定程度後,傳統的關鍵字搜尋往往無法精準回應使用者的需求。透過 Vertex AI Search,可以實作 RAG 架構,讓站內搜尋不只是「找關鍵字」,而是能「閱讀」我們的網站內容並整理出答案。
而在打不過 AI 就加入它的時代,我們可以達成:
- 自然語言提問:使用者問「怎麼實作 code review」,AI 能從多篇文章中彙整答案。
- 低開發成本:不需自行維護向量資料庫 (Vector DB) 或處理複雜的資料清洗。
- 高精準度:結合 Google 的語意搜尋與生成能力。
本篇實作成果,已經放上了首頁,以及每篇筆記文的文末,會看到一個「Vertex AI 搜尋」的輸入框,大家可以試一試喔,覺得好用或不好用,都歡迎留言。
建立 Google Vertex AI Search (RAG) 實作流程:資料匯入
首先,必須要有一個 Google Cloud 的帳號,有了帳號後新增專案,而這個專案必須是「付費帳戶」,就是要付 $$ 的。
有了一個付費帳戶的專案,接著開始以下幾個步驟。
- 進入 Vertex AI 後台:從 Google Cloud 控制台進入 Vertex AI 介面。
- 啟用 API:點擊「啟用所有建議的 API」以確保功能完整。
因為啟用不用 $$,之後有使用到才要,為了防止後續步驟陣亡的莫名其妙,這邊就給它全部打開。
- 選擇應用程式類型:點擊「運用 AI 模式打造站內搜尋服務」。
- 填寫應用程式資訊:輸入名稱並選取區域(建議選 global)。
- 建立資料儲存庫 (Data Store):點擊建立按鈕。
- 選取資料來源:選擇「網站內容」。
因為本篇是應用在站內搜尋,因此是選「網站內容」,如果大家使用時目的不同,可以詢問 AI 用哪一個省成本。
-
指定索引路徑:輸入網址模式,如
www.letswrite.tw/*。
「自動檢索網址並持續更新」:建議打勾,之後網站有更新,Vertex 就會自動更新。
- 啟用強化功能:設定儲存庫名稱並建議勾選「文件處理設定」。
下面截圖中的選項要不要勾看網站的內容,一樣可以跟 AI 討論是否都勾。
- 選取計費模式:初期選擇「一般計費模式」。
- 連結 Data Store:勾選剛建好的儲存庫並點擊繼續。
- 監控進度:在資料頁面查看「正在建立初始索引」。
- 增加 Sitemap:手動新增 Sitemap 網址以確保文章完整收錄。
- 索引完成:當狀態顯示「初始索引建立完成」時,可以看到目前文件的總體積與數量(本站約 340 MB,共 696 份文件)。
將 Vertex AI Search 放上網站
- 調整回覆風格:在「設定」中將搜尋類型改為「搜尋答案」,並在操作說明加入「簡明扼要、不超過 100 字、繁體中文」等指令。
- 整合權限設定:選取「公開存取權」並填入允許的網域。
實作: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>
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);
})();
成本分析預估
基於目前的網站規模(約 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)