ウィルゲートのエンジニアの岡田/おかしょい(@okashoi)です。
私はここ 1 年間ほど、新規プロダクトである BtoB セールスツール「アポトル」を開発するチームに所属していました。
このアポトルにおいて、全文検索エンジン OpenSearch を導入して検索機能の速度を大きく改善した事例について紹介いたします。
この記事は「ウィルゲート Advent Calendar 2024」の 7 日目の記事です。 adventar.org
リリースを迎えたアポトルの検索機能が抱えていた課題
アポトルは先述のとおり BtoB セールスツール(BtoB 商材の営業活動を支援するツール)ですが、そのあらゆる機能が「企業を検索する」という行為に紐づきます。
その検索条件は多岐にわたり、企業名や事業・サービス内容のフリーテキスト検索、業種や所在地(都道府県)を選択しての検索、従業員数や売上高などの数値の範囲検索を自由に組み合わせて検索できるのです。
さて、この検索機能はアポトルがサービスとしてリリースされるより遥か前、社内における検証の頃に MySQL を用いてナイーブに実装されたものでした。
あらゆる検索条件に対応する値をそれぞれカラムとして持つ 1 つのテーブルに対して、愚直に検索条件を AND や OR でつなげるような設計、実装になっていたようなイメージです。
このようなテーブルに対して、ユーザ側で条件を自由に組み合わせられる検索機能を提供する場合、有効なインデックスを作成することができずにフルテーブルスキャンが多発してしまいます。
ところでこの記事を執筆している 2024 年 11 月現在、日本で法人番号を持つ法人は約 600 万件、主たる検索対象となる株式会社・合同会社・合資会社・合名会社に絞っても約 300 万社もの企業が存在しています。
その結果、検索条件の組み合わせの最もひどいケースでは MySQL サーバからレスポンスが返ってくるまで 9 分近くもかかる、という状況を引き起こしてしまいました。
アポトルにおいて主要機能であるはずの「企業を検索する」機能が致命的に遅い、ということがリリースを迎えた 4 月頃の喫緊の課題となっていたのです。
OpenSearch 導入
この課題を解決すべく、全文検索エンジン OpenSearch を導入して企業検索機能を MySQL から分離することにしました。
OpenSearch は分散型 RESTful 検索/分析エンジンであり、Amazon が Elasticsearch を fork して独自開発しています。
検索機能(データの追加・更新や設定の変更含む)にアクセスできる RESTful API と、Web 経由でアクセスできるダッシュボードが提供されます。
導入結果:検索速度が 10 倍に
先に導入した結果を説明します。
最もシンプルなケースとして、画面上で 1 つの条件を指定し「検索」ボタンをクリックしてから結果が表示されるまでの時間を比較しました。 その結果、導入前は 13 秒かかっていたところが OpenSearch 導入によって 1 秒に短縮されました。
ほかにも導入前には数分間かかってしまっていた複雑な条件の組み合わせが、OpenSearch ではシンプルなケースとほぼ変わらずに 1 秒強以内で結果が表示されるようになりました。
これらの結果から、企業検索機能が「最低でも 10 倍速くなった」と言って問題ないほどまで改善しました。 導入した私自身でも驚いて、本当に検索機能が正しく動作しているのか疑うほどでした。
以降、OpenSearch 導入時の設計や設定について説明します。
OpenSearch 導入時の設計指針
OpenSearch 導入にあたって、はじめに「データストア目的では使わない」「検索以外の機能のために使用しない」という 2 つの設計指針を定めました。
データストア目的では使わない
OpenSearch はあくまで検索エンジンであると位置付けて、データストア目的としては使用しないこととしました。
具体的には「常に」以下の条件を満たすように運用していくこととしました。
- 保存されるドキュメント(データ)はすべて RDBMS 等の別のデータストアにあるデータから算出できるものでなければいけない
- OpenSearch のインデックスはなんらかの理由で消えてしまっても、いつでもデータを復元できる手順が提供されてなければならない
この方針を守るアポトルには「従業員が増えた」や「直近特定の職種の募集を開始した」といった時系列のデータに基づく検索条件が存在しています。
上記の条件を守るために、RDBMS 等のデータストアには「◯年◯月時点の従業員数」といった "点" のデータを保存しておきます。 また、OpenSearch には「従業員が増えたかどうか」「直近特定の職種の募集を開始したかどうか」のような時系列を捉えて解釈したデータを保存するようにします。 そして RDBMS 等に保存された "点" のデータを参照する、冪等なバッチ処理によって OpenSearch のデータを更新しています。
検索以外の機能のために使用しない
OpenSearch はデータを非正規化した形で保存できることに加えて、保存されたデータから動的にマッピング(フィールド名に対応する型情報を定義)する機能を持っています。
これにより気軽に色々な形でデータを保存・検索できて便利な半面、安易に様々な用途に使っていると設計が崩壊してしまい、いずれバグやセキュリティインシデントを発生させかねません。
そのため、きちんと RDBMS(MySQL)が役割を担うべきところは RDBMS に任せ、企業検索機能に関連するデータだけを OpenSearch に保存することとしました*1。
Amazon OpenSearch Service のドメイン設定
OpenSearch クラスタの構築や運用にはフルマネージドサービスである Amazon OpenSearch Service(旧:Amazon Elasticsearch Service)を用いることにしました。
ドメイン(OpenSearch クラスタに対応する概念)を作成する際、基本的には公式ドキュメントのベストプラクティスにもとづいて設定しましたが、アポトルに求められる可用性の要件とコストとのバランスを見て「専用マスターノード」と呼ばれるクラスタ管理専用のノードは無効化しました。
専用マスターノードを無効化した際の影響については下記ドキュメントに記載されています。
具体的には
- データノードが 3 台あるならば 1 AZ が落ちてもダウンタイムは発生しない
- 専用マスターノードの有無は 1 AZ が落ちた際の復旧のための動作を専任で行わせるか、データノードとして利用しているノードに行わせるかの違い
ということであり、アポトルの可用性要件と、AWS の可用性目標と照らし合わせた結果、「ダウンこそしないが速度は低下す」という状況は受け入れられると判断し、専用マスターノードを無効化してその分のコスト(ノード 3 台分)を削減することにしました。
OpenSearch における個別具体的な設定や設計
先述した指針以外にも、個別具体の箇所でも留意すべきポイントがいくつかあったので併せて紹介します。
デフォルトでは先頭から最大で 10,000 件しかデータが取得できない
OpenSearch におけるデフォルト設定では(ページネーションを指定しても)最大で先頭から 10,000 件目までしかデータが取得できません。
この上限値はインデックス定義の際に settings.max_result_window
という値を指定して変更できます。
アポトルの検索対象は日本で法人番号を持つ法人であり、サービスの成長とは無関係に高々 1,000 万レコードに留まるため決め打ちで指定しました。
{ "mappings": { // ... }, "settings": { "max_result_window": 10000000, // ... } }
参考:Elasticsearch でページングすると 10000 件までしか取れないの? 回避策は?
テキスト検索の挙動をカスタマイズする
ひとくちに「テキスト検索」といっても、その挙動は一意に定められません。
検索体験を向上させるため、OpenSearch では検索対象のドキュメントおよび検索クエリをどのように解釈し処理するかを細かくカスタマイズできます。 この挙動は Analyzer として定義され、以下の 3 つのコンポーネントからなります。
コンポーネント | 役割 |
---|---|
Character filters(char_filter ) |
文字単位の処理を適用する |
Tokenizer(tokenizer ) |
文字列をトークン(≒単語や形態素)に分割する |
Token filters(filter ) |
トークン単位の処理を適用する |
アポトルにおける企業名のテキスト検索と、それ以外(事業・サービス内容や求人の募集職種など)のテキスト検索では、検索クエリに込められたユーザの意図が異なると仮定しています。
- 企業名の検索
- 検索クエリが 1 文字の可能性もある
- 空白文字も含めて一致したものを検索したい
- それ以外のテキストの検索
- 単語やフレーズで検索したい
- 空白文字は単語やフレーズの区切りを表している
この仮定に対して Tokenizer の設定を少しだけ変えています(具体的な設定値は後述)。
またどちらにも共通する文字単位の処理(Character filters)として、半角カタカナの濁音と半濁音の表記を統一させる処理があります。
このとき OpenSearch に組み込まれている cjk_width
では「ガ」が「カ゛」のように 1 文字ずつ別々に変換されてしまったため、「ガ」が「ガ」という 1 文字に変換されるようなマッピングを明示的に指定しています。
最終的な Analyzer の定義は以下のようになりました。
name_analyzer
が企業名検索のための、search_text_analyzer
がそれ以外のテキスト検索のための Analyzer に対応しています。
{ "mappings": { // ... }, "settings": { // ... "analysis": { "char_filter": { "my_char_filter": { "type": "mapping", "mappings": [ "ヴ=>ヴ", "ガ=>ガ", "ギ=>ギ", "グ=>グ", "ゲ=>ゲ", "ゴ=>ゴ", "ザ=>ザ", "ジ=>ジ", "ズ=>ズ", "ゼ=>ゼ", "ゾ=>ゾ", "ダ=>ダ", "ヂ=>ヂ", "ヅ=>ヅ", "デ=>デ", "ド=>ド", "バ=>バ", "ビ=>ビ", "ブ=>ブ", "ベ=>ベ", "ボ=>ボ", "パ=>パ", "ピ=>ピ", "プ=>プ", "ペ=>ペ", "ポ=>ポ" ] } }, "tokenizer": { "ngram_tokenizer_for_name": { "type": "ngram" }, "ngram_tokenizer_for_search_text": { "type": "ngram", "min_gram": 2, "token_chars": [ "letter", "digit", "punctuation", "symbol" ] } }, "analyzer": { "name_analyzer": { "type": "custom", "char_filter": [ "html_strip", "my_char_filter" ], "tokenizer": "ngram_tokenizer_for_name", "filter": [ "trim", "cjk_bigram", "cjk_width", "decimal_digit", "lowercase" ] }, "search_text_analyzer": { "type": "custom", "char_filter": [ "html_strip", "my_char_filter" ], "tokenizer": "ngram_tokenizer_for_search_text", "filter": [ "trim", "cjk_bigram", "cjk_width", "decimal_digit", "lowercase" ] } } } } }
なお search_text_analyzer
の Tokenizer については、kuromoji 等の日本語形態素解析器を使うことで検索体験を向上させられる可能性もあります。
この検証については今後の課題としています。
おわりに
本記事では OpenSearch 導入に際しての設計方針や個別の設定内容を説明してきました。
OpenSearch それ自体は簡単に使うことができ、すぐにその恩恵に与ることができるものです。 ですが実プロダクトへの導入となると、プロダクトの性質や実現したい機能、開発チーム体制などから考慮すべきことは多いです。
本記事が OpenSearch 導入を検討している誰かの一助となれば幸いです。
アポトルについて
SNSを活用し顧客に「直接」つながることで法人向け営業を支援するツール「アポトル」にご興味をお持ちの方はぜひこちらよりお問い合わせください。
「ウィルゲート Advent Calendar 2024(https://adventar.org/calendars/10272)」、翌日は清水さんによる「あなたは『CSRF token mismatch.』の沼から抜けられるか」です。 お楽しみに!
*1:なお動的マッピングはインデックス定義の際、mappings.dynamic に "strict" を指定することで無効化できます