diff --git "a/src/content/2024-11-29-\350\262\247\344\271\217\344\274\201\346\245\255\345\220\215\343\202\265\343\202\270\343\202\247\343\202\271\343\203\210\346\251\237\350\203\275\351\226\213\347\231\272.md" "b/src/content/2024-11-29-\350\262\247\344\271\217\344\274\201\346\245\255\345\220\215\343\202\265\343\202\270\343\202\247\343\202\271\343\203\210\346\251\237\350\203\275\351\226\213\347\231\272.md" new file mode 100644 index 0000000000..21c0185de4 --- /dev/null +++ "b/src/content/2024-11-29-\350\262\247\344\271\217\344\274\201\346\245\255\345\220\215\343\202\265\343\202\270\343\202\247\343\202\271\343\203\210\346\251\237\350\203\275\351\226\213\347\231\272.md" @@ -0,0 +1,636 @@ +--- +slug: 2024-12-05/貧乏企業名サジェスト機能開発 +title: "企業名サジェスト機能貧乏開発" +date: 2024-12-05T14:24:04+0000 +description: お金をかけず企業名サジェストUIを作ろう! +tags: + - Elasticsearch +headerImage: https://i.imgur.com/1UvKYKu.gif +templateKey: blog-post +--- + +お金をかけない開発ほど美しいものはないだろう....。 + +## Table of Contents + +```toc + +``` + +## はじめに + +おはようございます!!こんにちは!! +この記事は**AWS re:invent開催のラスベガス**からお送りしてますが、一切AWS関係ないです....!!! + +![](https://i.imgur.com/wuH4vwW.jpg) + +アドベントカレンダーを**re:invent**期間中に書こうという魂胆が裏目にでました! + +たくさん出てくるアップデートを検証しながら即記事にするという行為は執筆速度の遅い私には不可能な芸当でした。 + +時差ボケで頭が回らない中書いてるので駄文が続きますがどうぞよろしくお願いします。 + +別途、**海外カンファレンスに英語ができない人が参加することになったらどうすればいいの?** のTipsを記事で公開しようと思います。そちらもお楽しみに!! + +## 企業検索窓のサジェスト機能が作りたいよー! + +突然ですが、こんな感じの**企業名を入力することでどんどん企業名の候補がサジェストで出てくるようなUI**を作ってほしいとクライアントやプロダクトマネージャーから依頼されたらどうしますか...? + +![](https://i.imgur.com/1UvKYKu.gif) + +企業名のサジェスト機能を提供しているAPIや実装に組み込めるSDKで提供しているSaaSを探したり、(このブログの検索窓でも採用してますが)[Algolia](https://www.algolia.com/)などの全文検索SaaSを活用するなどを検討することでしょう。 + +しかしながらそのようなサービスを使って開発を進めることでAWSなどのインフラコストとは**別に月額の利用料**がかかってしまいます。 + +個人開発ならまだしも実業務での開発だと別のSaaS料金がかかってくることに対する交渉は色々な要因で難航することも多いでしょう。 + +また、企業名など公開されている情報だけでなく、**社内で抱えている非公開情報**に対してもサジェストをしたい、という場面もあるかもしれません。 + +そうなってくると、事前に用意されているSaaSを使うことはできなくなるわけです。 + +今回は、 AWSなどのクラウドサービスにスクラッチでサジェスト機能の全文検索の仕組みとフロントエンドを作成し、しかもそのコストをできるだけ下げていく、というなんともマニアックな要件に対して挑んだ軌跡を記載していきたいと思います。 + +### ちなみに... + +日本の企業名に関しては、国税庁が提供する[法人番号システム Web-API](https://www.houjin-bangou.nta.go.jp/pc/webapi/riyokiyaku.html)というものがあります。 + +商用利用についても、 + +「このサービスは、国税庁法人番号システムWeb-API機能を利用して取得した情報をもとに作成しているが、サービスの内容は国税庁によって保証されたものではない」 + +という旨を明記することで利用が可能になります。便利ですね。 + +ただし...。Web-APIは**申請**しアプリケーションIDが発行されるまでにいくつか手続きが必要なため、**1ヶ月くらい開通まで時間がかかってしまう**という問題があります。 + +今回[法人番号システム Web-API](https://www.houjin-bangou.nta.go.jp/pc/webapi/riyokiyaku.html)を使わず実装したのには上記の待ち時間を持つことが開発上できず、そのような制約下で開発を進める必要があったためです。 + +## サジェスト機能ってそもそもどうやって作るものなんですかね + +まずはサジェスト機能の要件を叶えるためのアーキテクチャを考えていきます。 + +例えば[Algolia](https://www.algolia.com/)だと全文検索エンジンに入力に対して**フロントエンドから直接細かく全文検索し**結果をハイライトする形でサービスを構築することが推奨されています。(バックエンドを経由しない) + +直接APIをフロントエンドから叩くことでバックエンドを経由しないことによるパフォーマンスの向上のほか専用のJSライブラリによる**InstantSearch**, **Autocomplete**の実装で開発速度の向上が図れるとのことです。 + +[Frontend versus backend search](https://www.algolia.com/doc/guides/building-search-ui/going-further/backend-search/js/) + +[What architecture does Algolia use to provide a high-performance search engine?](https://support.algolia.com/hc/en-us/articles/4406975268497-What-architecture-does-Algolia-use-to-provide-a-high-performance-search-engine) + +ということで、これをスクラッチで作るとしたらざっくりこんな感じになるはずです。 + +- 企業名の一覧をどっかから仕入れる +- **Elasticsearch**のような全文検索エンジンを構築し、サジェストしたい単語(今回は企業名)をインデックスとして登録しておく +- フロントエンドから**直接上記のエンジンにクエリを投げ込んで**検索結果にアクセスする + +![](https://i.imgur.com/OO7JYK3.jpg) + +## データソースはどうする? + +上記にもちらっと記載しましたが、世の中には[法人番号システム Web-API](https://www.houjin-bangou.nta.go.jp/pc/webapi/riyokiyaku.html)という便利なAPIがありますが、この元ネタになっている情報をCSVやXMLでダウンロードできます。[基本3情報ダウンロード](https://www.houjin-bangou.nta.go.jp/download/) + +今回の記事では詳しく取り上げませんがこのCSVやXMLには企業名のほか、そのふりがなや住所、倒産や統合・社名変更に関する情報なども含まれております。 + +とくに今回のようなサジェスト機能ではふりがながあることがとても価値が高いです。**ありがとうございます国税庁様。** + +### フロントエンド実装 + +サジェスト機能のフロント側の一番の目玉はインタラクティブに検索結果が検索窓に反映され、**Autocomplete**する体験だと思います。 + +**Autocomplete**の仕組みを一から作るのは大変なのでCSSフレームワークに用意されているAutocompleteを賢く使うことが重要です。 + +例えばMaterial UIでは[Autocomplete](https://mui.com/material-ui/react-autocomplete/)が用意されているので賢く使って開発速度を落とさないようにしましょう。 + +上記の例ではoptionsに映画情報のサジェストの候補を突っ込むだけでよくあるサジェスト機能が作れます。 + +![](https://i.imgur.com/1WX3Roe.gif) + +### デバウンス処理 + +上記のMUIの例では、候補のoptionsが固定値ですが実際は入力された値から全文検索のクエリを実行し、結果をoptionsに都度入れ込む必要があります。 + +ユーザーの入力イベントはonChangeやonInputChangeが設定できるため、都度入力された文字列を取ることができますが、**入力イベントが発生するたびにクエリが実行されてしまう**と裏側の全文検索エンジンに大きな負荷がかかります。 + +そこで採用するのが**デバウンス処理**です。 + +デバウンス処理は、短時間に**連続して発生する処理を間引く**ための重要な最適化テクニックです。 + +よってユーザーの入力がある程度落ち着いたタイミングで一発クエリを叩くことで全文検索エンジンに過剰な負荷を防ぐことにつながります。 + +もちろん全文検索エンジンの性能がよければ、デバウンスでまとめ上げる時間を短くすればするほど、細かくサジェストが反映されて便利ですが、ここは小さいコンテナで実現するので、負荷が心配です。 + +長めに**300ms**くらいに設定しましょう。(最初のサジェストが出てくる体験が多少悪いですが、許容できる速さだと思います。) + +比較としてデバウンス**300msと50ms**の動画を撮ってみました。50msのほうが**入力に対して機敏に反応**してますが、日本語は変換などもあるため、少し余計な検索を実施してしまっている気もします。 + +**300ms** + +![](https://i.imgur.com/TFdYdcS.gif) + +**50ms** + +![](https://i.imgur.com/fQe66X3.gif) + +デバウンス処理は、lodashのdebounceとReact Hooksを組み合わせて次のように比較的かんたんに実装ができます。 + +```typescript +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 Service | t3.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](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-ngram-tokenizer.html)を作って乗り切って」です。 + +どういうことかというと、**ワイルドカード検索**という仕組みがElasticsearchにあるのですが、[こちら](https://discuss.elastic.co/t/performance-regression-in-elasticsearch-6-5-using-wildcards/160781)のディスカッションでも話題になってますが、 + +```json +"query_string": { + "query": "*sql*" +} +``` + +のように特に**先頭ワイルドカード**でクエリを実行すると転置インデックスへの検索に大きな負荷がかかってしまいます。 + +めちゃくちゃでかいインスタンスやクラスターを組んで実行していけば解決できそうですが、今回のような小さいコンテナで動かすには不向きです。 + +そこで、企業名を前方から細かく[N-gram tokenizer](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-ngram-tokenizer.html)を使って転置インデックスを作ってあげるのがよさそうです。 + +N-gramとは検索ワードを転置インデックスに配置する際に**単語を単純に文字数ごとに分割**し登録していく方法です。 + +![](https://i.imgur.com/qH1P0nD.jpg) + +こうすることで、「天」や「天下」と入力したタイミングで該当がヒットする(可能性が出てくる)ためすごくサジェストと相性がいいです。 + +ちなみに、図で緑色で色付けしたように先頭を固定したN-gramを[Edge N-gram](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-edgengram-tokenizer.html)といいまして、今回は別のトークナイザーで処理させてます。 + +- 前方一致の検索にはEdge N-gramを利用 +- あいまい検索にN-gramを利用 +- 両方のインデックスに対して**マルチマッチング検索**を実施し、Edge N-gramを優先的に評価することで検索精度を上げる + +ことができます。 + +加えてサジェストでは**入力途中など漢字への変換が入らない状態で検索が走る**ことがありますため企業名とは別にふりがなも**N-gramで登録**しておくことで入力途中にも対応ができます。 + +(図はEdge N-gramに限定して書いてますがN-gramも同様にインデックスしていきます) + +また、入力途中の値が入ってくるパターンと完全に変換しきった文字列が入り乱れるサジェストでは、このN-Gram対象のフィールドを分けて**マルチマッチング検索**を使い、各結果に対して重み付けをことでよりサジェストチックな検索体験が実現できます。 + +![](https://i.imgur.com/1HLTLQL.jpg) + +インデックスおよびクエリの具体的な指定方法については後ほど細かく見ていきます。 + +### インフラ構成 + +今回はこんな感じでECSにすっごいちっちゃいちっちゃいコンテナを立ち上げ、そこであらかじめ企業情報(企業名・住所など)をインデックスしておいたElasticsearchを立ち上げる構成を取ります。 + +![](https://i.imgur.com/ioPuQHy.jpg) + +バックエンドAPIとは切り離しかなり乱暴ですが**直接フロントエンドからElasticsearchのクエリを実行**させるようにさせてパフォーマンスと追加のバックエンドサーバー構築コストを抑えます。 + +ただし、そうするとエンドポイントを直接叩き、Elasticsearchのインデックスを好き勝手変更されたり消されるリスクがあるため、このあと後述しますが、**インデックス時にインデックスを読み取り専用**にしてしまいます。 + +オンラインでインデックスが更新されることがないのでクラスタ構成とはせず、ちっさいコンテナを必要数(ここでは1個だけ)立ち上げてALBなどでロードバランシングさせる構成となります。 + +### ベースイメージを用意 + +今回Elasticsearchのベースイメージは [docker.elastic.co/elasticsearch/elasticsearch:7.10.1](https://www.docker.elastic.co/r/elasticsearch/elasticsearch:7.10.1) を使います。 + +本来は差分の企業情報が追加されたタイミングでオンラインでインデックスを更新したほうがよいとは思いますが、常に最新の企業情報が反映される必要が要件になかったり、そもそも[基本3情報ダウンロード](https://www.houjin-bangou.nta.go.jp/download/)から順次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](https://www.docker.elastic.co/r/elasticsearch/elasticsearch:7.10.1)にはインストールされていないため、 +コンテナを立ち上げる際のエントリポイントを別途作成し、そちらでインストールとelsticsearchの立ち上げを実施します。 + +次のようなエントリポイントのShellを作成し、 + +```bash +#!/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コマンドで直接指定して立ち上げます。 + +```bash +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を展開していくため**スロークエリを監視できるように**しておきましょう。 + +特にしきい値の秒数には根拠がないのでプロジェクトに応じて変更してください。 + +```python +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) +``` + +このあと作る企業サジェスト用のインデックスに直接設定してもいいですが、テンプレートとして入れておくことですべてのインデックスに設定が反映されるため個人的におすすめです。 + +### リフレッシュインターバル + +インデックスを作成する際に少しでも処理を軽くするためにインデックスの**リフレッシュインターバルを無効化**しておきましょう!こちらもすべてのインデックスへテンプレートで当てる形を取ります。 + +```python +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) +``` + +## インデックス設定 + +さて、いよいよ**インデックスの設定とマッピング**を作成し、インデックスを作りきりましょう! + +ちょっと長くなりますが、先に今回のインデックス・マッピング設定を貼ります。 + + +```python +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のフィルター処理の簡単な流れを図示します。 + +![](https://i.imgur.com/Nuvz5Ik.png) + +このように最初に**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がバックエンドとわかれば、**いたずらされてインデックスが消されたり変更されたりする可能性**があります。 + +そこで**インデックスを読み取り専用**にしておきます。 + +```python +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つのフィールドに対して検索します。 + +### 検索手法の組み合わせ + +#### クロスフィールド検索 + +下記のようにクロスフィールドで各フィールドを合わせて検索します。 + +```json +{ + "multi_match": { + "query": query, + "fields": ["company_name", "company_name.ngram", "hiragana_name"], + "type": "cross_fields", + "operator": "and" + } +} +``` + +合わせて次のように各フィールドに重み付けを行うことで、あいまい検索より前方一致、前方一致より完全一致を優先的に順位付けするようになります。 + +```json +{ + 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を設定してます。 + +細かい設定をすることで、より適切な検索結果の順序付けを実現しています。 + +こうすることでより体験のよいサジェストを提供できるはずです。 + + +## おわりに + +本記事では、限られたリソースとコストのなかで企業名サジェスト機能を実装する方法について、小さい小さいコンテナを使った実装とそのインデックス例を取り上げました。 + +貧乏開発が必ずしもいいとは限りませんが、常にコスト意識を持つことの重要性を感じることができました。 + +時差ボケが抜けず、たぶん駄文になりましたが以上です。 + +