客戶開始問「你們網站有沒有 AI」了。
不是問你有沒有用 AI 設計網站——那個問題早在 2024 年就問完了。現在他們問的是:「網站本身有沒有 AI 功能?」彷彿網站是一個需要內建智慧的存在,而不是一張會呼吸的電子名片。
身為網頁設計師,我的第一個念頭是:我又不是 AI 工程師,這種需求要怎麼接?
但實際研究了一圈之後,我發現這件事沒有那麼難。2026 年的工具鏈已經足夠成熟,一個具備基本技術能力的網頁設計師,完全可以在一週內為客戶的網站加入「AI 語義搜尋」功能,而且不需要昂貴的 API 費用。
這篇文章是我的實戰紀錄,從需求拆解到代碼實作,全部是真的。
多數客戶說「想要 AI」,心裡想的是「想要一個聰明的搜尋框」。
這個期待是合理的。相比一個需要訓練和維護的聊天機器人,語義搜尋(Semantic Search)更容易落地:
更重要的是:對多數台灣中小企業來說,網站內的搜尋功能幾乎都是爛的。用 Google 自訂搜尋或 WordPress 預設搜尋,輸入「網站」找不到「官方網站」,輸入「費用」找不到「收費方式」。
與其說是加入 AI,不如說是把原本就該做好的搜尋功能做好。
在評估了幾種方案之後,我最終選擇了這個組合:
選擇的理由很樸實:
第一,Supabase 有免費額度。 每月 500MB 的資料庫空間,對多數小型網站來說綽綽有餘。pgvector 直接內建在 PostgreSQL 裡,不需要另外架設向量資料庫服務。
第二,Next.js 的 Server Actions 讓 API 路由變得極簡。 不需要寫獨立的 API route 檔案,直接在 Server Component 裡面執行資料庫查詢。
第三,Vercel 部署足夠簡單。 客戶的網站最終要部署,Vercel + Supabase 的組合在台灣的網路環境下延遲可接受。
當然,如果你對資料主權有更嚴格的要求,也可以把 Supabase 換成 self-hosted 的 PostgreSQL + pgvector,部署在自己的 VPS 上。
AI 搜尋的核心流程是這樣的:
內容 → 分塊(Chunking)→ 向量化(Embedding)→ 存入向量資料庫
使用者查詢 → 向量化 → 語義相似度搜尋 → 回傳結果
第一步是讓網站內容轉成向量。這段通常在網站後台管理介面處理,假設你已經有一個 CMS,流程如下:
// 假設這是你從 CMS 取出的文章資料
const documents = [
{
id: '1',
title: '網站設計服務方案',
content: '我們提供從零到上線的網站設計服務,包含 UI/UX 設計、前端開發、後端系統。',
url: '/services/web-design'
},
{
id: '2',
title: 'SEO 優化服務',
content: '協助客戶分析網站技術 SEO 問題,提供關鍵字研究與內容策略建議。',
url: '/services/seo'
}
];
// 使用 OpenAI 產生 Embedding
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
async function embedDocument(text) {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text
});
return response.data[0].embedding;
}
實務上,內容不會只有一兩篇——如果是知識庫可能有上百篇。這時候你需要一個任務佇列(job queue)來處理批次 embedding,避免 API rate limit。
Supabase 的 pgvector 擴充讓你可以在 PostgreSQL 裡直接做向量相似度搜尋。假設你的文章表格長這樣:
-- migrations/001_create_articles_table.sql
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE articles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
url TEXT NOT NULL,
embedding VECTOR(1536), -- text-embedding-3-small 的維度是 1536
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 建立索引,加速相似度搜尋
CREATE INDEX ON articles USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
注意到 embedding 欄位的型別是 VECTOR(1536) 嗎?這就是 pgvector 的魔法——它允許你在一個資料庫欄位裡儲存 1536 維的向量,然後用 SQL 直接做餘弦相似度(cosine similarity)搜尋。
// 將文件及其 embedding 存入資料庫
async function storeArticle(doc) {
const embedding = await embedDocument(doc.title + ' ' + doc.content);
await supabase.from('articles').insert({
title: doc.title,
content: doc.content,
url: doc.url,
embedding: embedding // pg 會自動處理 JS array → vector 轉換
});
}
最關鍵的部分來了——當使用者輸入一個查詢,我們要:
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
export async function POST(req: NextRequest) {
const { query } = await req.json();
// 將使用者查詢向量化
const queryEmbedding = await embedDocument(query);
// 在 pgvector 裡做語義相似度搜尋
const { data: results, error } = await supabase.rpc(
'match_articles',
{
query_embedding: queryEmbedding,
match_threshold: 0.7, // 相似度閾值
match_count: 5 // 最多回傳 5 筆結果
}
);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ results });
}
match_articles 是一個 Supabase RPC 函數,底層是這樣的 SQL:
CREATE OR REPLACE FUNCTION match_articles(
query_embedding VECTOR(1536),
match_threshold FLOAT,
match_count INT
)
RETURNS TABLE (
id UUID,
title TEXT,
content TEXT,
url TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
articles.id,
articles.title,
articles.content,
articles.url,
1 - (articles.embedding <=> query_embedding) AS similarity
FROM articles
WHERE 1 - (articles.embedding <=> query_embedding) > match_threshold
ORDER BY articles.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
<=> 是 pgvector 的「向量距離」運算子,$1 - (embedding <=> query_embedding) 就是把距離轉換成相似度分數(0 到 1)。
有了 API 之後,前端就是一個普通的 React 元件:
// components/AISearch.tsx
'use client';
import { useState } from 'react';
export default function AISearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
const res = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const data = await res.json();
setResults(data.results || []);
setLoading(false);
};
return (
<div>
<form onSubmit={handleSearch}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="輸入你想找的內容..."
style={{ width: '100%', padding: '12px', fontSize: '16px' }}
/>
<button type="submit" disabled={loading}>
{loading ? '搜尋中...' : '搜尋'}
</button>
</form>
{results.length > 0 && (
<ul>
{results.map((r) => (
<li key={r.id}>
<a href={r.url}>{r.title}</a>
<p>{r.content.substring(0, 120)}...</p>
</li>
))}
</ul>
)}
</div>
);
}
這段代碼沒有任何華麗的 UI——重點是讓你看到核心邏輯。實際專案裡,你可以加上 Highlight 關鍵字、相似度分數顯示,或是「找不到結果時推薦相關頁面」的 fallback 邏輯。
老實說,可以。
假設你對 Next.js 有基本了解,整個流程可以這樣安排:
當然,這是理想狀況。真實世界會有各種意外:pgvector 版本不相容、Embedding 模型費用比預期高、使用者輸入繁體中文時的向量空間表現不如預期……
多數 Embedding 模型對繁體中文的支援不如簡體中文或英文。在 text-embedding-3-small 上測試過,你會發現「網站設計」和「網站開發」的相似度很高,但「台北」和「台中」的相似度反而比預期低——這是因為在模型的訓練資料裡,繁體中文的語料相對少,導致向量空間的解析度不夠細。
解決方案有幾種:
第一,使用針對中文優化的模型。 比如 thenlper/gte-large-zh 或阿里巴巴的 m3e-large,對繁體中文的語意理解會更細緻。
第二,使用多語言模型並加強特定領域的微調。 如果你的網站是法律或醫療相關,這點特別重要。
第三,用 hybrid search 混合關鍵字搜尋。 把 pgvector 的語義相似度與傳統 BM25 關鍵字匹配做混合,確保精確匹配時不被向量搜尋稀釋。
我在實作時最終選擇了第三種方案,額外加上了一個 full_text_search 欄位用於關鍵字匹配,在排序時把 BM25 分數和向量相似度做加權平均。效果顯著提升——使用者搜「費用」,真的能找到「收費方式」和「價格方案」,而不只是語義上「相關」的頁面。
「你們網站有沒有 AI」——這個問題最好的回答不是口頭上說「有」,而是讓客戶實際體驗到。
一個語義搜尋框,看起來不起眼,但它解決了一個真實的問題:網站內的資訊終於能被找到了。對多數中小型網站來說,這本身就是一個足夠有價值的 AI 功能。
剩下的,就只是把那張 MVP 做出來而已。