検索システム開発に携わって得られた知見 〜もっと早くに知りたかった〜

本記事は、[情報検索・検索技術 Advent Calendar 2021 - Adventar](https://adventar.org/calendars/6430) および [ストックマーク Advent Calendar 2021](https://adventar.org/calendars/6612) の 13日目の記事となります。（ Advent Calendar の多重投稿禁止の規則はないものと認識していますが、もし問題があるようならば修正したいと思います）

# はじめに

　私は、2020年にストックマークに入社して以来、[Astrategy](https://stockmark.co.jp/product/astrategy) のバックエンドエンジニアとして検索システムの開発に携わってきました。検索システムといっても、もちろんElasticsearchなどの全文検索エンジンを利用する形のものではありますが、それなりに自分たちで作り込む部分もありました。そして、開発において、もっと早くに知っておけばよかった、もっと早くからこうすればよかったと今振り返ると感じる内容が多々ありましたので、今回はそのような、**「検索システムを開発する上で得られれた知見、もっと早くに知りたかった知識」**についてまとめたいと思います。

　Astrategyの全体像（アーキテクチャ）については、[弊社のテックブログ](https://tech.stockmark.co.jp/blog/astrategy_overview/)にてかなり大胆に公開しているので、今回は、検索部分に関する部分に特化してお話したいと思います。また今回は、[自然言語処理と情報検索についての発表](https://speakerdeck.com/kamata_shingo/zi-ran-yan-yu-chu-li-toqing-bao-jian-suo-nituite-nlp-and-ir)であまり触れられなかった「プロダクトとしての工夫（最後の章）」の箇所の補足をしたいという気持ちもあります。

　内容としては、**「検索システム開発一般になるべく当てはまる」ことをある程度抽象化してまとめています。なので、個別具体の専門的内容を期待する人には少し薄い内容に思えるかもしれませんがご了承ください。学術書等にはあまり取り上げられない現場の工夫、実際に経験した問題や対処をまとめました。** また、情報検索の基本的なことを抑えている方向けの記事になります。


- 今回話すこと
    - 検索クエリーと検索結果に関する一般的な注意点
    - 検索システムのアーキテクチャに関して
    - 運用に関して
    - Elasticsearchの使い方のポイント[^1]
        - ただし、今回はGet（read）系の機能に関してのみでのポイントになります
- 今回話さないこと
    - 論文の紹介などの高度かつ専門的な内容はありません
    - 検索結果の精度に関すること
    - マルチテナント周りの話
    - データ追加（write）まわりの話


[^1]: Elasticsearchが現在は一般的なのでそうしていますが、Elasticsearch以外の「索引型」で「分散型」の「全文検索エンジン」であれば同じようなことが言えたり、似た機能が存在すると思えます。そのため、今回取り上げる範囲においては、他の全文検索エンジンの場合でも置き換えて考えられる範囲かなと思います。

# 本題

徒然と箇条書きしていますが、盛り込みたい内容が多いのでご容赦ください

## 検索クエリーと検索結果に関する仕様編

検索クエリーと検索結果についての関係だけで、いろいろ知っておくべきこと、考慮しておくべきことなどがたくさんあります。ここでは、直接的なエンジニアリング（アーキテクチャやコーディング）が入らないレイヤーでの話をまとめたつもりです

### 全文検索システムのメンタルモデルに関して
まずは当然ですが、検索システムや全文検索エンジンに関するメンタルモデルをしっかりもつのが重要です。自分はここにまとめたメンタルモデルが最初はなかったあるいはぶれていたので、開発や運用時に戸惑ってしまったことがありました。

- 検索対象と取得（表示）対象の違い
    - （ドキュメント内の） 「検索対象フィールド」 = 「取得（表示対象）フィールド」 では必ずしもないことをしっかり抑えておく
    - 基本的には =  である場合が多いが、例えばElasticsearchの場合、(Inverted) Indexingをしたものを検索している時点で厳密には = ではない
    - 検索用に加工（例えば英文文章での単語の[見出し語化](https://en.wikipedia.org/wiki/Lemmatisation)）したフィールドを検索するが、取得するのは同じドキュメントの生テキストフィールドの方だったりすることもある
- 取得上限がある場合の振る舞い
    - だいたいの場合は上限を設定してそう
    - 例えば、取得ドキュメント数上限がm=100だが、全体ドキュメントが100万ある場合、  
   「人工知能 AND 自然言語処理」の検索結果m件 ⊄ 「人工知能」 の検索結果m件 になったりするので注意
        - ランキングによる上位m件が異なるため
  - 一般化すると、Query α, β, γ があり、β = 「α AND γ」の形で 「βの検索結果数」 > m みたいな場合に不思議な挙動がおきることがある
        - 一方で、「αの検索結果数」 ≦ m の場合（当然、「βの検索結果数」 ≦ m でもある）、直感通り、「βの検索結果数（α AND γの検索結果数）」 ⊆ 「αの検索結果数」と ( ⊆ を ≦ と読み替えるてもよく、その場合 「αの検索結果数」 ≦ m にも) なる
   - 他にも上限があると、Queryに対して直感的ではないことはしばしば起きる
     - （個人的には、数学で無限なものを扱うときに起きる非直感性と上限設定による非直感性はすごく似ている。。。）
  - ただし、そもそも全体のヒット件数を出さない（目立たないように出す）ことで、そもそもそこを意識させない方法などUX的な解決としてなくはない
        - が、だしたほうがユーザは安心感をもてる
- 検索式の演算子の優先順位
    - 上の例のように、ANR、ORを使う場合に、演算子の優先順位があることに気をつける
    - 例えば、[プログラミング言語のBool演算の優先度付けを踏襲](https://ja.wikipedia.org/wiki/%E6%BC%94%E7%AE%97%E5%AD%90%E3%81%AE%E5%84%AA%E5%85%88%E9%A0%86%E4%BD%8D#%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0%E8%A8%80%E8%AA%9E)するならば、 A OR B AND C の場合、正しい結合順位は (A OR (B AND C) ) 
- 分かち書きが行われる箇所
    - Query α, β に関して α ⊆ β みたいな文字列（例： β=廃棄プラスチック、α=廃棄プラ）において、必ずしも、βの検索結果 ⊆ αの検索結果 とはならない
        - 「醤油ラーメン（醤油/ラーメン）」と「ラーメン」のような例であれば、 「醤油ラーメン」の検索結果 ⊆ 「ラーメン」の検索結果になるはず
        - Query も Analyze（分かち書き） されて検索されることと、索引による検索の仕組みに注意
            - プラスチックの例だと、「プラ」と「プラスチック」は異なる Inversed Indexing の Key になっている

### 検索式の構文に関して
もし、検索クエリーに演算子とかが利用な可能な仕組みにしている場合、以下の点を注意したほうが良いです。

- 厳密すぎる構文ルールにしない
  - 誤った検索式入れても可能な限り結果が帰ってきている方がUXがよい
        - 例： 「人工知能 AND 」のように、演算子の引数が足りていないなら、「人工知能」だけで検索してあげればよい
    - 構文的な誤り自体は別途提示してあげたほうがよい
        - 上の例で言えば、AND への引数が足りないなどを一緒に返してあげるとよい
- Legal な検索式でもひょっとすると誤りがあるので、それを注意を出してあげる仕組みも作ると優しい
  - 例えば、 「脱炭素 OR SDGｓ OR エコ OR **OR**」 とある場合、 最後のORはつなげたいキーワードではなく入力ミスの可能性が高い
      - 構文レベルの補正
  - あとは、有名な「もしかして：ふんいき」みたいな訂正もあるとよい
      - 意味レベルの補正 
  - 二つの例とも、機械学習的にサジェストを学習することもできるかもしれないが、特に一番目は実装コストが安いわりには、わりとあるある系のミスだったりするので、機能を作っておいて損はない


### 検索結果の見せ方
- 集約して結果を見せたほうが良い
    - キーワードの集約
    - 記事の集約
   - 集約に関しては、[弊社テックブログ](https://tech.stockmark.co.jp/blog/astrategy_overview/#%E6%A4%9C%E7%B4%A2%E7%B5%90%E6%9E%9C%E3%81%AB%E5%AF%BE%E3%81%99%E3%82%8B%E3%83%86%E3%83%BC%E3%83%9E%E3%81%AE%E6%8A%BD%E5%87%BA)に例示している
- 検索結果は上位だけの提示でも良い
    - 結果が１万件あっても、ユーザは上位n%くらいしか見なかったりする
- 検索ヒット数は明示的に出したほうが、ユーザフレンドリー
    - 見るのは上位だけでも、ヒットした母集団がどのくらいあるかといった情報がある方が、ユーザが検索結果や自身の検索クエリーに納得や安心感を得られる

## システム編
システムアーキテクチャやコーディングする上で気をつけておくべきことをここではまとめます。

### アーキテクチャ
- 付加的な機能は別（マイクロ）サービスにしておくと稼働性の意味でGood
  - 例えば、 クエリー拡張機能 や 補完・サジェストなどの機能 など
  - 何かしらの理由でそれらの機能が死んでも、クリティカルな検索業務は続行できる
- 名寄せ などの機能をサービス全体で一貫性保つことはかなり難しいので設計は慎重に
  - 一方で、名寄せ機能などをマイクロサービスとして切り出すとそれはそれで冗長
        - [SPOF](https://ja.wikipedia.org/wiki/%E5%8D%98%E4%B8%80%E9%9A%9C%E5%AE%B3%E7%82%B9) になりそう
        - 余分なサービス間通信が発生してしまう
  - [サイドカー](https://docs.microsoft.com/en-us/azure/architecture/patterns/sidecar)にしておいて、論理的（コード）にはDRYだが、存在論的には複数あるという状態で運用しているが、いい感じ 

### Cache
- キャッシュは絶対に絶対に必要
    - 検索の内容には偏りがあり、平均応答速度を高めるにはその偏りを利用するのが有効
    - httpリクエストレベルでキャッシュをしておけば、バックエンドの計算やElasticsearchのネットワークレイテンシーも含めてキャッシュできるからめっちゃ( ・∀・)ｲｲ!!
- キャッシュにはデザインパターン的なものがあるので、必要なものを採用すべき
  - キャッシュは1箇所だけではなく、複数の箇所あってもよい 
        - 後述するが、Elasticsearch側でもキャッシュ機構があったりする
  - おすすめ記事： https://speakerdeck.com/moznion/pattern-and-strategy-of-web-application-caching 

### 検索対象データの工夫
- 英語の場合、見出し語化しておくと完全一致でもヒットしやすくなる
    - メンタルモデルのところで述べたように、検索のためだけのフィールドとしてlemma化したフィールドをもたせる工夫なども考えられる
- 構造化できるところは抽出して構造化しておく
    - 構造化されたデータのほうが検索も集計もしやすい
    - 全文検索のフィールドとして構造化するべき情報を検索するべきではない
- ドキュメントの構造はなるべくフラットにしたほうがよい
    - ネストがあるとスコア計算などが難しくなる 

### Elasticsearch の利用（Queryの組み立て）
- Queryの組み立てには、 Query Builde のライブラリをちゃんと使う
    - 複雑な Query になると、Json 形式の Query を手動で組み立てると（仮にある程度バックエンド言語のNativeなデータ構造を用いても）絶対にミスが発生する
  - Python なら、 [Elasticsearch DSL](https://elasticsearch-dsl.readthedocs.io/en/latest/) とか
- parser 作成が必要
    - ユーザフレンドリーな直感的検索式とElasticsearchでの検索式の表現は大きく異る
    - ユーザの検索式をElasticsearchのQueryに書き換えるのを手動でやるのはだいぶ難しいので、Parser必須
        - [Lark](https://www.lark-parser.org/ide/)とかだと手軽に遊べるからおすすめ
            - Pythonを使わなくても、どのみちBNFで抽象化して扱うだろうから、ここで検証することはそこまで無駄にはならないはず
        - 検索式にANDなどの演算子を許すならば、演算子の結合順を意識したParserにする必要があることも注意
            - NOT、AND、OR、（） くらいであれば、[四則演算のParser](https://newbedev.com/how-to-parse-mathematical-expressions-involving-parentheses)なんかを参考にするのが良いと思う
- ランキング結果に一貫性をもたせる必要がある
    - 分散型であるElasticsearchにおいては、場合によっては検索結果（ランキング）に一貫性が保証されない
    - 特に上位m件で足切りがある場合、検索したタイミングで結果が変わる、ユーザの検索システムに対する信頼性を失いかねない
    - preference のような値を設定して、結果が（母集団が同じ場合には）一致するようにしておくことが必要
        - [この件は記事を書いた](https://qiita.com/Shingo_KAMATA/items/c52d95ea6fedf9954038)


### バッチ処理をしておく
- 事前計算可能なことはオンライン処理ではなく事前計算をしておくのは鉄則なので、バッチ処理可能な部分はそれをしておく
    - ある意味、（転置）インデックスも事前計算とみなすこともできると思う
    - 情報検索において、事前計算できる複雑さと、オンラインでしか求められない複雑さをしっかり見極めるのは重要
- Astrategyの場合、類似記事の集約などを検索時に行うのではなく、事前にバッチ処理をしておいて、オンライン時には検索結果の類似記事IDを集約するだけで類似記事が集約できる仕組みを作っていたり、新規出現したキーワードをバッチ処理で日々見つけたりしている

## 速度編

システム編にまとめてもよかったのですが、速度はやはり検索システムにおいて最重要ともいえるので、別章としてまとめました。

### まずは計測から
- オンラインアルゴリズムをエレガントにする前に、ボトルネックを計測してそこから改善するべき
    - ともすると、いきなりオンラインアルゴリズムから人はやりがち
        - アルゴリズムの問題はコード的に目に付きやすいので
- Profile や log などからボトルネックをしっかりみつけて、そこから改善するべき
  - 0.5sのオンライン計算処理を80%高速化するより、5sのネットワークレイテンシを10%効率化したほうが全体への貢献は大きい

### Elasticsearch の利用（で速度に影響する部分）
基本的には、 https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.html にあることをしっかり守る

- 通信量が最適になるようなElasticsearchの利用する
  - 余計なデータを取得していたために、ネットワークレイテンシーが高かったことがあった
  - 可能であれば、バックエンドサーバではなくElasticsearchで計算結果をもとめ最低限のデータだけもらう
    - Includes や Excludes などを指定することで、必要十分なデータ取得をするようにする 
    - 特定のTermを集計したいならば、Aggregation等を利用すれば、場合によってはLogのオーダーにデータ量を圧縮できる可能性がある
    - Elasticsearch側から送られるデータを減らさないとネットワークIOは減らない（それはそう）
- [QueryCache](https://www.elastic.co/jp/blog/elasticsearch-caching-deep-dive-boosting-query-speed-one-cache-at-a-time) がされるようなQueryを心がける
    - Filter を使おう
- Elasticsearchのインフラの状態を最適にしておく
  - Shard数やShardサイズに問題があると最適な検索がされないので
  - https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html 等をしっかり読んでいい感じの設定にしておく

### その他バックエンドでの工夫
- すでに述べた通り、以下が重要
    - バッチ処理での事前計算
    - キャッシュによる結果の保存

## 運用編
リリース後の運用で気をつけるべき点も少し記載します。一応、サービス一般ではなく、情報検索特有なことに焦点をあててまとめました。

### 問い合わせの準備
- 検索結果に関する回答の準備
  - αというQueryの結果がAなのに、βというQueryの結果がBなのはなぜですか？といった類の問い合わせは絶対におきる
        - メンタルモデルのところで述べた、記事数上限や分かち書きにかんすることなどは、ありがち
        - 代表的なものは回答を事前に用意したり、QA例に載せる 
- ルールベース的なものに対する追加依頼は絶対に起きるので、それに柔軟に対応できる準備が必要
  - 例えば、XXX という文字列を YYY に名寄せできてほしいなど
  - 要望を取り入れた際にサービス全体で矛盾なく更新できること（仕組み）が重要
        - 上で述べたサイドカーだとコード的にはDRYなので実現しやすい
        - デプロイのタイミングを合わせる問題などはあるが
 - 顧客が自分で適用できる仕組みがあればそれが一番良い

### 辞書の整備
- 現代のNLPサービスであっても、どうしても辞書をつくるのは避けられない。。。
    - 使う側のメンタルモデルとしても、辞書が背後にあるということのほうがわかりやすい
    - 某NLPの大先生も辞書の整備は大事だとおっしゃられていた 
- 辞書は1つの目的ではなくいろいろな用途で複数必要となるので、辞書の管理はしっかりしておくべき
    - 辞書の名前を役割に合わせて的確なものにしておく
        - blockList なのか、hiddenList なのかちゃんと名前でわかるようにしたほうが良い
- 辞書の登録は柔軟にできる仕組みを用意しておく
    - 問い合わせのところにも書いたように、ルールの追加依頼などはわりとあるある


# まとめ
- 適切なメンタルモデルをもった上で開発するのが大事なので、検索システム、特に全文検索に関するメンタルモデルをしっかり持っておく
    - 特に、直感的ではない振る舞いをする部分には注意
    - 分かち書きの部分、記事数上限の部分には注意しておく
- Elasticsearchをちゃんと使う
    - 特に、以下の資料には目を通す
        - https://www.elastic.co/guide/en/elasticsearch/reference/current/tune-for-search-speed.html 
        - https://www.elastic.co/guide/en/elasticsearch/reference/current/size-your-shards.html
- 「速度の工夫」は「バックエンド一般の工夫」としていろいろ知見が世の中にあるのでそれを活用する
    - 情報検索特有な問題もあるけど、大体の問題はキャッシュとかバッチで解決できるかもしれない 
- 検索結果の妥当性に関する質問への回答準備も事前にしておくと、信頼される検索システムになっていく
    - ユーザは検索結果の妥当性納得性を強く気にする