Supabase + pgvector
作成日:
Supabase pgvector vector-search semantic-search PostgreSQL
概要
Supabaseは公式にpgvector拡張をサポートしており、PostgreSQLベースのバックエンドでセマンティック検索(意味的な検索)を簡単に実現できます。
Supabase + pgvectorのメリット:
- Supabaseダッシュボードからワンクリックでpgvectorを有効化
- 既存のSupabase認証・RLSと統合可能
- Edge Functionsと組み合わせて検索APIを構築
- マネージドサービスのため運用負荷が低い
pgvectorの詳細については pgvector を参照。
セットアップ
1. pgvector拡張の有効化
Supabaseダッシュボードの「SQL Editor」で以下を実行:
-- pgvector拡張を有効化
CREATE EXTENSION IF NOT EXISTS vector;
または、ダッシュボードの「Database」→「Extensions」からvectorを検索して有効化することも可能です。
2. テーブルの作成
記事やドキュメントを保存するテーブルを作成:
-- 記事テーブル(埋め込みベクトル付き)
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
category TEXT, -- 'topics', 'daily', 'standards' など
tags TEXT[],
embedding VECTOR(1536), -- OpenAI text-embedding-3-small
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 更新日時を自動更新するトリガー
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER articles_updated_at
BEFORE UPDATE ON articles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
3. インデックスの作成
-- ベクトル検索用のインデックス
CREATE INDEX articles_embedding_idx ON articles
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- 通常の検索用インデックス
CREATE INDEX articles_category_idx ON articles (category);
CREATE INDEX articles_slug_idx ON articles (slug);
4. 検索関数の作成
-- セマンティック検索関数
CREATE OR REPLACE FUNCTION search_articles(
query_embedding VECTOR(1536),
match_threshold FLOAT DEFAULT 0.7,
match_count INT DEFAULT 10,
filter_category TEXT DEFAULT NULL
)
RETURNS TABLE (
id INT,
slug TEXT,
title TEXT,
content TEXT,
category TEXT,
tags TEXT[],
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
articles.id,
articles.slug,
articles.title,
articles.content,
articles.category,
articles.tags,
1 - (articles.embedding <=> query_embedding) AS similarity
FROM articles
WHERE
1 - (articles.embedding <=> query_embedding) > match_threshold
AND (filter_category IS NULL OR articles.category = filter_category)
ORDER BY articles.embedding <=> query_embedding
LIMIT match_count;
END;
$$;
JavaScriptからの利用
Supabase クライアントのセットアップ
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = 'https://xxxxx.supabase.co'
const supabaseKey = 'your-publishable-key'
const supabase = createClient(supabaseUrl, supabaseKey)
記事の登録(埋め込み付き)
// OpenAI APIで埋め込みを生成
async function generateEmbedding(text: string): Promise<number[]> {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
}),
})
const data = await response.json()
return data.data[0].embedding
}
// 記事を登録
async function insertArticle(article: {
slug: string
title: string
content: string
category: string
tags: string[]
}) {
// タイトルと内容を結合して埋め込みを生成
const textForEmbedding = `${article.title}\n\n${article.content}`
const embedding = await generateEmbedding(textForEmbedding)
const { data, error } = await supabase
.from('articles')
.upsert({
...article,
embedding,
}, {
onConflict: 'slug' // slugが同じなら更新
})
if (error) throw error
return data
}
セマンティック検索の実行
// 検索クエリから関連記事を検索
async function searchArticles(query: string, options?: {
threshold?: number
limit?: number
category?: string
}) {
// 検索クエリを埋め込みベクトルに変換
const queryEmbedding = await generateEmbedding(query)
// RPC関数を呼び出してセマンティック検索
const { data, error } = await supabase
.rpc('search_articles', {
query_embedding: queryEmbedding,
match_threshold: options?.threshold ?? 0.7,
match_count: options?.limit ?? 10,
filter_category: options?.category ?? null,
})
if (error) throw error
return data
}
// 使用例
const results = await searchArticles('コンテナ仮想化について教えて', {
threshold: 0.6,
limit: 5,
})
// 「Docker」「Kubernetes」などの記事がヒット
console.log(results)
静的サイトとの統合
アーキテクチャ
静的サイト(Astro、Next.js等)でSupabase + pgvectorを使う場合の構成:
┌─────────────────────────────────────────────────────────────────┐
│ ビルド時(GitHub Actions等) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Markdownファイル │
│ ↓ │
│ Embedding API(OpenAI等)で埋め込み生成 │
│ ↓ │
│ Supabase (pgvector) に保存 │
│ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 検索時(クライアント or Edge Functions) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ユーザーの検索クエリ │
│ ↓ │
│ Embedding API でクエリを埋め込みベクトルに変換 │
│ ↓ │
│ Supabase RPC 関数で類似検索 │
│ ↓ │
│ 検索結果を返却 │
│ │
└─────────────────────────────────────────────────────────────────┘
ビルド時の埋め込み生成スクリプト例
// scripts/generate-embeddings.ts
import { createClient } from '@supabase/supabase-js'
import { getCollection } from 'astro:content'
import crypto from 'crypto'
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY! // サーバーサイドではsecret keyを使用
)
async function generateEmbedding(text: string): Promise<number[]> {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
}),
})
const data = await response.json()
return data.data[0].embedding
}
// コンテンツのハッシュを計算(変更検出用)
function hashContent(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex')
}
async function syncArticles() {
// Astro Content Collectionsから記事を取得
const topics = await getCollection('topics')
for (const article of topics) {
const textForEmbedding = `${article.data.title}\n\n${article.body}`
const contentHash = hashContent(textForEmbedding)
// 既存の記事をチェック
const { data: existing } = await supabase
.from('articles')
.select('id, content_hash')
.eq('slug', article.slug)
.single()
// ハッシュが同じなら更新不要
if (existing?.content_hash === contentHash) {
console.log(`Skip: ${article.slug} (unchanged)`)
continue
}
// 埋め込みを生成
console.log(`Generating embedding for: ${article.slug}`)
const embedding = await generateEmbedding(textForEmbedding)
// Supabaseに保存
await supabase
.from('articles')
.upsert({
slug: article.slug,
title: article.data.title ?? article.slug,
content: article.body,
category: 'topics',
tags: article.data.tags ?? [],
embedding,
content_hash: contentHash,
}, {
onConflict: 'slug'
})
console.log(`Updated: ${article.slug}`)
}
}
syncArticles()
GitHub Actionsでの自動実行
# .github/workflows/sync-embeddings.yml
name: Sync Embeddings
on:
push:
branches: [main]
paths:
- 'src/content/**/*.md'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Generate and sync embeddings
run: npx tsx scripts/generate-embeddings.ts
env:
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Edge Functionsでの検索API
クライアントから直接OpenAI APIを呼び出すとAPIキーが漏洩するリスクがあります。Supabase Edge Functionsを使って安全に検索APIを構築できます。
// supabase/functions/search/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const { query, limit = 10, threshold = 0.7 } = await req.json()
// OpenAI APIで埋め込みを生成
const embeddingResponse = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: query,
}),
})
const embeddingData = await embeddingResponse.json()
const queryEmbedding = embeddingData.data[0].embedding
// Supabaseで検索
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
)
const { data, error } = await supabase.rpc('search_articles', {
query_embedding: queryEmbedding,
match_threshold: threshold,
match_count: limit,
})
if (error) throw error
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})
コスト試算
小規模サイト(記事100件)の場合
| 項目 | コスト |
|---|---|
| Supabase Free プラン | $0/月 |
| OpenAI Embedding(初回) | 100記事 × 1000トークン = 100,000トークン → 約$0.002 |
| OpenAI Embedding(検索) | 1000検索/月 × 50トークン = 50,000トークン → 約$0.001 |
| 合計 | ほぼ無料(月額数円) |
中規模サイト(記事1000件)の場合
| 項目 | コスト |
|---|---|
| Supabase Pro プラン | $25/月(必要に応じて) |
| OpenAI Embedding(初回) | 約$0.02 |
| OpenAI Embedding(検索) | 10000検索/月 → 約$0.01 |
| 合計 | $25〜/月 |
ハイブリッド検索
キーワード検索とセマンティック検索を組み合わせることで、より精度の高い検索を実現できます。
-- ハイブリッド検索関数
CREATE OR REPLACE FUNCTION hybrid_search(
query_text TEXT,
query_embedding VECTOR(1536),
keyword_weight FLOAT DEFAULT 0.3,
semantic_weight FLOAT DEFAULT 0.7,
match_count INT DEFAULT 10
)
RETURNS TABLE (
id INT,
slug TEXT,
title TEXT,
similarity FLOAT
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
WITH keyword_results AS (
SELECT
articles.id,
articles.slug,
articles.title,
ts_rank(
to_tsvector('simple', articles.title || ' ' || articles.content),
plainto_tsquery('simple', query_text)
) AS keyword_score
FROM articles
WHERE to_tsvector('simple', articles.title || ' ' || articles.content)
@@ plainto_tsquery('simple', query_text)
),
semantic_results AS (
SELECT
articles.id,
articles.slug,
articles.title,
1 - (articles.embedding <=> query_embedding) AS semantic_score
FROM articles
WHERE 1 - (articles.embedding <=> query_embedding) > 0.5
)
SELECT DISTINCT
COALESCE(k.id, s.id) AS id,
COALESCE(k.slug, s.slug) AS slug,
COALESCE(k.title, s.title) AS title,
(COALESCE(k.keyword_score, 0) * keyword_weight +
COALESCE(s.semantic_score, 0) * semantic_weight) AS similarity
FROM keyword_results k
FULL OUTER JOIN semantic_results s ON k.id = s.id
ORDER BY similarity DESC
LIMIT match_count;
END;
$$;
関連トピック
- pgvector - pgvectorの基本的な使い方
- Embedding APIの選択 - 埋め込み生成APIの選択肢
- セマンティック検索の実装パターン - 実装パターンの詳細
- Supabase - Supabaseの概要
- 検索技術の基礎 - 検索技術の基本概念