日本語による全文検索を導入したいという気持ちで調べたところ、AlgoliaやElasticsearch、Supabaseなど、多くのソフトウェアが出てきました。
少し前であれば、自前でMySQLを用意してMroongaあたりをインストールしていましたが、現在では大きく進歩しています。しばらく全文検索を実装する機会がなかったのでワクワクです。

はじめはElasticsearchを導入しようとしました。しかし、いくらか触ったところ「高機能だが学習コスト高め」という結論に至りました。(導入自体はDockerで簡単になっていました)
そこで、「簡単に構築できる全文検索環境」のキャッチコピーに惹かれ、Fessへ入門することに決めたので備忘録を残します。

追記
Java製のため、かなりCPUを消費することが判明。Cloud Runで動かすことを予定していたため、コスト面で不安が残るため一旦導入は断念。

Fessとは

株式会社コードリブズという企業が提供するオープンソースの全文検索サーバーです。
とにかく簡単に導入でき、それなのに高機能な全文検索システムです。裏側はElasticsearchかOpenSearchで選べるようになっています。

Fessでコードリブズを知りましたが、レベルの高いオープンソースを惜しみなく提供していて素敵な企業でした。
Fessだけでなく、AIプラットフォームのFioneレコメンドエンジンのRecotemといったオープンソースを提供しています。代表取締役社長の菅谷信介氏はギーク感が溢れています。

Fessのインストール

早速、Fessをインストールしていきます。基本的に下記リンクの公式ドキュメントに沿って進めていくので、どちらかと言うと公式ドキュメントをご覧になることをおすすめします。
今回はmacOS+Docker環境で最低限使える環境を作っていきます。

Docker版はGitHubリポジトリにあるDocker Composeのyamlファイルを使います。
git cloneしてみましょう。

ターミナル
git clone git@github.com:codelibs/docker-fess.git fess
ターミナル
cd fess/compose
ターミナル
docker compose -f compose.yaml -f compose-opensearch2.yaml up -d

たったこれだけで完了です。

起動動作確認

ブラウザでhttp://localhost:8080にアクセスしてみましょう。
下の画像のような画面が出てきます。

Fessログイン画面
Fessログイン画面

次に右上のLoginからログインします。デフォルトでユーザー名とパスワードはadminですが、すぐにパスワードを変更できます。
パスワード変更が済んだら、右上のメニューからAdministrationをクリックして管理画面に入りましょう。下のスクリーンショットのような管理画面が表示されます。

Fess管理画面
Fess管理画面

起動が確認できたら、次のステップとして実際にインデックスを作成してみます。

ブラウザの言語設定が日本語になっていれば、自動的に日本語で表示されます。
※当方のブラウザの言語設定が英語のため、英語のまま進めます。

インデックスの作成

今回はFessのWebクローリング機能を使っていきます。(とても便利)
ただ、注意点として自社や自身が保有するWebサイトを対象にしてください。他社のWebサイトに余計な不可を加えることは避けましょう。

以下の画面から対象のURLを設定します。

クローラー設定
クローラー設定

作成したら、"Scheduler"メニューから"Default Crawler"をクリックし、"Start now"で開始しましょう。
下のスクリーンショットのように、"Crawling Info"でクローラーのステータスを見ることができます。(スクリーンショットは完了後)

スケジューラー設定
スケジューラー設定
Fessクローラー実行
Fessクローラー実行
クローラー情報
クローラー情報

検索動作確認

次に検索できるかを確認しましょう。
右上にあるツールバーの"Search View"、またはサイドバー上部にある検索窓からクローリングしたWebサイトに掲載されていそうな語句を検索してみます。

Fess検索窓
Fess検索窓

検索結果にスコア(検索語句と対象の近似性)付きで検索結果が表示されます。これで検索が動作していることがわかりました。

Fess検索結果
Fess検索結果

Next.jsから使ってみる

「GUI上で検索結果が見られる=APIで利用できる」状態なので、Next.jsから使ってみましょう。
まずはAPIの確認です。公式ドキュメントではこちらに詳しく書かれています。

API動作確認

先ほど、"Python"を検索語句とした結果を画面上で表示しました。同じようにAPIで叩いてみます。
検索APIはapi/v1/documents?q={検索語句}なので、以下のコマンドを叩きます。

結果のJSONを見やすくするためにjqを使っています。
インストールしていない人は、とても便利なのでこちらからインストールしてみてください。

ターミナル
curl -X GET 'localhost:8080/api/v1/documents?q=Python' | jq .

下記画像のように、良い雰囲気で取得できています。

Fess APIテスト
Fess APIテスト

Next.jsにコンポーネントを作る

Next.jsで上記APIを使うために、検索フォーム用コンポーネントを作成します。
なお、前提としてAPIへリクエストするため、Next.jsの設定はSSRであるとします。また、今回は簡略化のためにMantine UIを利用しています。

Search.tsx
import { useState } from 'react'
import { useForm } from '@mantine/form'
import { Box, Button, TextInput } from '@mantine/core'

export function Search() {
  const [items, setItems] = useState([])

  const form = useForm({
    initialValues: {
      query: '',
    }
  })

  const fessSearch = async () => {
    const res = await fetch(`/api/fess?q=${form.values.query}`)
    const data = await res.json()
    setItems(data.items)
  }

  return (
    <Box>
      <form onSubmit={form.onSubmit(() => fessSearch())}>
        <TextInput
          label="キーワード"
          placeholder="キーワード"
          {...form.getInputProps('query')}
        />
        <Button type="submit">検索</Button>
      </form>
      {items.length > 0 && (
        <p>検索結果</p>
        <ul>
          {items.map((item: Item) => (
            <li key={item.url}>
              <a href={item.url}>{item.title}</a>
            </li>
          ))}
        </ul>
      )}
    </Box>
  )
}

最終的に/api/fessへリクエストします。
/api/fessの内容は以下です。Next.js 13系で使えるApp Routerの場合だと実装が変わるので、それぞれ記載します。

Next.js Pages Routerの場合
pages/api/fess.ts
type Item = {
  title: string
  url: string
}

type ResponseData = {
  items: Item[]
}

export default async function handler( req, res ) {
  const keyword = req.query.q

  const endpoint = `http://localhost:8080/api/v1/documents?q=${keyword}`

  const response = await fetch(endpoint)
  const data = await response.json()

  const items = data.data.map((item: any) => ({
    title: item.title,
    url: item.url,
  }))

  res.status(200).json({ items })
}
Next.js App Routerの場合
app/api/fess/route.ts
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const json = await req.json()
  const res = await fetch(`http://localhost:8080/api/v1/documents?q=${json.q}`)
  const data = await res.json()
  const items = data.data.map((item: any) => ({
    title: item.title,
    url: item.url,
  }))
  return NextResponse.json({ items })
}

若干デザインはいじっていますが、以下のように検索結果が表示されれば完了です。

Fess + Next.js通信
Fess + Next.js通信

簡単に導入できるFessでお手軽全文検索

今回は最短距離で試していきましたが、実際に本番運用する際はAPI Keyの発行やインデックス作成のワークフローを考慮する必要があります。
そういった点を念頭に置いても、Fessは非常に簡潔な導入方法で利用できるためおすすめです。

よりビジネスシーンに活用したい場合は、以下のプランもあります。

他の全文検索サービスと比べても安価なので、「全文検索だけ導入したい」というユースケースでぜひ活用していきましょう。