🔍

Supabase + OpenAIでハイブリッド検索を実装する方法

に公開

こんにちは!@Ryo54388667です!☺️

普段は都内でエンジニアとして業務をしてます!主にTypeScriptやNext.jsといった技術を触っています。今回は Supabase + OpenAIでハイブリッド検索を実装する方法 を紹介していきます!

📌 背景

ChatGPTのチャット履歴検索を行うと、最初の数件のみ表示され、その後に遅れて多くの検索結果が表示されます。内部の実装は不透明ではありますが、この実装を真似できないかと思い、今回試してみました!SupabaseのpgvectorとPostgreSQLの全文検索を組み合わせて、全文検索と意味検索を行います。

📌 全体の流れ

ユーザー入力
    ↓
[並列リクエスト]
    ├─→ 全文検索API → PostgreSQL全文検索 → 即座に表示
    └─→ セマンティック検索API → 入力内容を OpenAI Embedding でベクトル化 → pgvector類似度検索 → 後から表示

📌 Supabaseのセットアップ

1. 拡張機能の有効化

Supabaseダッシュボードでpgvectorを有効化します。

  1. Supabaseプロジェクトにログイン
  2. Database > Extensions
  3. vectorを検索してONにする

2. テーブル作成

SupabaseダッシュボードでSQLを実行してテーブルを作成します。
いつも思いますが、SupabaseのSQL Editorはうまくできているなぁと感心しますー!

操作手順:

  1. Supabaseダッシュボードにログイン
  2. SQL Editor を開く(左サイドバーから選択)
  3. New query ボタンをクリック
  4. 以下のSQLを貼り付けて Run ボタンをクリック
-- pgvector拡張を有効化
create extension if not exists vector;

-- 記事テーブルを作成
create table if not exists articles (
  id uuid primary key default gen_random_uuid(),
  title text,
  content text,
  embedding vector(1536) -- OpenAI text-embedding-3-smallの次元数
);

-- 全文検索用のtsvectorカラムを追加
alter table articles add column if not exists search_tsv tsvector;

3. 全文検索用トリガーの設定

同じくSQL Editorで以下のSQLを実行してトリガーを設定します。

-- tsvectorを自動更新するトリガー関数
create or replace function articles_tsv_trigger() returns trigger as $$
begin
  new.search_tsv :=
    setweight(to_tsvector('simple', coalesce(new.title,'')), 'A') ||
    setweight(to_tsvector('simple', coalesce(new.content,'')), 'B');
  return new;
end
$$ language plpgsql;

-- トリガーを作成
drop trigger if exists tsv_update on articles;
create trigger tsv_update
before insert or update on articles
for each row execute procedure articles_tsv_trigger();

ポイントとしてはsetweighttitleを'A'、contentを'B'の重みづけを表し、タイトルの方が高い重要度で検索されるようにしています。

4. インデックスの作成

引き続きSQL Editorで検索用のインデックスを作成します。

-- 全文検索用GINインデックス
create index if not exists idx_articles_tsv
  on articles using gin(search_tsv);

-- ベクトル検索用IVFFlatインデックス
create index if not exists idx_articles_embedding
  on articles using ivfflat (embedding vector_cosine_ops)
  with (lists = 100);

5. ハイブリッド検索関数の作成

最後に、ハイブリッド検索を実行する関数を作成します。

create or replace function public.hybrid_search(
  query_text text,
  query_embedding vector(1536)
) returns table(
  id uuid,
  title text,
  content text,
  hybrid_score double precision
)
language sql as $$
  select
    id,
    title,
    content,
    (
      0.6 * ts_rank_cd(search_tsv, plainto_tsquery('simple', query_text))
      + 0.4 * coalesce(1 - (embedding <=> query_embedding), 0)
    ) as hybrid_score
  from articles
  order by hybrid_score desc
  limit 10;
$$;

スコアの重み付け:

  • 全文検索: 60%
  • ベクトル類似度: 40%
  • coalesceでembeddingがnullの場合も対応

この比率は用途に応じて調整できます!

6. Row Level Security (RLS)

セキュリティ設定も必要です!
こちらはプロジェクトの要件に応じて調整してください。今回は匿名ユーザーでも記事を閲覧できるように設定します。

-- RLSを有効化
alter table articles enable row level security;

-- 読み取り専用ポリシー(匿名ユーザーでも閲覧可能)
create policy "read public articles"
on articles
for select
to public
using (true);

あとは、記事データを投入すればOKです!
今回はサンプルとして、似たようなエンジニアの職種をシードデータとして投入しました。
例:エンジニア、コーダー、プログラマー、SEなど

これでSupabase側のセットアップは完了です!🎉

📌 Next.jsアプリケーションの実装

プロジェクト構造

hyblid-search/
├── app/
│   ├── api/
│   │   └── search/
│   │       ├── fulltext/route.ts    # 全文検索API
│   │       └── semantic/route.ts    # セマンティック検索API
│   ├── search/
│   │   └── page.tsx                 # 検索UI
│   ├── layout.tsx
│   └── page.tsx
├── lib/
│   └── fetchers.ts                  # SWR用フェッチャー
├── scripts/
│   └── update-embeddings.ts         # embedding更新スクリプト
└── .env.local                       # 環境変数

1. Embedding更新スクリプト

記事テーブルでは、はじめにembeddingがnullの状態なので、OpenAI APIを使ってembeddingを生成し、Supabaseに保存するスクリプトを作成します。これを行わないとhybrid_scoreが常にnullになってしまいます。自分はこちらの設定を行っていなかったので、意味検索の結果がずっと変わらなくて困ってました😇

scripts/update-embeddings.ts

import { createClient } from '@supabase/supabase-js';
import OpenAI from 'openai';
import * as dotenv from 'dotenv';
import * as path from 'path';

// .env.localを読み込み
dotenv.config({ path: path.join(process.cwd(), '.env.local') });

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY // embeddingの更新にはサービスロールキーが必要
);

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

async function updateEmbeddings() {
  console.log('Fetching documents...');
  const { data: documents, error } = await supabase
    .from('articles')
    .select('id, title, content');

  if (error) {
    console.error('Error fetching documents:', error);
    throw error;
  }

  console.log(`Found ${documents.length} documents. Updating embeddings...`);

  for (const doc of documents) {
    console.log(`Processing: ${doc.title}`);

    // OpenAI APIでembeddingを生成
    const embeddingResponse = await openai.embeddings.create({
      model: 'text-embedding-3-small',
      input: doc.content ?? '',
    });

    const embedding = embeddingResponse.data[0].embedding;

    // Supabaseに保存
    const { error: updateError } = await supabase
      .from('articles')
      .update({ embedding })
      .eq('id', doc.id);

    if (updateError) {
      console.error(`Error updating ${doc.title}:`, updateError);
    } else {
      console.log(`✓ Updated ${doc.title}`);
    }
  }

  console.log('All embeddings updated successfully!');
}

updateEmbeddings().catch(console.error);

実行方法:

## ターミナル
npm install dotenv
npx tsx scripts/update-embeddings.ts

このスクリプトで既存の記事にembeddingを追加できます!

2. 全文検索API

app/api/search/fulltext/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.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

export async function POST(req: NextRequest) {
  const { query } = await req.json();

  // Supabaseの全文検索機能を使用
  const { data, error } = await supabase
    .from("articles")
    .select("*")
    .textSearch("content", query, {
      type: "websearch",
      config: "simple"
    })
    .limit(10);

  if (error) return NextResponse.json({ error: error.message }, { status: 500 });
  return NextResponse.json({ results: data });
}

3. セマンティック検索API

app/api/search/semantic/route.ts

import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
import { createClient } from "@supabase/supabase-js";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // RPC呼び出しにはサービスロールキーが必要
);

export async function POST(req: NextRequest) {
  const { query } = await req.json();

  // OpenAI APIでクエリをembeddingに変換
  const embeddingRes = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: query,
  });
  const queryEmbedding = embeddingRes.data[0].embedding;

  // Supabaseのハイブリッド検索関数を呼び出し
  const { data, error } = await supabase.rpc("hybrid_search", {
    query_text: query,
    query_embedding: queryEmbedding,
  });

  if (error) return NextResponse.json({ error: error.message }, { status: 500 });
  return NextResponse.json({ results: data });
}

残りのフロントエンドの実装

UIなどの実装

4. フェッチャー関数

lib/fetchers.ts

export async function postFetcher(url: string, { arg }: { arg: unknown }) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(arg),
    cache: "no-store", // Next.jsのキャッシュを無効化
  });
  if (!res.ok) throw new Error("API error");
  return res.json();
}

5. 検索UI

app/search/page.tsx

"use client";

import { useState } from "react";
import useSWRMutation from "swr/mutation";
import { postFetcher } from "@/lib/fetchers";

type Article = { id: string; title: string; content: string };

export default function SearchPage() {
  const [query, setQuery] = useState("");

  // 全文検索用のSWR mutation
  const { data: fulltextData, isMutating: fulltextLoading, trigger: triggerFulltext } = useSWRMutation(
    "/api/search/fulltext",
    postFetcher
  );

  // セマンティック検索用のSWR mutation
  const { data: semanticData, isMutating: semanticLoading, trigger: triggerSemantic } = useSWRMutation(
    "/api/search/semantic",
    postFetcher
  );

  const fulltextResults: Article[] = fulltextData?.results || [];
  const semanticResults: Article[] = semanticData?.results || [];

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    // 並列で両方のAPIを呼び出し
    triggerFulltext({ query });
    triggerSemantic({ query });
  };

  return (
    <div className="p-6 space-y-6 mx-auto max-w-xl">
      <h1 className="text-xl font-bold">ハイブリッド検索</h1>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          className="border px-2 py-1 w-80"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="検索ワードを入力"
        />
        <button className="bg-blue-500 text-white px-3 py-1 rounded">
          検索
        </button>
      </form>

      {/* 全文検索結果 */}
      <section>
        <h2 className="font-semibold my-4">📝全文検索結果</h2>
        {fulltextLoading && <p>🔍 検索中...</p>}
        <ul>
          {fulltextResults.map((r) => (
            <li key={r.id} className="border-b py-2">
              <strong>{r.title}</strong>
            </li>
          ))}
        </ul>
      </section>

      {/* セマンティック検索結果 */}
      {semanticLoading && <p>💡 意味的類似を解析中...</p>}
      {semanticResults.length > 0 && (
        <section>
          <h2 className="font-semibold my-4">💡セマンティック検索結果</h2>
          <ul>
            {semanticResults.map((r) => (
              <li key={r.id} className="border-b py-2">
                <strong>{r.title}</strong>
              </li>
            ))}
          </ul>
        </section>
      )}
    </div>
  );
}

📌 ハマったポイントと解決策

実装する中でハマったポイントがあったので共有します!

セマンティック検索の結果が常に同じ

どのキーワードでも結果がかわらず同じ結果が返ってくるので困ってました。
調べたところ、articlesテーブルのembeddingカラムがnullだったのが原因でした。
embeddingの登録が必要です。update-embeddings.tsスクリプトを実行してください!

確認クエリ:

SELECT id, title,
       embedding IS NOT NULL as has_embedding,
       array_length(embedding::float[], 1) as dimension
FROM articles
LIMIT 5;

そういうわけで、手順の「1. Embedding更新スクリプト」が必要になります。

📌 まとめ

ハイブリッド検索を実装することで、キーワードマッチの速さと意味検索の精度を両立できそうです!データが多ければ多いほどUXの良さを感じられると思います。

同じような検索機能を実装したい方の参考になれば幸いです!
最後まで読んでいただきありがとうございます!
気ままにつぶやいているので、気軽にフォローをお願いします!🥺

https://x.com/Ryo54388667

参考資料

GitHubで編集を提案

Discussion