この記事は8490文字約22分で読めます

お金をかけない開発ほど美しいものはないだろう....。

Table of Contents

はじめに

おはようございます!!こんにちは!! この記事はAWS re:invent開催のラスベガスからお送りしてますが、一切AWS関係ないです....!!!

アドベントカレンダーをre:invent期間中に書こうという魂胆が裏目にでました!

たくさん出てくるアップデートを検証しながら即記事にするという行為は執筆速度の遅い私には不可能な芸当でした。

時差ボケで頭が回らない中書いてるので駄文が続きますがどうぞよろしくお願いします。

別途、海外カンファレンスに英語ができない人が参加することになったらどうすればいいの? のTipsを記事で公開しようと思います。そちらもお楽しみに!!

あと、この記事はKDDIアジャイル開発センター(KAG) Advent Calendar 2024の5日目の記事です!

企業検索窓のサジェスト機能が作りたいよー!

突然ですが、こんな感じの企業名を入力することでどんどん企業名の候補がサジェストで出てくるようなUIを作ってほしいとクライアントやプロダクトマネージャーから依頼されたらどうしますか...?

企業名のサジェスト機能を提供しているAPIや実装に組み込めるSDKで提供しているSaaSを探したり、(このブログの検索窓でも採用してますが)Algoliaなどの全文検索SaaSを活用するなどを検討することでしょう。

しかしながらそのようなサービスを使って開発を進めることでAWSなどのインフラコストとは別に月額の利用料がかかってしまいます。

個人開発ならまだしも実業務での開発だと別のSaaS料金がかかってくることに対する交渉は色々な要因で難航することも多いでしょう。

また、企業名など公開されている情報だけでなく、社内で抱えている非公開情報に対してもサジェストをしたい、という場面もあるかもしれません。

そうなってくると、事前に用意されているSaaSを使うことはできなくなるわけです。

今回は、 AWSなどのクラウドサービスにスクラッチでサジェスト機能の全文検索の仕組みとフロントエンドを作成し、しかもそのコストをできるだけ下げていく、というなんともマニアックな要件に対して挑んだ軌跡を記載していきたいと思います。

ちなみに...

日本の企業名に関しては、国税庁が提供する法人番号システム Web-APIというものがあります。

商用利用についても、

「このサービスは、国税庁法人番号システムWeb-API機能を利用して取得した情報をもとに作成しているが、サービスの内容は国税庁によって保証されたものではない」

という旨を明記することで利用が可能になります。便利ですね。

ただし...。Web-APIは申請しアプリケーションIDが発行されるまでにいくつか手続きが必要なため、1ヶ月くらい開通まで時間がかかってしまうという問題があります。

今回法人番号システム Web-APIを使わず実装したのには上記の待ち時間を持つことが開発上できず、そのような制約下で開発を進める必要があったためです。

サジェスト機能ってそもそもどうやって作るものなんですかね

まずはサジェスト機能の要件を叶えるためのアーキテクチャを考えていきます。

例えばAlgoliaだと全文検索エンジンに入力に対してフロントエンドから直接細かく全文検索し結果をハイライトする形でサービスを構築することが推奨されています。(バックエンドを経由しない)

直接APIをフロントエンドから叩くことでバックエンドを経由しないことによるパフォーマンスの向上のほか専用のJSライブラリによるInstantSearch, Autocompleteの実装で開発速度の向上が図れるとのことです。

Frontend versus backend search

What architecture does Algolia use to provide a high-performance search engine?

ということで、これをスクラッチで作るとしたらざっくりこんな感じになるはずです。

  • 企業名の一覧をどっかから仕入れる
  • Elasticsearchのような全文検索エンジンを構築し、サジェストしたい単語(今回は企業名)をインデックスとして登録しておく
  • フロントエンドから直接上記のエンジンにクエリを投げ込んで検索結果にアクセスする

データソースはどうする?

上記にもちらっと記載しましたが、世の中には法人番号システム Web-APIという便利なAPIがありますが、この元ネタになっている情報をCSVやXMLでダウンロードできます。基本3情報ダウンロード

今回の記事では詳しく取り上げませんがこのCSVやXMLには企業名のほか、そのふりがなや住所、倒産や統合・社名変更に関する情報なども含まれております。

とくに今回のようなサジェスト機能ではふりがながあることがとても価値が高いです。ありがとうございます国税庁様。

フロントエンド実装

サジェスト機能のフロント側の一番の目玉はインタラクティブに検索結果が検索窓に反映され、Autocompleteする体験だと思います。

Autocompleteの仕組みを一から作るのは大変なのでCSSフレームワークに用意されているAutocompleteを賢く使うことが重要です。

例えばMaterial UIではAutocompleteが用意されているので賢く使って開発速度を落とさないようにしましょう。

上記の例ではoptionsに映画情報のサジェストの候補を突っ込むだけでよくあるサジェスト機能が作れます。

デバウンス処理

上記のMUIの例では、候補のoptionsが固定値ですが実際は入力された値から全文検索のクエリを実行し、結果をoptionsに都度入れ込む必要があります。

ユーザーの入力イベントはonChangeやonInputChangeが設定できるため、都度入力された文字列を取ることができますが、入力イベントが発生するたびにクエリが実行されてしまうと裏側の全文検索エンジンに大きな負荷がかかります。

そこで採用するのがデバウンス処理です。

デバウンス処理は、短時間に連続して発生する処理を間引くための重要な最適化テクニックです。

よってユーザーの入力がある程度落ち着いたタイミングで一発クエリを叩くことで全文検索エンジンに過剰な負荷を防ぐことにつながります。

もちろん全文検索エンジンの性能がよければ、デバウンスでまとめ上げる時間を短くすればするほど、細かくサジェストが反映されて便利ですが、ここは小さいコンテナで実現するので、負荷が心配です。

長めに300msくらいに設定しましょう。(最初のサジェストが出てくる体験が多少悪いですが、許容できる速さだと思います。)

比較としてデバウンス300msと50msの動画を撮ってみました。50msのほうが入力に対して機敏に反応してますが、日本語は変換などもあるため、少し余計な検索を実施してしまっている気もします。

300ms

50ms

デバウンス処理は、lodashのdebounceとReact Hooksを組み合わせて次のように比較的かんたんに実装ができます。

import { useCallback, useMemo } from "react";

import debounce from "lodash/debounce";

  const searchCompanies = useCallback(
    async (query: string) => {
        // 実際のクエリ処理
    }, [// 実際の依存配列] 
  );

  const debouncedSearch = useMemo(
    () =>
      debounce((query: string) => {
        void searchCompanies(query);
      }, 300), // 300msにした
    [searchCompanies],
  );

全文検索実装

さて、ここからがこの記事の真骨頂の如何にして限られたコンピュートリソースで全文検索を実施できるElasticsearchを作るかという話です。

AWSならOpensearch serverlessとか使えばいいんじゃない?

御名答。そのとおりです。"お金があれば"。"お金があれば"。"お金があれば"。

インデックス量やワークロードにかかる負荷など前提条件はさまざまですが、今回の企業名を検索する用途で使うとインデックス量がFargateのエフェメラルストレージ20GBに収まるため、FargateやFargate spotを使うことで圧倒的に安く作れます。

もちろん実運用だと可用性を考え冗長な構成を取ったりする必要はあるのであくまでもケースバイケースということはご承知いただくのと、お金があるのであれば運用コストを考えてOpenSearchなどのマネージドサービスに乗っかったほうが100%よいとは思います。

デプロイメントタイプインスタンスタイプ月額コスト
通常のOpenSearch Servicet3.small.search$40.992
Serverless(レプリカ有)2 OCU (0.5 × 4)$488.976
Serverless(レプリカ無)1 OCU (0.5 × 2)$244.488
ECS Fargate(vCPU0.5 / Memory 1G) * 1タスク$17.77
ECS Fargate spot(vCPU0.5 / Memory 1G) * 1タスク$5.47

このあと紹介する方法でインデックス作成・クエリ作成していくと、上記のECSのような小さなサイズのタスクでもそこそこいいパフォーマンスで動きます。

小さい小さいECSでサジェスト機能のインデックスを作ってみよう

先に答えを書きます。

「ワイルドカードを使わず、N-gram tokenizerを作って乗り切って」です。

どういうことかというと、ワイルドカード検索という仕組みがElasticsearchにあるのですが、こちらのディスカッションでも話題になってますが、

"query_string": {
  "query": "*sql*"
}

のように特に先頭ワイルドカードでクエリを実行すると転置インデックスへの検索に大きな負荷がかかってしまいます。

めちゃくちゃでかいインスタンスやクラスターを組んで実行していけば解決できそうですが、今回のような小さいコンテナで動かすには不向きです。

そこで、企業名を前方から細かくN-gram tokenizerを使って転置インデックスを作ってあげるのがよさそうです。

N-gramとは検索ワードを転置インデックスに配置する際に単語を単純に文字数ごとに分割し登録していく方法です。

こうすることで、「天」や「天下」と入力したタイミングで該当がヒットする(可能性が出てくる)ためすごくサジェストと相性がいいです。

ちなみに、図で緑色で色付けしたように先頭を固定したN-gramをEdge N-gramといいまして、今回は別のトークナイザーで処理させてます。

  • 前方一致の検索にはEdge N-gramを利用
  • あいまい検索にN-gramを利用
  • 両方のインデックスに対してマルチマッチング検索を実施し、Edge N-gramを優先的に評価することで検索精度を上げる

ことができます。

加えてサジェストでは入力途中など漢字への変換が入らない状態で検索が走ることがありますため企業名とは別にふりがなもN-gramで登録しておくことで入力途中にも対応ができます。

(図はEdge N-gramに限定して書いてますがN-gramも同様にインデックスしていきます)

また、入力途中の値が入ってくるパターンと完全に変換しきった文字列が入り乱れるサジェストでは、このN-Gram対象のフィールドを分けてマルチマッチング検索を使い、各結果に対して重み付けをことでよりサジェストチックな検索体験が実現できます。

インデックスおよびクエリの具体的な指定方法については後ほど細かく見ていきます。

インフラ構成

今回はこんな感じでECSにすっごいちっちゃいちっちゃいコンテナを立ち上げ、そこであらかじめ企業情報(企業名・住所など)をインデックスしておいたElasticsearchを立ち上げる構成を取ります。

バックエンドAPIとは切り離しかなり乱暴ですが直接フロントエンドからElasticsearchのクエリを実行させるようにさせてパフォーマンスと追加のバックエンドサーバー構築コストを抑えます。

ただし、そうするとエンドポイントを直接叩き、Elasticsearchのインデックスを好き勝手変更されたり消されるリスクがあるため、このあと後述しますが、インデックス時にインデックスを読み取り専用にしてしまいます。

オンラインでインデックスが更新されることがないのでクラスタ構成とはせず、ちっさいコンテナを必要数(ここでは1個だけ)立ち上げてALBなどでロードバランシングさせる構成となります。

ベースイメージを用意

今回Elasticsearchのベースイメージは docker.elastic.co/elasticsearch/elasticsearch:7.10.1 を使います。

本来は差分の企業情報が追加されたタイミングでオンラインでインデックスを更新したほうがよいとは思いますが、常に最新の企業情報が反映される必要が要件になかったり、そもそも基本3情報ダウンロードから順次XMLやCSVをダウンロードする必要があるため、オンラインで該当のインデックスが更新されることがないです。

つまり、コンテナ作成時に追加したインデックスをフリーズして使う、ということです。

ローカル or CI/CDでElasticsearchのイメージを立ち上げ、インデックスを作成後、Docker Commitを使ってイメージを固めてECRなどにPushする構成を取ります。

ECS FargateでElasticsearchを動かすTips

上記で作成したベースイメージをそのままECSで動かそうとする場合、タスク定義に注意が必要です。

linuxParameters.initProcessEnabledをtrueにせず、Elasticsearchのコンテナのエントリポイントに設定されているプロセス管理に従ってください。

もしinitProcessEnabledをtrueにしたままコンテナを立ち上げると、

To fix the problem, use the -s option or set the environment variable TINI_SUBREAPER to register Tini as a child subreaper, or run Tini as PID 1.

というエラーがでてタスクがすぐ異常終了してしまいます。

この問題がわからず半日潰しました.....。

kuromojiのインストールとElasticsearchの立ち上げ

今回企業名は一応日本語のため、日本語プラグインであるKuromojiをElasticsearchにインストールします。 docker.elastic.co/elasticsearch/elasticsearch:7.10.1にはインストールされていないため、 コンテナを立ち上げる際のエントリポイントを別途作成し、そちらでインストールとelsticsearchの立ち上げを実施します。

次のようなエントリポイントのShellを作成し、

#!/bin/bash
set -e

# プラグインがインストールされていない場合のみインストール
if ! [ -d "/usr/share/elasticsearch/plugins/analysis-kuromoji" ]; then
    elasticsearch-plugin install --batch analysis-kuromoji
fi

if ! [ -d "/usr/share/elasticsearch/plugins/analysis-icu" ]; then
    elasticsearch-plugin install analysis-icu --batch;
fi

chmod -R 777 /usr/share/elasticsearch/data

# Elasticsearchを起動
exec /usr/local/bin/docker-entrypoint.sh elasticsearch

docker runコマンドで直接指定して立ち上げます。

docker run -d \
       --name elasticsearch \
       -p 9200:9200 \
       -p 9300:9300 \
       -e "network.host=0.0.0.0" \
       -e "discovery.type=single-node" \
       -e "xpack.security.enabled=false" \
       -v $(dirname "$0")/elasticsearch-entrypoint.sh:/elasticsearch-entrypoint.sh \
    docker.elastic.co/elasticsearch/elasticsearch:7.10.1 \
    /bin/bash /elasticsearch-entrypoint.sh

スロークエリ監視

おまたせしました!事前準備は終わりです。

それではインデックスを作っていきましょう!

まず、リソース面を削りに削っていくためできる限り小さなコンテナにElasticsearchを展開していくためスロークエリを監視できるようにしておきましょう。

特にしきい値の秒数には根拠がないのでプロジェクトに応じて変更してください。

from elasticsearch import Elasticsearch

es = Elasticsearch(["http://localhost:9200"])

slowlog_template = {
    "index_patterns": ["*"],
    "template": {
        "settings": {
            "index.search.slowlog.threshold.query.warn": "10s",
            "index.search.slowlog.threshold.query.info": "5s",
            "index.search.slowlog.threshold.query.debug": "2s"
        }
    }
}

es.indices.put_template(name="slowlog-template", body=slowlog_template)

このあと作る企業サジェスト用のインデックスに直接設定してもいいですが、テンプレートとして入れておくことですべてのインデックスに設定が反映されるため個人的におすすめです。

リフレッシュインターバル

インデックスを作成する際に少しでも処理を軽くするためにインデックスのリフレッシュインターバルを無効化しておきましょう!こちらもすべてのインデックスへテンプレートで当てる形を取ります。

from elasticsearch import Elasticsearch

es = Elasticsearch(["http://localhost:9200"])
refresh_template = {"index_patterns": ["*"], "template": {"settings": {"refresh_interval": "-1", "number_of_replicas": 0}}, "priority": 1}
es.indices.put_index_template(name="default", body=refresh_template)

インデックス設定

さて、いよいよインデックスの設定とマッピングを作成し、インデックスを作りきりましょう!

ちょっと長くなりますが、先に今回のインデックス・マッピング設定を貼ります。

from elasticsearch import Elasticsearch

es = Elasticsearch(["http://localhost:9200"])

index_settings = {
    "settings": {
        "analysis": {
            "analyzer": {
                "kuromoji_analyzer": {
                    "type": "custom",
                    "tokenizer": "kuromoji_tokenizer",
                    "char_filter": ["icu_normalizer", "kana_converter", "remove_special_chars", "normalize_charset_filter"],
                    "filter": [
                        "lowercase",
                        "kuromoji_baseform",
                        "kuromoji_part_of_speech",
                        "ja_stop",
                        "kuromoji_number",
                        "kuromoji_stemmer",
                        "kana_readingform",
                    ],
                },
                "ngram_analyzer": {
                    "type": "custom",
                    "tokenizer": "ngram_tokenizer",
                    "filter": ["lowercase"],
                    "char_filter": ["kana_converter", "remove_special_chars"],
                },
                "edge_ngram_analyzer": {
                    "type": "custom",
                    "tokenizer": "edge_ngram_tokenizer",
                    "filter": ["lowercase"],
                    "char_filter": ["kana_converter", "remove_special_chars"],
                },
            },
            "tokenizer": {
                "ngram_tokenizer": {"type": "ngram", "min_gram": 2, "max_gram": 3, "token_chars": ["letter", "digit"]},
                "edge_ngram_tokenizer": {"type": "edge_ngram", "min_gram": 1, "max_gram": 15, "token_chars": ["letter", "digit"]},
            },
            "char_filter": {
                "kana_converter": {
                    "type": "mapping",
                    "mappings": [
                        # カタカナ→ひらがな(五十音順)
                        "ァ=>ぁ",
                        "ア=>あ",
                        "ィ=>ぃ",
                        (省略)
                        # 半角カタカナ→ひらがな
                        "ア=>あ",
                        "イ=>い",
                        (省略)
                        # 濁音、半濁音の処理
                        "ガ=>が",
                        "ギ=>ぎ",
                        (省略)
                        # 記号の正規化(重複を削除)
                        ".=>",
                        (省略)
                        # 長音符号の統一
                        "ー=>ー",
                        (省略)
                        # 空白文字の統一
                        " =>",  # 全角スペース除去
                    ],
                },
                "remove_special_chars": {"type": "pattern_replace", "pattern": "[・.,、。:;!?&(){}[]【】《》〔〕<>「」『』〈〉" "''=*+-∽~①②③④⑤⑥⑦⑧⑨⑩]", "replacement": " "},
                "normalize_charset_filter": {
                    "type": "mapping",
                    "mappings": [
                        # 全角英字(大文字)を半角に変換
                        "A=>A",
                        (省略)
                        "Z=>Z",
                        # 全角英字(小文字)を半角に変換
                        "a=>a",
                          (省略)
                        "z=>z",
                        # 全角数字を半角に変換
                        "0=>0",
                        (省略)
                        "9=>9",
                        # 全角記号を半角に変換
                        "&=>&",
                        (省略)
                    ],
                },
            },
            "filter": {"kana_readingform": {"type": "kuromoji_readingform", "use_romaji": False}},
            "normalizer": {"lowercase_normalizer": {"type": "custom", "filter": ["lowercase"]}},
        }
    },
    "mappings": {
        "properties": {
            "company_name": {
                "type": "text",
                "analyzer": "kuromoji_analyzer",
                "search_analyzer": "kuromoji_analyzer",
                "fields": {
                    "keyword": {
                        "type": "keyword",
                        "normalizer": "lowercase_normalizer",
                    },
                    "ngram": {"type": "text", "analyzer": "ngram_analyzer", "search_analyzer": "kuromoji_analyzer"},
                    "edge_ngram": {"type": "text", "analyzer": "edge_ngram_analyzer", "search_analyzer": "kuromoji_analyzer"},
                },
            },
            "company_name_hiragana": {
                "type": "text",
                "analyzer": "kuromoji_analyzer",
                "fields": {
                    "keyword": {"type": "keyword", "normalizer": "lowercase_normalizer"},
                    "ngram": {"type": "text", "analyzer": "ngram_analyzer", "search_analyzer": "kuromoji_analyzer"},
                    "edge_ngram": {"type": "text", "analyzer": "edge_ngram_analyzer", "search_analyzer": "kuromoji_analyzer"},
                },
            },
            "tax_id": {"type": "text", "fields": {"keyword": {"type": "keyword", "ignore_above": 256}}},
        }
    },
}

# インデックスの作成
if es.indices.exists(index="companies"):
    es.indices.delete(index="companies")
    es.indices.create(index="companies", settings=index_settings["settings"], mappings=index_settings["mappings"])

アナライザー処理の基本

補足的な話ですが簡単にElasticsearchのフィルター処理の簡単な流れを図示します。

このように最初にCharacter Filter、そのあとにTokenizerが入り最後にToken Filterが入る構成です。

なので、Character Filterでは分割前の文字列に対して、Token Filterでは分割後の文字列に対して正規化などの処理が入る形となります。

アナライザーについて

今回は次の3つで構成されています。

  • kuromoji_analyzer
  • N-gram
  • Edge N-gram

kuromojiでは日本語の形態素解析に合わせて分割するいわゆるよくあるテッパンの日本語全文検索の構成です。

こちらをベースのアナライザーとしつつ、加えてN-gram、Edge N-gramのアナライザーを使って前方一致のサジェストおよびあいまい検索を実現してます。

独自に作ったChar filter

ICU normalizerなどのプラグインのほか、今回のために独自で作成しているChar filterがあります。

  • kana_converter
    • 手作りのマッピングでおもに企業名のふりがな検索に利用
    • 全角カタカナ、半角カタカナをひらがなに正規化
    • 濁点、半濁点も正規化
  • remove_special_chars
    • ①②・などの特殊な記号文字が企業名に含まれるため、正規化の観点で削除
  • normalize_charset_filter
    • 全角英数記号を半角英数記号に正規化

ngram_analyzerとedge_ngram_analyzer

日本語形態素解析ベースで動くkuromoji analyzerの他、ngram、edge ngramのanalyzerも設定します。

それぞれのtokenizerの設定は次のとおりです。

ngramは、最小文字数2、最大文字数3で設定してます。これはあいまい検索のためにできるだけ単語を分割したほうが引っかかりやすいためです。 一方で、Edge ngramでは最大文字数を15まで拡大してます。これは前方一致の検索が次々と文章が打たれるたびに検索が一致で絞り込まれるようにするためです。

入力テキストN-gram (n=2)Edge N-gram (min=1, max=15)
株式会社株式, 式会, 会社株, 株式, 株式会, 株式会社
テスト開発テス, スト, ト開, 開発テ, テス, テスト, テスト開, テスト開発

インデックスの読み取り専用化

今回はフロントエンドからElasticsearchを直接叩く構成になっているため、エンドポイントを解読してElasticsearchがバックエンドとわかれば、いたずらされてインデックスが消されたり変更されたりする可能性があります。

そこでインデックスを読み取り専用にしておきます。

from elasticsearch import Elasticsearch

es = Elasticsearch(["http://localhost:9200"])

settings_body = {"index": {"blocks": {"write": True, "metadata": True, "read_only": True}, "auto_expand_replicas": "0-all"}}
response = es.indices.put_settings(index="companies", body=settings_body)

検索クエリ

検索クエリでは、さまざまな形式で登録したフィールドに対してマルチマッチで検索を実施します。

  • 検索対象
    • company_name (通常の会社名)
    • company_name.ngram (N-gram解析された会社名)
    • hiragana_name (ひらがな)

の3つのフィールドに対して検索します。

検索手法の組み合わせ

クロスフィールド検索

下記のようにクロスフィールドで各フィールドを合わせて検索します。

{
  "multi_match": {
    "query": query,
    "fields": ["company_name", "company_name.ngram", "hiragana_name"],
    "type": "cross_fields",
    "operator": "and"
  }
}

合わせて次のように各フィールドに重み付けを行うことで、あいまい検索より前方一致、前方一致より完全一致を優先的に順位付けするようになります。

{
  match_phrase_prefix: {
    company_name: {
      query: query,
      max_expansions: 10,
      boost: 3,
    },
  },
},
{
  match_phrase_prefix: {
    hiragana_name: {
      query: query,
      max_expansions: 10,
      boost: 2,
    },
  },
},
{
  match_phrase_prefix: {
    katakana_name: {
      query: query,
      max_expansions: 10,
      boost: 2,
    },
  },
},
{
  prefix: {
    "company_name.keyword": {
      value: query,
      boost: 2,
    },
  },
},
  • 前方一致検索

    • 会社名、ひらがな名それぞれに対してmatch_phrase_prefixを使用し、最大10件まで展開します。
  • 完全一致優先

    • company_name.keywordに対する前方一致検索を追加し、完全一致に近い結果を優先的に表示します。
    • 完全一致に近い結果にはboost: 3を設定、ひらがな検索にはboost: 2を設定してます。

細かい設定をすることで、より適切な検索結果の順序付けを実現しています。

こうすることでより体験のよいサジェストを提供できるはずです。

おわりに

本記事では、限られたリソースとコストのなかで企業名サジェスト機能を実装する方法について、小さい小さいコンテナを使った実装とそのインデックス例を取り上げました。

貧乏開発が必ずしもいいとは限りませんが、常にコスト意識を持つことの重要性を感じることができました。

時差ボケが抜けず、たぶん駄文になりましたが以上です。

tubone24にラーメンを食べさせよう!

ぽちっとな↓

Buy me a ramen

 Related Posts

hatena bookmark