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;
$$;

関連トピック

参考リンク