はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

とあるタイプの検索サイトのElasticsearchを使ったサービス設計などに関する私見(2019年改訂版)

検索サイトで、どのようにElasticsearchを活かしてサイトをディレクションするかについて自分の意見をまとめてみました。

まとめてみたと言いいつつ、アタマの整理の過程をダンプしたという体裁になっています。... のでまとまってないかもしれません。

何かの勢いで書いてはならないことを書いてしまわないようにしたため、筆者のドキュメント力とは別の問題として、本来は具体的なもので述べるところ、抽象的な言い方になっているところが多々あります。

一方で、多少リアルな例にしたいと思い、ある程度シーンを絞って記述したところもあるのですが、抽象化との兼ね合いで、論理の飛躍や検証が甘いところもあると思います。

つまるところポエムになっているかもしれません。

また、2019年改訂版としていますが、改訂前のものがあるわけではありません。今後、世の進歩とともに、陳腐化するかもという言い訳でして、2019年現在のこういうスタイルもある...という意味です。

TOC

この記事で想定する検索サイトユースケース

検索を伴うサイトと言いましてもいろいろありますが、次の表の分類4、5あたりをうっすら想定してまとめています。

f:id:azotar:20190123220411p:plain

説明の都合、本来は同時にあり得ない、求人検索サイト、レストラン検索サイト、ECの商品検索サイトの検索要件で求められそうな要素(例. 検索BOXは募集職種とエリアの2BOX系。原本データは基幹システムなど上流システムで管理されている。... など)を混在で気まぐれで引用して説明を試みています。

なお、クチコミなどUGC由来のテキストデータも検索対象としてイメージしていますが、UGC自体がドキュメントの最重要コンテンツとなるような検索サイトではないこととします。これは、このようなUGCの検索サイトにおいて本記事の内容が無意味というものではないと思いますが、UGCメインのサイトの場合は、コミュニティ運営あってこそで、そこを含めて論じないと話が繋がらないためです。

とある検索サイトの悩み

なお、分類4などは他の例と別の悩みを伴うことが多いのではと思います。 (これがこの記事を書いた背景です。)

コンテンツデータが増えてきて、カテゴリ目次だけのディレクトリ型では立ち行かなくなったので検索エンジンを入れようという話になりました。

一方で、一見さんの多い(かもしれないサイト)としては、どんな検索ワードが使われるか予想がつきません。

データ件数は多少揃ってきているものの、当初のディレクトリ型の目次のワードが正しくアタマに入っていて、これらのワードを検索語として検索してくれる人がどれほどいるでしょうか。

precisionとrecall、F値の世界とは別の世界が広がります。

結構データ件数があり本当はエンドユーザーの期待にそえる(かもしれない)データはそれなりのはずだが、実際は検索結果が0件になってしまう。

ということで、ローンチやリニューアル後の検索結果の0件分析は必須だとして、そうは言ってもリニューアル前に逃した魚が大きい...とならないようにせっせと工夫をすることになると思います。

同じ「検索」でも実は結構違う

f:id:azotar:20190125003816p:plain

検索語の派生について

本来は、チューニングと言えばF値という話になるのでしょうが、思いの他、ユーザーはドキュメントに合わせた検索ワードで検索してくれませんし、検索結果の最初の数件しか見ない傾向がより強いように思います。

ドメインが限られた世界では、多少幅を持たせてヒットさせてやる方が重要なんだと思います。

どっちかと言えば、再現率重視ということになるのかもしれません。 まちがっているかもしれないけど、本当はこの辺を探してたんでしょ?というところを端っこにでもhitさせてやるような方向だと思います。

Elasticsearchなど昨今の検索エンジンの機能や仕組みに乗っかると以前はできなかったようなことが平易になります。よって、これらを活用した場合、例えば次のような戦略です。

  1. 形態素解析n-gramのハイブリッド。→ 漏れの防止。一方、前者の加点を高めとすることで、適合度が高そうなものの優先確保。
  2. エリア条件など、厳密な語が入力された方が確実なヒットにつながりやすく、かつある程度エンドユーザーも知識があるような検索条件の入力BOXについては、オートコンプリートを(他の案件より優先して)設置。
  3. ユーザーの入力した語から検索語を派生させて、それらでOR検索する。派生させる語はできれば実際にドキュメント中に現れる語にするのが良いでしょう。
  4. 検索語を派生させてOR検索すると当たり幅が広がります。加えて、複数フィールドを串刺しで検索するとなおさらです。幸い、今回例にしているような検索サイトの例でいうと、複数のフィールドの中にも、ここに検索語が入っていれば優先したいというフィールドがそれなりに存在します。レストラン検索で言えば店舗名、求人募集検索で言えば企業名といったユニークになるフィールドです。また、同じ観点の次点の例としては、すごく当たり前のことを言いますが、レストラン検索のジャンルや求人募集検索の職種や資格のフィールドも視野に入ります。 

ここで言う、検索語の派生は次の図のような塩梅です。

なお、複数ワード入れられた場合はどう考えるのが良いでしょうか。次の図では複数ワードの場合も織り交ぜて私見を述べています。

f:id:azotar:20190123225928p:plain

ユーザー入力の個々のワードはOR検索用に派生させつつ、複数の入力ワード間は世の感覚に合わせてANDにする、というやり方の場合少し工夫が必要かもしれません。 (Elasticsearchのmatch系は入力ワードに対してデフォルトでOR検索です。ここで主張しているように派生させつつ、元の複数の入力ワードの間はANDにするという場合は、boolクエリのmustとの組み合わせになります。)

エリア条件のオートコンプリートの例

オートコンプリートの例としてエリア条件の例を挙げたのでここで少し寄り道します。

エリア条件というのは求人サイトであれば、勤務地とか企業のビルの所在地を検索条件に使うというやつです。

Elasticsearchだと、オートコンプリートには、Suggestersシリーズのサジェスト専用のクエリがあり、その中でもザ・オートコンプリート用のCompletion Suggesterというのがあります。

ただし、Suggestersシリーズのクエリは、性能等を考慮して専用のインデックスを作成することになるのと、クエリのDSLの構成および返り値のJSON構造ともに、通常の"query"系の検索と少し形式が違います。

私なぞは、どうせ専門のインデックスを作成することになるなら、元ネタになる住所コードマスタを入手するついでに、これを活用して、都合の良いインデックスを作成して、通常の"query"系の検索で検索してしまえと考える派です。

ということで、ひとつの案ですが、次のような例を挙げてみます。

f:id:azotar:20190126152838p:plain

これ自体は、階層ごとのデータを作ってやってそれを、prefix検索すると(部分一致より高速と思われるというメリットも含めて)、オートコンプリートのリストの元ネタおよび付帯情報が得られるというノウハウかと思います。

だんだん確定する部分を伸ばしていくということにも向いています。

例えば、次のとおりです。

「みな」と入力

→東京都港区

→さらに東京都港区の配下の候補一覧表示され、もう少し詳細なエリアを指定したければ

候補から選択 or 「東京都港区しん」を入力して「東京都港区新橋」で確定

この実装パターンの発想は他にも有用な用途があるかもしれません(あると思っています)。いつかデザインパターンだかイディオムだかとしてまとめてみたいと考えています。

また、この例であれば、そもそも入力中のひらがなでも候補を出すことが可能です*1

なお、基本に戻って、Suggestersは、通常の検索とやや異なります。過去にまとめた記事があるのでリンクしておきます。

itdepends.hateblo.jp

memo: オートコンプリートのパターン

オートコンプリートの要件に従い、Elasticsearchのどの方法でオートコンプリートを実現するかというはもちろん重要です。

ただ、その前に、オートコンプリートのスペースが限られた領域で、どういうUIにするか/ありうるかという自分なりのパターン分けをしておくと、本当に必要な要件を見出しやすくなると思います。

私は、次の4つをイメージして、あるサイトにマッチしたものは、これらのうちどれか、どれの延長線上かという捉え方でアタマの整理をすることにしています。

f:id:azotar:20190126143426p:plain

※ 戦略2、戦略2’では、候補のリスト表示を階層表示風のイメージ図にしていますが、これはあくまで元のデータが階層的になっていることを活かした候補一覧を表示するという意味で示しています。必ずしも実際のUIとしてリスト内の表示を階層型にすべきという主旨ではありません。

検索プロセスの見極め

話を元に戻します。

次に、このサイトでの「検索プロセス」のカタチ・型を見定めます。 

Elasticsearchや最近のSPAのUI等、ブラウザ上・サーバサイドともにやれることの選択肢は広がってきていますが、私の主張は、ある検索いちサイトでは、全ての検索要件をこれだと決めたカタチ・型にそろえるように寄せていくことで、設計・実装してくスタイルが何かと取り回しがききやすく、得られる検索UXの満足度が高くなるんじゃないかというものです。

私のお気に入りの「検索プロセス」の型は次のような図のものです。私は空手の心得がある訳ではありませんが、空手の型・流派みたいなものなのかもしれません。

※図中の点線で示している部分は、オプション(無くても機能は成り立つが、これらの扱いは議論して、重要だと考えるなら機能や仕掛けを設けること筆者はオススメしているもの)です。f:id:azotar:20190124005443p:plain

私のお気に入り検索プロセス解説

図の中で、ここまで述べたこと以外に、あるいは述べたことに重ねて、それとなくマイベタープラクティスとして示唆している点は次のとおりです。

  1. 全文検索には、(AND検索を主軸にすること、アプリで検索語派生する方針もあり、)multi_matchを使おう。multi_matchでヒットさせるフィールドの並べ方(正確にはboostの係数)で、優先フィールドを調整しよう。このフィールドの配列は、クエリビルダーの外で管理して、並び替えや係数を調整しやすいようにしよう。
  2. オーガニック検索は、複数ワード入れられた場合はAND検索です。ただし当たり前ですが、AND検索は検索結果が絞り込まれるので、利用者の想定以上に検索にヒットしないという結果になることもしばしばです。ということで、検索結果が0件の場合などにレスキュー処理(図中の二次検索など)の取り扱いを考えてやりましょう。まあ、実際にどこまで対応するかは別として、「救済」が必要なのかどうかというのは議論のポイントになると思います。
  3. 「救済」のための二次検索は、一例としては、派生したワードを含めて全てORで検索します。Elasticsearchの場合、1の例でのbool.mustをbool.shouldに組み替えるだけで全てをOR検索にするクエリが出来上がります。
  4. 図ではどちらと結論づけていませんが、レスキューの際に、検索結果が0件だったことを示すのか、なんとか検索結果が得られるような二次検索の検索結果を代わりに返してやるのかというのも「型」としての見定めどころだと考えています。

コラム: ElasticsearchのCompound query clausesとLeaf query clauses、match系・term系のクエリの検索ワードと対象フィールドの指定の仕方

上記では、Elasticsearchを下地にした、検索プロセス云々を論じて、極力リアルにアーキテクチャを示したつもりです。

ElasticsearchのクエリDSLの記法でどんなものがあるかを念頭に置いた上で、その中でもこれをこう組み合わせて...という組み合わせの結果です。

経過を全てお見せするのは難しいですが、組み合わせの試行錯誤の素材になった、ElasticsearchのクエリDSLのどの記法で、レコードのどのフィールドを指定の検索ワードで検索しにいくことになるかを復習しておきます。

f:id:azotar:20190130000925p:plain

私などは物覚えも物忘れもよろしくないので、時々、上のような図で再確認をすることにしています。

※ 先に述べた検索プロセスの型を持ちましょうというのは、このようなどのクエリをどう使うみたいなところの逐次のモグラ叩きから解放されるのではという考えもあって主張しているところもあります。

また、参考に過去記事をリンクしておきます。

itdepends.hateblo.jp

itdepends.hateblo.jp

検索のオニオンアーキテクチャ

ちなみに、このあたりで、アーキテクチャっぽい図をまとめて、チーム内の役割分担や責任範囲をすり合わせていくと良いと思います。

個人的には、オニオンアーキテクチャ風の書きっぷりの図でまとめると、余計なバイアスが入らない範囲でアソビを残しつつ気持ち合わせしたい程度には言いたいことが言えて良いのではと感じています。

f:id:azotar:20190130015302p:plain

実装や利用フレームワークはこの絵では規定しないのですが、ドメイン知識を示唆できるのと、どの塊・どこが内部/外部のインタフェースとなるかというところや、要件に見合った依存関係の方向を示せるので、参画メンバのキャラクターにもよりますが、この類の図を用いて認識合わせするのが有効だと考えています。

検索プロセスの設計アプローチイメージ

前の説の検索プロセスの型を定めると良いという話の少しだけ続きです。

自分(?)の得意な型を定めるというような言い方をしていますが、つまるところ、Elasticsearchのクエリビルダーの機能はできるだけ1機能に集約しようという主旨です。

↓こんな感じです。

検索機能の設計アプローチイメージ

f:id:azotar:20190125010120p:plain

※ 検索サービスや検索メニューとありますが、ここではサービスとメニューという用語にそれほど厳密な意味はありません。前者が企画目線で後者はやや機能や実際の操作方法目線ぐらいの意味です。

前の方で述べたことの言い直しですが、そのサイトの検索サービスのEsクエリのテンプレートおよび検索プロセスはできるだけ骨組みとしては一本に集約するようにしましょう。 逆に、それ以外はアドホックに、付け足ししていく方針で、設計も実装もアプローチしていくと、維持や継続的な改善含めて費用対効果が高いような検索サイトとして運営しやすいのではないか、という主張になります。

検索クエリの構造の標準化

クエリビルダーを構築しましょうのように申していますが、この記事のターゲットの検索サイトの範囲であれば、無理に汎用的なものにする必要もないと考えています。

といいますか、汎用クエリビルダーをどこまでどうしようかというのは、開発効率やもろもろ考えるとアーキテクトの人のウデの見せ所ではあると思いますが、一方でここまでのとおり、検索プロセスの型を定めるとおおよそクエリのDSLのテンプレートのJsonのカタチが決まるので、検索メニューに応じて異なる部分を差し替えるというやり方で泳げる(つまり、クエリビルダーClassのようなものでなんらかモジュール化はした方が良いものの、じゃあそれが、他の検索サイトPJで変更なく使える程のものにする必要はないのでは?)という 範囲になります。

SQLもそうですが、検索する・リストを返すための「クエリ」そのものは、文字列構築の魔法使いの世界だと思います。

宣言型で実現できることによる見通しの良さと、複数の検索メニューのうち共通する部分をドメイン知識として、多少むき出しでも良いので、プログラムに見栄えに可視化して残すというのが少なくともこの界隈の2019年1月時点ではうまい方法のように感じています。

ということで、先述の私のお気に入り「検索プロセス」の型の場合の、オーガニック検索のクエリDSLの「テンプレート」は次のようになります。

f:id:azotar:20190130003116p:plain

なお、ここに公式のElasticsearch Clientsの一覧がありますのでリンクしておきます。

上記のような主張のとおり、私自身は例えば、Javaであれば、Java REST Clientの「High Level」よりも、「Java Low Level REST Client」の方がクエリDSLを自分で取り回しやすく、プログラムの見栄えにドメイン知識がにじみ出る分、好んで利用したい気持ちです。

www.elastic.co

補足1: 型はめ論法の落とし穴に陥らないように点検しながら進める

ここまで、型を決めろとか、型が決まれば全て解決というような言い方をしていますが、念のため自問しておきます。

手順やプロセスありきで進めて、それが当てはまらない場合を見逃してしまうという、エンジニアリングあるあるにならないように気をつける必要はあります。

確かに、実際のところはケースバイケースです。 ただ、冒頭に述べたように、どのような種類の検索サイトなのかを客観視した上で、いけそうだということであれば、型ファーストで進めていくことも可能かなと考えています。

詳しくは説明しませんが、私なぞは、例えば、次にあげたような表を埋めていくなどしながら、要件とElasticsearchの機能や仕組みのマッピングをとっていく中で、イケそうか点検しながら検討することにしています。

f:id:azotar:20190125010105p:plain

f:id:azotar:20190125010112p:plain

点検のポイントとしては....

  1. 優先するフィールドを見極める
  2. (特に優先する)フィールドについてアナライザーのパターンの最小公倍数を明らかにする
  3. term、range、geoXXのような、特定の属性に対しての厳格な検索の要件を洗い出す。
  4. メインクエリの検索かファセット(aggsやpost_filter)での絞り込みかの役割分担のおおよその目処をつける。

です。

また、余力があれば、開発初期のダイナミックmappingテンプレートの設定をどうするかも見極めると良いでしょう。

...と言いながら、ひとまず機会的に次などを設定しちゃいますが....

PUT my_search_index
{
  "settings": {
    "analysis": {
      "tokenizer": {
        "my_kuromoji_tokenizer":{ "type": "kuromoji_tokenizer", "mode": "search" },
        "my_ngram_tokenizer":{ "type": "ngram","min_gram":2,"max_gram":3, "token_chars":["letter","digit" ]  }
      },
      "analyzer": {
        "my_ja_default_analyzer": { 
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": ["icu_normalizer","kuromoji_iteration_mark","html_strip" ],
      "filter": [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_number", "kuromoji_stemmer" ]
        },
        "my_kuromoji_readingform_analyzer": {
          "type": "custom", "tokenizer": "my_kuromoji_tokenizer",
          "char_filter": [ "icu_normalizer","kuromoji_iteration_mark","html_strip" ],
          "filter": [ "kuromoji_readingform", "kuromoji_part_of_speech", "ja_stop", "lowercase", "kuromoji_stemmer" ]
        },
        "my_ngram_analyzer":{ 
          "type":"custom", "tokenizer":"my_ngram_tokenizer",
          "char_filter": ["icu_normalizer","html_strip"], "filter": [ ]
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "dynamic_templates": [
        {
          "hybrid_style_for_string": {
            "match_mapping_type": "string",
            "mapping": {
              "analyzer": "my_ja_default_analyzer",
              "fielddata": true, "store": true,
              "fields": {
            "readingform":{ "type":"text", "analyzer":"my_kuromoji_readingform_analyzer" },
            "ngram":{ "type":"text","analyzer":"my_ngram_analyzer" },
        "raw": { "type":"keyword" }
              }
            }
          }
        }
      ]
    }
  }
}


そもそものMapping全体については、雰囲気だけですが、過去にまとめたものがありますので、リンクしておきます。

itdepends.hateblo.jp

補足2: bool、shouldの捉え方

点検では、アタマの片隅で、要件を実現するクエリDSLを適宜思い浮かべながら進めていくのですが、この際、boolとshouldについてこのような理解をしておくと、混乱しにくいのではというポイントを以下に図にしてみました。

shouldはOR検索条件のようで必ずしもそうではない f:id:azotar:20190130011252p:plain

SQLをビルドする時と異なり、ElasticsearchのクエリDSLを見定める際は、まずmustで考えて、違和感があるものはshouldに追い出す...ぐらいが良いように思います。.... という話。


検索は検索なんだけど、軸となる検索条件の塊を組み合わせて元の集合からフィルター*2しているようなイメージで設計しよう f:id:azotar:20190130011308p:plain

補足3: ファセットとpost_filter

検索結果のファセット(Elasticsearchであれば、aggsで実現するのが定石)とpost_filterの見定めを行います。

ファセットの大まかなパターンとしてどのようなものがあるかの自分の中でのパレットを描いておくのが良いと思います。概念的なものよりも、UIのイメージで捉えておき、このフィールドのファセットは、スタイルAのドリルダウン...などと整理していくのが良いでしょう。

f:id:azotar:20190130021530p:plain

私はpost_filterについては次のような理解(検索条件のピボットを重視するUX/UIの場合に重宝する)でいます。 f:id:azotar:20190130011336p:plain

点検の結果としてのオーガニック検索のクエリDSLのテンプレートブラッシュアップ

点検を通して、少し前で述べたオーガニック検索のクエリDSLの「テンプレート」は、次のようなより具体的なものにブラッシュアップされるとともに、文字どおりこのテンプレートの応用の範囲で今回のサイトの要件を現実的にコントロールしていけそうだという目処が立ちました(というストーリーです)。

f:id:azotar:20190130004459p:plain

また、上記の図のままではないですが、(補完がきいたとしても)初出のクエリDSLJSONをゼロから手打ちするのはめんどくさいので、シンタックス的に誤りのない(ハズ)のある程度盛りクエリのテキストを貼り付けておきます。

POST car_and_animal/_search
{
  "size": 10,
  "from":1,
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "bool": {
                "must": [
                  {
                    "boosting": {
                      "positive": {
                        "multi_match": {
                          "query": "鳥 産む 食べる",
                          "fields": [
                            "content_ja",
                            "content_ja.meishi"
                          ],
                          "boost": 1,
                          "operator": "or"
                        }
                      },
                      "negative": {
                        "match_all": {}
                      },
                      "negative_boost": 0.2
                    }
                  }
                ]
              }
            }
          ],
          "should": [
            {
              "match": {
                "title_ja": {
                  "query": "ヒゲワシ カリフォルニア",
                  "operator": "or"
                }
              }
            }
          ]
        }
      }
    }
  },
  "aggs": {
    "tokuchougo": {
      "significant_text": {
        "field": "content_ja.doushi_nado",
        "size": 10,
        "filter_duplicate_text": "true",
        "background_filter": {
          "term": {
            "content_ja": "車"
          }
        }
      }
    }
  },
  "_source": [
    "title_ja"
  ]
}

※このクエリ自体には意味はありません。

設計の流れ・アプローチの全体像のおさらい

ポエム度低減のため、できるだけElasticsearchの例を先行して述べてきました。気まぐれな流れで申し訳有りませんが、実は次の図のような流れで設計なりなんなりの検討を進めているというストーリーにそったものでした。

f:id:azotar:20190125005838p:plain

※ 図中の 1、2、3、A、C あたりをつまみ食いして述べてきたつもりです。

いうまでもなく、これが唯一無二の方法でもないですし、この流れにそえば手戻りも最小だったり失敗しないというものではないです。

ただ、試行錯誤でぐるぐる回ったりしている部分はあるにせよ、経験豊富な方はみなさんおおよそこんな感じで検討されているではないでしょうか。

さて、図で示した、太線で結ばれている部分が、検討のメインフローだとすると、メインフローに寄り添ったトピック:図中の【5】のところが、今回テーマにしたタイプの検索サイトでは見逃せない要素だと考えています。

次項では、この部分についてだらりと述べてみます。

検索用ドキュメント標準化(仮称)の営み

ElasticsearchではJSON形式のドキュメントをざっくりインデックスに投げ入れることができます。

また、LogstashなどログアグリゲーターやIngest Nodeなど、あるいは中の仕掛けはよく知らないのですがkibanaのデータロードツール的なものなどで、データソースのデータを取り扱いやすいデータ形式にお手軽に変換してやることもできますので今や便利づくしです。

とは言え、なんだかんだいっても半定形とも言えるログ型のデータと異なり、今回対象としているような検索サイトの例だと、データソース側はいびつなビジネスロジックの結晶とも言える形式になっているものもしばしばあります。せっかくのIngest Nodeなどの仕組みはありつつも、軽く下調べした際に妙な臭いが嗅ぎとれる場合は、自前のアプリケーションで変換してやる方に舵を切るというスタンスもありだと思います。

つまりこういうことです。↓

f:id:azotar:20190125014234p:plain

ともあれ、自前のアプリで変換等してやるかはともかく、ある程度腰をすえて検索エンジンを導入する場合は、検索要件に適した、かつ極力検索結果のリスティングにそのまま埋め込むだけで利用者向けの表示がなりたつようなドキュメントのカタチにしてやるのが遠回りなようで近道だということは言えると思います。

注:

この記事では、検索用ドキュメント標準化(仮称)的なものは、自前アプリでできるだけ頑張ってやる方向で割り切った方が今回のテーマにした用途には合いそう、というストーリーでこの後も続きます。

とは言え、ググった流れでたまたまこのページにたどり着いた方向けに誤解のないように、念のため公式のIngest Nodeのリファレンスへのリンクと、このブログの過去記事で、Ingest Nodeの勉強のために早見表をまとめたので、こちらのリンクを付けておきます。この記事のノリにしっくりこない方は、車輪の再発明にならないように、公式リファレンス等もあたってみてください。

www.elastic.co

itdepends.hateblo.jp

補足1: 標準化した方が良いパターンについて

前述の図にそれと無しに書きましたが、自前アプリで標準化しても良いかも...の見極め観点として、図の右上あたりですが、標準化パターンとして私の考えで分類して見ました。

特に、重要な見極めポイントではと考えているのは、図中に「データソース側固有のビジネスロジックの圧縮」としたものです。

これは、例としてしっくりくるか分かりませんが、例えばの例でいうと、RDBのあるテーブルで、カラム1の値がAの場合、カラム2、3、... の値がQならこのレコードでは「カラム2」の意味は「XXX」だが、カラム1の値がBの場合、カラム2の値がやはりQだったとしても、このレコードでは「カラム2」の意味が「YYY」となるというような場合に、Elasticsearchのインデックスにこのままのフィールド単位でデータを持ち込むことは現実的でありません。

なにやら変な例を持ち出したので、そんな経験があるんかいと思われそうですが、それはともかく、似た例でもう少し軽めのものとしては、◯◯項目群有効フラグのようなカラムがあって、これが「無効」の場合、関連するカラム2、3は値が登録されていても表の検索サイトでの表示には使ってはならん...ぐらいのことは歴史のある上流システムではあるあるだと思います。

標準化パターンとしてあげたもののについて、ざっくりした例を次の図に示します。

f:id:azotar:20190126065358p:plain

また、これらの解説を長々とまとめてみたのですが、本当に長くなったので付録に回しました。

検索用ドキュメント標準化パターン解説

補足2: インデックス更新のフレームワーク

自前アプリでの標準化に近いところの整理ポイントとして、(ビジネスルールで許容される範囲の割り切りはしつつも、)表示内容の整合性確保のために、更新トランザクションを確立したいという要件に対応するために、インデックス更新のやり方にこだわりがある場合があります。

こちらもあまり言うとボロがでそうなので、雰囲気の範囲で自説を述べておくと、更新処理のフレームワークを確立しておくと良さそうです... というところと、私の好みは次の図の3つ目の方式です。

f:id:azotar:20190130235537p:plain

ちなみに、図の3つ目の方式では、それとなく論理削除方式を用いていることを示唆しています。これは何かとアンチパターンに繋がりうる論理削除方式を推奨している訳ではなくて、フレームワークやなんらかビジネスルール等の都合で、論理削除方式を取るなら、こういうやり方もあるのではという例です。

トランザクションには、「結果整合性」重視というスタンスもありますが、この例だとインデックスの更新に関して「結果整合性」の視点で見るのがテック的に正攻法の視点だと思いますが、一方で、次のざっくり「検索サイトの機能構成イメージ(※気持ちECサイトを想定しています)」図で言うと、検索サイトのビジネス目線でのトランザクションは、商品ページで発生します。

f:id:azotar:20190130234816p:plain

つまるところ、ビジネス的な「結果整合性」は商品ページで確保する、例えば、その商品の在庫が切れてしまった時には商品ページでエラーにする、といったアプローチを前提に、検索インデックスは論理削除方式にするというスタンスもありでしょう。また、論理削除方式をアンチパターンと知らしめている一つの理由に、検索時に「論理削除 ≠ ON」のデータを検索しなければならないというものがありますが、後々物理削除されることを前提に、このデータは現在カートに入れることはできませんが、本来の商品説明としてはこんな感じですという情報として検索結果リスティングには成り行き表示することにして、検索時には論理削除を意識しないという手もありそうですね。

なお、ここでは、データソースが一元化されていてかつデータソースの更新=検索エンジンの即時反映とすべきのようなものではなく、多少ひねくれたかつデータソースが複数ありこれらの更新契機が様々なものを想定してこのような話を書いています。前者のような素直でまじめな構成の場合のトランザクション制御としては、「version」を使った楽観的ロックでの制御ができます。根元の要件が素直な場合はversionを使ったロックを行うものと思われます。

この「version」については、ググラビリティが低いのと、公式リファレンスのどこに書いてあったかわかりにくいので、自分メモも兼ねてリンクをしておきます。

補足3: 自前アプリでの検索用ドキュメント標準化のフレームワーク

話はドキュメント標準化に戻りますが、ドキュメント標準化処理もフレームワーク化しておいた方が良いでしょう。トランザクションや例外処理のフレームワーク化という意味もありますが、ここでは、私の芸風にありがちな、よくあるパターンの骨組みを見つけて、あとは多少ナイーブで冗長でも、最小限のDSLで実現できないかを模索して、それを型にしたものをフレームワークと呼んでいます。

例えば、次の図のようなものです。

f:id:azotar:20190131002035p:plain

標準化パターンは、項目単位のものと、項目またがりのものと、項目は意識せずドキュメント内の全項目に適用するという種類に分けられるので、これらをパイプラインで最大2回並べるようなUNIXのパイプ処理風の考え方で、それぞれのノードにコンフィグやプラグイン的に編集ロジックを付け足しできるようにしておくという考え方にしておけば、大半のものは対応できると考えています。

あと、そういう世界もあるという話で申しますと、これぐらいのバランスの方が、(何かと力の及ばない)上流システムの設計書(≒EXCELの表)などから、これらの変換処理を生成したり、Elasticsearchのインデックスフィールド項目の仕様書的なものを作成して上納のための事務作業の手間減らしにはちょうどよかったりするというところもつぶやいておきます。

補足4: 同じドキュメントでも項目群別に別インデックスにする考え方もある

最近のElasticsearchでは、1インデックスに1タイプが基本になったので、業務アプリ屋的にはむしろ迷いがなくなってありがたい状況です。

また、負荷分散や可用性方面の話、クラスタやシャードやノードといったところの世界観はElasticsearch自体が面倒を見てくれるので、(Elasticsearchに限らずですが)油断や過信は禁物なものの、1インデックス1タイプでお任せ設計を基本線とすれば良いと思います。

ただし、それでもリソース節約や外部要因(おもに上流のデータソースの事情含む)に引きずられて、ユースケース的には同じタイプなものの、実は全フィールドに値が入っているようなレコードはまれで、領域だけ確保されてスカスカのインデックスとなるという場合もあるでしょう。

スカスカのインデックスが本当にリソースが大ムダになるようなElasticsearch/luceneの仕様かどうかは確かめていないのですが、実際に期待した性能が出ない場合などに札束で殴るわけにもいかずという場合に、アプリでのインデックス分割を考えることになるかもしれません。 ということで、(下のレイヤのシャードやノードの分散・分割はやはりElasticsearch/luceneの世界なので)Elasticsearchのアプリレベルでのインデックス分割が効果がでるかはさておき、こういう分割パターンがあるよねというパターンとチーム内で議論できるようなボキャブラリーをしたためておくのは悪くないと思います。

同じ「タイプ」を別インデックスに保持する方式バリエーション f:id:azotar:20190126162046p:plain

※この分野では、水平分割・垂直分割という用語があるように記憶していますが、ここでは、分割方法の分類よりもどこにアプリケーション視点での分割できる切れ目を作るかという視点なので、自分で言い回しを定義して呼ぶことにしました。

また、この節の主題とはやや異なるのですが、「インデックス分割」から連想される、別のフォーメーションも示しておきます。

RDBのマテリアライズドビュー風に派生して併用 f:id:azotar:20190131014936p:plain

検索結果画面のUIについて

長くなったポエムもこれで最後です。

ここまで、色味やトーンなどのデザインの機微はあるものの、ワイヤーフレームレベルではすでに決まったものという体で特にふれなかった、検索結果画面のUIについてです。

検索結果画面とはつまるところ何なのか

次の

itdepends.hateblo.jp

という過去の記事でもそれとなく主張したのですが、検索サイトの少なくとも初期ローンチ時のフォーマットは他のサイトの標準的なUX/UIに慣れた人向けに、よくあるパターンにはめ込むことになると考えています。

この姿勢はひょっとすると画期的な案を潰しかねない面もありますが、逆に言うと、奇をてらったものに過ぎないのか本当に画期的な案なのかは、一旦「よくあるパターンにはめ込み」した上でそれと画期的な案のプロトタイプを比較するということで炙り出されるとも言えそうなので、そう言う意味でも思考停止にならない程度に、標準パターンにはめ込むことは悪いものではないでしょう。

ということで、パターンはめ込みスタイルで検討していけばいいじゃんというのがこの記事での再度の主張なのですが、もちろん実際の検索サイトでは、色味やトーンなどの見栄えや、ファセットや検索BOXの配置、今であればアニメーションなどを練りこんでいく必要があり、ここの試行錯誤がサイトの成功要否に関わりますので手を抜けないところでもあります。

また、チームに全方面に詳しい・明るいUX/UIエンジニアがプロジェクトにいてリードしてくれれば良いですが、必ずしもそうでないでしょう。しかし、見栄えの話は意見が言いやすいことから、いろいろ発散してしまいがちで、気がつけば「実際はUXに寄与しない誰かの好みの見栄えの確保」のために、せっかく上記で点検・整理した検索プロセスを崩さざるを得ないということにもなりかねません。

ともあれチームビルディングや合意形成のうまいやり方で乗り切るというところも一つですが、そこにどういう役割でかかわるにせよ、チームの一員としては検索結果画面について自分なりのブレないメンタルモデル*3をしっかりイメージしておくと良いと考えています。

私の場合は、

検索結果画面は、

検索処理を伴うものの、実際のところは

「ユーザーが気になるものを意識的・無意識的に机の上に広げたただのリスト」

というイメージで捉えています。

エンドユーザーは机の上あるいは自分の手のひらの中で、ドキュメントを転がしている、そのための場が検索結果画面、というものです。

このイメージをイメージにしたのが次の図です。

f:id:azotar:20190131015559p:plain

イメージを無理にイメージにした図というようにくだらないシャレなアレな図なので読者のみなさんに伝わるかは自信は無いですが、例えばこのような頭の整理をしてみると良いでしょうという主張としてあげさせていただきました。

検索結果画面よくあるパターンのバリエーション(の可視化)

上記の捉え方がイケているかはともかく、ひとまず自分なりのイメージが出来上がると、想定ペルソナとの組み合わせで、必須なあるいはサイトの目的にあうなら確実にあったら良いと思われるUI要素がニョキニョキと生えてきて、次の図のように、結果的に「よくあるパターン」のバリエーションの範囲に収束しそうな気がします。(しませんか???)

検索結果画面よくあるパターンのバリエーション f:id:azotar:20190131015643p:plain

あとは、自分がどの立場かにもよりますが、例えば検索UXとElasticsearchを軸にしたディレクター的立場なら、デザイナーさんやCSSデザイナーさんに、サイトのコンセプトとともに、このバリエーションの図およびバリエーションの図から引き出した今回の利用候補のコンポーネント一覧を伝えて検討してもらう、というような段取りになるかと思います。

まとめ

ある種の検索サイトにターゲットを絞ったとして、どのようにElasticsearchを活かしてサイトをディレクションするかについて自分の意見をまとめてみました。

Elasticsearchをダシにしつつかつターゲットを限定した割には、毎度のごとくイメージ図ばかりになってしまいましたので、テック系の内容やもう少し具体的な例で加筆できそうなものがあれば、(ここで述べているストーリーが古くならないうちに)随時追記したいと思います。ただし、意図せぬお漏らしなどはできないので、実際にすごいことをやっているかどうかは別として検証系はこの記事用に全く無関係なものの似た構造のデータを見つけて来てやってというところになるのでやりたくても悩ましいところ。

また、Elasticsearchを利用させてもらっている者として、その魅力を伝えるようなアピール作文ができたら追記したいです(優等生発言!)。

参考リンク:古くならないうちに....

この記事が「古くならないうち」にと言いましたが、今やこの記事の講釈を垂れたような内容(およびそれ以上の発展的なもの)は、例えばトップランナーの方が紹介されている次のようなもので、実際に簡単に試すことができるところまで来ているようです。

Reactive Searchの紹介

qiita.com

Reactive Search のVue.js版の公式サイト ↓

https://opensource.appbase.io/reactivesearch/vue

(この記事執筆時点では、私はどちらかといえば、Vue.js派なので...)

また、この記事ではスコープを絞り込むことで少ないリソースで検索サイトを仕上げるというスタンスでまとめていますが、そんなチマチマしなくとも、公式のクラウドサービスのうちのさらにいくつかサイト検索系の製品では(私は触って見たことがあるわけではありませんが、おそらく)いろんなユースケース用のパターンがプリセットかつオールインワンで同梱されており、Elasticsearchの生のコンフィグファイル設定ではなくGUIの管理画面などでサクサク検索サイトが実現できると思われます。 (いろいろ類似の例がありますが、私のなんとなく知っている範囲では、Cloudera社のHadoop系のスイーツのサービスモデル・ビジネスモデル、エコシステムと似た動向を感じています。)

自分の書いた記事の自己否定ではないですが、いくつかある検索エンジン、またluceneをベースにしたものの中でもElasticsearchはサービスのエコシステムが強力そうなので、まずは公式サイトを当たってみるのが良いと思います。

www.elastic.co www.elastic.co


========================= 本編終わり ==========================


付録

標準化パターンの解説

本編で「標準化パターン」として私見を述べましたが、これらについて解説(もし新人向けに話したら老害一歩前の説教か退屈な昔話風)しています。

観点 解説
レコード名寄せ そもそものデータソース側がそれでええんかいというところはありますが、同じデータ(とみなせるもの)が複数存在する場合は1件のドキュメントになるようにレコードの名寄せをしましょう。

名寄せっぽいものとして、Elasticsearchの検索機能側には、collapse機能があります。こちらは、同じ著者の複数著作を著者単位に折りたたんだり、複数職種を募集しているある企業を親検索結果として見せるという用途に絞った使い方かと思います。
平坦化 Elasticsearchでは、NestedなデータタイプやObject型の階層データを扱えますが、何かと得意ではありません。得意でないというと語弊があるかもしれませんが、ECでの商品検索のように属性検索と全文検索を併用するというような泥臭系の検索ニーズは検索対象のデータの構造化や階層化に必要以上にこだわりすぎない方が良いのではと考えています。 この世界はオブジェクト指向ではないと思います。

... ということで具体的には、「a.b.c」フィールドの値を、「a_b_c」フィールドに格納し直す、人の名前で「氏名」が「氏」と「名」にサブプロパティに分けて保持しているというようなものを結合して1つのフィールドに統合するというのは考え方の一つとしてあるかと思います。(後者の例はPainless Scriptでも平易に実現可能ですね。)
表示名派生 元のデータソースでコード値持ちのものについては、表示名に置き換えたフィールドを派生してやりましょう。これで、フリーワードでの検索メニューでも該当のフィールドを検索にひっかける土壌ができます。

なお、長くなるので詳しくは書きませんが、コード値と表示名の対応表がデータソース側でどういう運営になっているかを確認しましょう。

また、検索サービスとして、同じ意味を表す表示名の変遷があった場合にどういう扱いにするポリシーとするかを整理しておきましょう(例えば、埼玉市からさいたま市に変わった場合に、お店の住所フィールド自体は最新化するでしょうが、埼玉市でも引き続きヒットさせるようにするか、するとしてどこの何のしかけでそれに対応するか)。
簡易全文検索フィールド かつての「_all」フィールド相当をイメージしたものです。
Elasticsearchには、multi_matchなど、複数フィールド(個別指定、フィールド名ワイルドカード指定ともに可)で同じ条件で検索して(一方、個別にブーストできる)ものもあります。 

ここでは、それらはそれらで活用するとして、様々なところの性能などのトレードオフ等も考慮しつつ、元のデータソースでは、フィールドA、フィールドB、フィールドCのように別フィールドに格納されているものの、属性検索の要件があきらかに発生しないと思われるものについては、一つのフィールドに片寄せしたようなドキュメントになるように編集したものをインデックスにPOSTするという考え方に対応するという考え方もあります。

これにより、検索側のクエリが検索ならではの要件にシンプルになるようにインデックス時に頑張っておくというスタイルもあるでしょう。
データソース側固有のビジネスロジックの圧縮 背景としては前項と似たようなもの。
人材募集サイトで、お金を多めに払ったスポンサー企業の募集と通常の募集を検索時に区別してやるとして(つまり検索シーズからいうと優待顧客とそうでない顧客のYES/NOの区別さえつけば良いという場合に)、データソースのビジネスロジックの都合で、フィールドAがX、BがY、CはZ1またはZ3、ただし...のような判定で「優待顧客かそうでないかの判定」を行う必要がある場合があります。
これを検索側に持ち込むのは得策ではない。... ということで、このような例の場合は、インデックス時にBooleanのフィールドに変換してやるというある種の正規化をしてやるという手はありますよねという話。

ちなみにベタなロジックでドキュメント自体を編集するというのもあるが、ElasticsearchだとPercolatorをうまく使って宣言的に条件を定義しておいてというやり方も使えるのでせっかくなら活用したいところ。
オフライン分類 これまた背景としては前項と似たようなもの。
元のデータソースのあるフィールドをもとにこのドキュメント自体のカテゴリ分けやタグ付け、ラベル付けをしてやる。データソースの方では厳密な値のみの管理で大分類のようなものは管理していない・しようもないような場合でも、検索ニーズではビッグワードや大分類レベルで検索できると嬉しいという話がしばしばあって、検索側だけで頑張る場合は、大分類ワードをデータソースの該当属性の対応付けを元にOR検索になるように展開してやる必要があるのと、検索側に展開のための知識を保持して(ロジックを作って)やる必要があるので、それをどう評価するかという話かもしれません。

なお、OR検索がめんどくさいと言いましたが、大ジャンル→配下の小ジャンル群(配列でなくても空白区切やカンマ区切りの列挙で良い。要素数も特に気にしなくて良い。)をドキュメントとしたジャンル対応インデックスを作ってやることで、このインデックスを独自類義語辞書風につかって、取得した小ジャンル群を"terms"クエリでマルッと検索することができなくはないと思いますので念のため補足しておきます。(ただし、たすき掛けの検索になるので検索時性能としては、冒頭に述べたようなオフラインで事前に分類してやるというチョイスは頭に入れておいた方が良いかもと思います。
ドメイン情報フィールドの除外 うまい言葉が見つからなかったので妙な呼び名になっていますが、主旨としては、データソースのフィールドのうち明らかに表側の検索サービスに不要な項目は、ドキュメントそのものから取り除いておきましょうという方針です。

例えば、ECの商品情報でいうと、データソース側の商品管理システムでは、商品情報最終更新オペレータ名などを保持していると思いますが、このようなフィールドは検索エンジンのインデックスにつかづける前に削除するクリーニングをしましょう(もうちょっと言うと、このようなクリーニング処理を「宣言的」に設定するだけで対象項目を調整できるような仕掛けの要否を整理しておきましょう)という話です。
異体字の派生 お店名や事業者名に含まれる漢字の異体字については都合よくヒットさせて欲しいね(例: 渡辺で渡邊)という要望がつきものかと思います。
教科書的にはchar_filterで頑張るのが基本かもしれませんが、検索対象のデータやAWS Elasticsearch Serviceを利用する場合などは外付けの対応表ファイルを使うのも何かと難しいです。

ということで、異体字を一般にもっとも平易な漢字に変換したものをフィールド派生してやるというのは、案としてアリです。
あくまで対象フィールドを限定して、コメント欄等は対象外で良いと思いますが、得てして事業者名等については、実際のUXとしてどうかはともかく、奇妙だが譲れない(ただし、検索エンジン全体の設定を変える程ではないし、そうしたくない)という要望が出がちだと思いますので、そういう場合の逃げ道にもなると考えています。

なお、ここでは異体字は、派生させる方針としましたが。別案としては、ANALYZERで、kuromoji_readingform token filterの読み仮名化したフィールドをインデックス化するという手があります。kuromojiの辞書の読み仮名の範囲に限られますが、「渡辺」で標準フィールド格納の「渡邊」を検索して空振りしても、読み仮名化したフィールドの検索でひっかけることができます。
カナ項目の清音化 kuromojiやicuなどのANALYZERでは、残念ながら(?)、清音化まではやってくれません。
ここで言う清音化とは「ベット」や「バック」で「ベッド」や「バッグ」をよしなに検索できるようにしましょうという用途のものです。
これぐらいならプラグインを自前で作成しても良いのかもしれません。一応ここでは、ElasitcsearchにPOSTする前の前処理でいろいろやってみるという話の流れなので「ド」を「ト」に置き換えたような「清音化済みフィールド」を用意してやる、あるいは少しだけシンプルに間違えがちな単語に置き換えた派生フィールドを作ってやるというのは案としてあるかもしれません。
もっと素直に「Fuzzy検索」する手もありますね。
ただ、この類のものはどこで入り込んでくるかわからないので、全部をFuzzy検索にするのも...というところも悩ましい話かと思います。

いずれにせよ、他の表記の揺れに比べればニーズが小さいのかもしれませんが、「清音化」で救いたい検索ワードの入力誤り例はしばしば不特定多数が利用するサイトではあなどれないこともあるというところを主張しておきたいです。
aggsのbucket用にグループ化 データソースのRDBでは1つのカラムに0、1ビットでフラグ管理されている類の項目A、B、Cがあります。
検索サービスとしてみると、これらはaggsの同じbucketに分類したいという場合がしばしばあります。
このようなケースに対応しやすくするために、インデックスにPOSTするドキュメントは、A、B、Cの設定内容に応じて、これらを配列に入れてやるというアプローチです。

例えばレストラン検索サイトなら、A、B、Cは、個室有無、貸切可否、駐車場有無といったカラムで、これらを席・設備というbucketにします。

上記の表の中で簡易全文検索フィールドと言っているものについては次の図でそもそもどんなユースケースを想定しているかイメージを表してみています。

(この記事のためにフリーハンドで作成したので(?)、図の中では、「簡易全文検索フィールド」は、「全文検索保険フィールド(仮称)」という呼び名になっています。良い名前を付けてあげたいのですが、初見の人にも伝わるような名前は思いつかずというところです。何か非公開のドキュメントを転用したわけではなく、この記事のために書き下ろした都合やむなしということでご了承くださいませ。)

f:id:azotar:20190126074555p:plain

*1: 念のため、オートコンプリートならやっぱりSuggesters系とした場合の、ど真ん中の正攻法はこちらのまとめを参考にさせてもらうのが良いと思います。 https://qiita.com/kijtra/items/36dd35b3b9db75c88f55

*2:ここでは、クエリコンテキストとフィルターコンテキストの意味のフィルターではなく、EXCELのフィルターのような絞り込みのニュアンス

*3:メンタルモデルという用語の誤用かもしれませんが。ひとまず。