Docusaurus製のドキュメントサイトをElasticsearchで検索する

この記事は、CYBOZU SUMMER BLOG FES '24 (Garoon Stage) DAY 7の記事です。

Nozomiチームのぱくとまです。

ここ数ヶ月、サイドプロジェクトとして過去の仕様書やナレッジをConfluenceからDocusaurusによるドキュメントサイトに移行していました。

この記事では、このドキュメントサイトでElasticsearchによる全文検索を実現した手法を紹介します。

背景

現在、Garoonチームでは仕様書やナレッジをConfluence Cloudで管理しています。

このConfluence Cloudは2代目のドキュメント管理環境で、2016年ごろまではオンプレミス版Confluenceを利用していました。

現在はオンプレミス版Confluenceは利用されていませんが、読み取り専用な状態で社内で維持されています。ただ、新規アカウントの追加が停止されているため社内の半数以上のユーザーが閲覧権限がないなど、廃止に向けたロードマップが進んでいます。

Garoonチームがよく利用する資料はConfluence Cloudに移行しているものの、普段使わない資料については移行が滞っていました。しかし、オンプレミス版Confluenceのアカウントを持つメンバーが減ってきているため、全ての資料をDocusaurusでドキュメントサイトにアーカイブすることにしました。

Docusaurus

Docusaurusは、ドキュメントサイトが簡単に作成できるReactベースの静的サイトジェネレータ(SSG)です。

ドキュメントのバージョニングや複数言語の翻訳など、仕様書・APIドキュメント・ヘルプサイトなどに必要な機能を持ったWebサイトを、MDXというJSXを内包できるMarkdown方言によって作成できます。

Docusaurusの特徴は、フロントエンドがReactコンポーネントになっているためカスタマイズしやすいことです。ただし、Infimaという独自のCSSフレームワークを採用していたり、Swizzlingという独特なコンポーネントの管理をしていたりするので汎用のReactフレームワークよりは少し癖があります。

また、カスタマイズが容易で画面のデザインも綺麗なのですが、Hugoなど他のSSGと比べてビルドが遅いことが欠点です。今回移行したページは4000件ほどでしたが、npm run buildに10分ほどかかりました。

全文検索機能については、Algoliaのみを公式でサポートしています。他にはコミュニティサポートとしてTypesenseLunr.jsPagefindがあります。Algoliaはサイボウズ社内のセキュリティ要件から採用が難しく、Typesense、Lunr.js、Pagefindは日本語検索に難があります。また、Lunr.jsとPagefindはクライアントサイドに検索インデックスを持つエンジンなので、ページ数が数千件になると性能に不安がありました。

Docusaurusの検索コンポーネント

Docusaurusでは検索機能がコンポーネントとなっていて、Swizzlingという仕組みで編集することができます。

Algoliaベースの検索

ドキュメントで紹介されている通り、公式サポートのコンポーネントです。

npm run swizzle @docusaurus/theme-search-algolia SearchBarというコマンドにより、src/theme/SearchBarディレクトリに実装がコピーされ、編集できます。

図のように、公式ドキュメントの検索機能もAlgoliaベースのものです。図のグレーアウト領域の右上にある検索欄らしきものは単なるボタンであり、クリックすると検索ボックスが出現します。

公式ドキュメントの検索機能

検索機能の実体はこの検索ボックスなのですが、かなりDocusaurusおよびAlgoliaと密結合した作りになっていて、筆者がフロントエンドに疎いことを差し引いても改変は難しいように思いました。

標準テーマの検索コンポーネント

こちらは空のコンポーネントです。ドキュメントによると「この実装を変更して自由に検索機能を作ってね」という意図のようです。

npm run swizzle @docusaurus/theme-classic SearchBarというコマンドにより、src/theme/SearchBarディレクトリに実装がコピーされ、編集できます。中身は空のindex.tsxとcustom.cssです。

実装方針

当初はAlgoliaベースの検索バーを改変してElasticsearchに対応させようと考えていました。しかし、前述の改変の難しさに加えて検索ボックス型の検索機能が自分の好みに合わなかったため、独自に検索機能を実装することにしました。実装した検索機能は、検索バーに文字を入力すると独立した検索ページにジャンプするような形式です。

検索ページはDocusaurus側で作成することも出来るのですが、DocusaurusのCSSフレームワークであるInfimaに情報がほとんどないことから、調査が難航することが予想されました。また、DocusaurusはSSG専門のフレームワークであるため、検索リクエストをElasticsearchに中継するようなバックエンドのサーバーは用意されていません。ElasticsearchはREST APIを採用しているのでクライアントから直接呼び出すこともできますが、セキュリティリスクとなる恐れがある*1ためバックエンドから呼び出す必要があります。これらの理由から、検索ページの実装は別のフレームワークを利用することにしました。

作成するページ数が少なく、サイドプロジェクトなので工数もなるべく少なくしたかったため、軽量なフレームワークでバックエンドとの統合が優れているRemixを利用しました。

Remix

RemixはReactベースのWebフレームワークであり、シンプルさとWeb標準への準拠を謳っています。

Remixはコンパクトなフレームワークであり、SSGをサポートしていません。今回のユースケースではSSGが必要な要素はDocusaurusに全て任せているため、この点はマッチしていました。

また、Remixの「サーバーで動作するコードとクライアントで動作するコードが明確に分かれている」という点も、今回の要件とマッチしていました。ElasticsearchのREST APIで利用する認証キーなどのデータは、セキュリティ上サーバーサイドに閉じる必要がありますが、Remixではそのような記述を自然に書くことができました。

システムの構成

今回構築したシステムは、下の図のような構成になっています。

ドキュメントと検索ページの構成

/searchへのリクエストはnginxによって検索ページに振り分けられます。検索ページはDocker Compose上のRemix App Serverで動作しており、同じくDocker Compose上のElasticsearchのAPIにアクセスして検索します。

/search以外へのリクエストは通常のドキュメントサイトとして、Docusaurusで生成された静的ファイルがnginxから配信されます。

書き換えが発生しないアーカイブサイトという性質上、静的ファイルのビルドやElasticsearchへのドキュメント追加にパイプラインは組んでおらず、一回きりの実施になっています。

Remixによる検索ページの実装

ありがたいことに、Remixのチュートリアルに検索ボックスの参考実装があります。

チュートリアルに従って文字入力のたびにfetchする検索バーを作成し、loaderでElasticsearchから検索結果を取得することでインクリメンタルサーチを実装できました。

チュートリアル以外の要素としては、IMEのON/OFFをInputEvent.isComposingおよびCompositionEventから検知することでかな漢字変換中に検索されないようにしました。実装したコードの一部を以下に示します。なお、Safari以外では変換候補の確定(Enter押下)時にevent.nativeEvent.isComposingtrueになる*2ため、onCompositionEndイベントでもsubmit()を呼び出しています。

return (
  <Form
    onChange={(event) => {
      if (! event.nativeEvent.isComposing) {
        return;
      }
      submit(event.currentTarget, {
        replace: true
      });
    }}
  >
    <input
      onCompositionEnd={(event) => {
        submit(event.currentTarget.parentElement, {
          replace: true
        });
      }}
    />
  </Form>
);

サーバーサイドでは、Elasticsearch Node.js clientを用いてElasticsearchに検索結果を問い合わせ、結果に合わせてパンくずリストを生成しています。

クライアント・サーバーのどちらもシンプルな実装となったため、どちらかというとTailwind CSSを使って検索ページとDocusaurusのデザインを合わせる方が大変でした。

実装した検索ページ

Docusaurusへの検索バーの実装

こちらは標準テーマの検索コンポーネントを編集する形で実装しました。<input>タグに検索ワードを入力してEnterキーを押すと、入力内容をクエリとして検索ページにジャンプするReactコンポーネントです。

検索機能を検索ページに集約したため、とても単純な実装になりました。

実装した検索バー

まとめ

検索ページをRemixで実装することで、Docusaurus製のドキュメントサイトでElasticsearchによる全文検索を実現しました。筆者はReactフロントエンドは未経験だったのですが、Elasticsearchにはある程度慣れていることもあって合計一週間ほどで作成できました。

Docusaurusの他にもドキュメントサイト向けのSSGはいくつかありますが、全文検索エンジンの選択肢は限られており、日本語検索が可能なのはAlgoliaだけという場合も多いです。Algoliaはクラウドサービスなので、機密データや費用、外部アクセスなどの事情で利用できないことも多いと思います。その際は、工数が折り合えば検索ページを自前で実装する選択肢も検討すると良いかもしれません。