【個人開発向け】Supabaseで日本語全文検索（Supabase x PGroonga x Next.js ）

## なにをするのか

SupabaseとPGroongaを利用して、日本語の全文検索が可能なWebアプリケーションの作り方を紹介します。検索の仕組みには詳しくないけど、全文検索ができるシステムを作りたい人向けの記事になっています。

今回はNext.jsで作成したWebアプリケーションに全文検索機能を実装します。
SupabaseとNext.jsのプロジェクト作成から、全文検索用拡張であるPGroongaを設定し、検索画面を実装してみます。

公式リファレンスで「[Full Text Search](https://supabase.com/docs/guides/database/full-text-search?queryGroups=example-view&example-view=sql&queryGroups=language&language=js)」の解説がありますが、これでは日本語での検索がうまくできないので、今回は日本語で検索可能にするための実装方法になっています。

## Supabaseとは

Supabaseは今年[2024年4月にGA](https://supabase.com/ga)された、アプリケーションのバックエンドを簡単に構築するためのクラウドサービスです。

https://supabase.com/

AWSのようなメガクラウドというわけではなく、バックエンドのインフラの構築に特化し、アプリケーションに必要なPaaS群とその利用ライブラリを提供している、いわゆるBaaS（Back as a Service）です。

最も特徴的な点としてSupabaseはRDBのサービスを備えており、同じBaaSでNoSQLを提供しているFirebaseをだいたいするサービスとして謳われています。
管理画面も使いやすく、ローカルでの実行環境の構築も可能であったりと、運用面でも使い勝手がいいなぁと感じています。

とりあえずゼロイチで、RDBを使ったWebアプリケーションをこの土日で構築したい！といった際にSupabaseはバックエンドのインフラの選択肢としてとても有効です。

この１年、個人的にとても助かったサービスの一つということで、アドベントカレンダー記事にしたいと思います。


## やり方

### supabaseとサンプルアプリを作成する（Next.js）

以下のページを参考にSupabaseのNext.jsサンプルアプリの作成をします。

https://supabase.com/docs/guides/getting-started/quickstarts/nextjs

（私はよく使うのでNext.jsにしています。今回の全文検索はすべてSupabase上で実現するためクライアントはなんでも大丈夫です。）

### 検索対象のデータを準備する

今回は、公式リファレンスで説明されている「Full Text Search」とおなじテーブルを使います。日本語検索をしたいので、データは日本語に変えています。

https://supabase.com/docs/guides/database/full-text-search?queryGroups=example-view&example-view=sql&queryGroups=language&language=js

Supabaseの管理画面から【SQL Editor】 を開き以下のSQLを実行します。

```SQL
CREATE TABLE books (
  id SERIAL PRIMARY KEY,
  title TEXT,
  author TEXT,
  description TEXT
);
```

```SQL
INSERT INTO books (title, author, description)
VALUES
  (
    'のろまな小さな子犬',
    'ジャネット・セブリング・ローリー',
    '子犬は他の大きな動物たちより動きが遅い。'
  ),
  (
    'ピーターラビットのおはなし',
    'ビアトリクス・ポター',
    'うさぎは野菜をいくつか食べる。'
  ),
  (
    'トゥートル',
    'ガートルード・クランプトン',
    '小さなおもちゃの列車は大きな夢を持っている。'
  ),
  (
    '緑の卵とハム',
    'ドクター・スース',
    'サムは食べ物の好みが変わり、普通とは違う色の食べ物を口にする。'
  ),
  (
    'ハリー・ポッターと炎のゴブレット',
    'J.K.ローリング',
    '4年生の学年が始まり、大きな事件が起こる。'
  );
```

### 全文検索に必要な設定をする

今回全文検索にはPGronngaを利用します。PGroongaとは、PostgreSQL向けの全文検索拡張機能です。
PGronngaは内部にGroongaという全文検索エンジンを組み込んでいます。これにより、PostgreSQL上で様々な言語に対応した高速な全文検索が可能になります。


https://pgroonga.github.io/ja/


Supabaseのデータベースの拡張機能設定から、PGroongaを有効化します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615193/edc3a4ee-882b-f18e-00ca-b2734a246f80.png)

スキーマは【public】を指定します。
![schema.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615193/b5e56025-1d96-4cd6-b8e1-5cf4bfd8d165.png)

### 検索用のindexの作成する

今回は、descriptionカラムを検索対象にしてみます。

```sql
CREATE INDEX pgroonga_books_description_index ON public.books
  USING pgroonga (description)
  WITH (tokenizer='TokenMecab');
```

日本語の説明文のような自然言語への検索に対応するため、トークナイザーにMeCab形態素解析来をベースにしたTokenMeCabを設定しています。これにより自然な検索結果が得られやすくなります。

トークナイザーとは、文章などのテキストを検索しやすくするように、分解する際の処理器です。groogaのリファレンスで丁寧に説明されています。

https://groonga.org/ja/docs/reference/tokenizer/summary.html

様々な検索ケースに対応するためのカスタムトークナイザーが用意されています。
例えばタグ検索に最適な[TokenDelimit](https://groonga.org/ja/docs/reference/tokenizers/token_delimit.html)なども用意されています。

※ Supabaseの特定のバージョンでTokenMecabトークナイザーが利用できなくなることがありました。バージョンを上げなければ問題ないのですが、このあたりがマネージドなPaaSを使うリスクの一つかなと思いました。

https://github.com/supabase/postgres/pull/462#issuecomment-2270662752

ちなみにこのときは、[1ヶ月くらいで対応](https://github.com/supabase/postgres/pull/1105)されていました。

#### 補足

PGroongaはデフォルトですべてのテキストエンコーディングに対応したノーマライザが設定されます。
リファレンスにもある通りまずはこのままでいいと思います。

https://groonga.org/ja/docs/reference/normalizers/normalizer_auto.html#normalizerauto

また、PGroongaで同義語の定義なども可能です。

https://pgroonga.github.io/ja/how-to/synonym-expansion.html


### 検索するクエリをストアドファンクションとして定義する

クライアントから呼び出す、実際に検索をするクエリをストアドファンクションとして定義します。
Supabaseにはクライアントからデータを取得するためのAPIが存在しますが、それではPGroogaを利用したクエリが実行できないのでストアドファンクションを作成します。

WHERE句で記述している `&@~` がPGroongaの全文検索用の演算子になります。
クライアントからはSupabaseのライブラリを利用して、このストアドファンクションを呼び出します。

```sql
CREATE OR REPLACE FUNCTION search_books_description(query text)
RETURNS SETOF books AS $$
BEGIN
  SELECT *
  FROM books
  WHERE description &@~ query;
END;
$$ LANGUAGE plpgsql;
```

### クライアント側の実装

クライアントでは以下の３つを実装します。

- ストアドファンクション呼び出し
- 画面の実装
    - 検索窓
    - 検索結果

今回は簡単に一つのクライアントコンポーネントとしてページを実装します。


#### ストアドファンクション呼び出し
```TypeScript
const { data, error } = await client
    .rpc('search_books_description', {query})
    .returns<{id:number; title:string; author:string; description:string;}[]>();
```

`.rpc()`でストアドファンクションを呼び出します。

ちなみに、`.returns<T>()`のジェネリクスで取得データの型を指定できます。これは普通の`select()`等でも利用可能です。


#### 画面の実装

Nextのコンポーネントの実装は詳しく紹介しませんが、ページコンポーネントのコードは以下のようになりました。

```tsx
"use client";

import { useState } from 'react'
import { createClient } from '@/utils/supabase/client'


const search = async (query: string): Promise<{id:number; title:string; author:string; description:string;}[]> => {
    const client = createClient();
    const { data, error } = await client
        .rpc('search_books_description', {query})
        .returns<{id:number; title:string; author:string; description:string;}[]>();


    if (error) {
        console.error(error)
        return []
    }

    return data || [];
}

const SearchPage = () => {
    const [searchTerm, setSearchTerm] = useState('');
    const [results, setResults] = useState<{id:number; title:string; author:string; description:string;}[]>([]);
    const [loading, setLoading] = useState(false);

    const handleSearch = async () => {
        setLoading(true);
        try {
            const res = await search(searchTerm);
        setResults(res);
        } finally {
            setLoading(false);
        }
    }

    return (
        <div>
            <div className="bg-white shadow-md rounded p-6 my-6 w-max mx-auto">
                <h1 className="text-2xl font-bold mb-4">本の検索</h1>
                {/* 検索窓 */}
                <div className="flex space-x-2 mb-4 ">
                    <input
                        type="text"
                        className="border border-gray-300 rounded px-3 py-2"
                        placeholder="フリーワード"
                        value={searchTerm}
                        onChange={(e) => setSearchTerm(e.target.value)}
                    />
                    <button
                        onClick={handleSearch}
                        className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
                    >
                        検索
                    </button>
                </div>
            </div>

            {/* 検索結果 */}
            <div className='w-full max-w-xl'>
                {loading && <div className="text-gray-500">検索中...</div>}

                {!loading && results.length > 0 && (
                <ul className="space-y-4 w-full">
                    {results.map(book => (
                    <li key={book.id} className="border-b border-gray-200 pb-2">
                        <h2 className="font-semibold text-lg">{book.title}</h2>
                        <p className="text-sm text-gray-600">著者: {book.author}</p>
                        <p className="text-sm text-gray-600 mt-1">{book.description}</p>
                    </li>
                    ))}
                </ul>
                )}

                {!loading && results.length === 0 && searchTerm.trim() !== '' && (
                <div className="text-gray-500">該当する本がありませんでした。</div>
                )}
            </div>
        </div>
    );
}
export default SearchPage; 
```

### おわり
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3615193/14c32095-b4f4-090e-d56c-b421d7f78cb4.png)

# 参考

以下大変参考なった資料です。著者の方々に感謝申し上げます。ありがとうございます。

- https://zenn.dev/hmatsu47/articles/supabase_pgroonga_flutter
- https://zenn.dev/circleback/articles/supa-pgroonga
- https://slide.rabbit-shocker.org/authors/komainu8/postgresql-conference-japan-2021/improve-search-result-with-pgroonga.pdf
- https://pgroonga.github.io/ja/
- https://www.lambdanote.com/products/ir-system