ElasticsearchとNode.jsで全文検索エンジンを作ろう(後編)

2016/10/23

Behrooz Kamali

45

Articles in this issue reproduced from SitePoint
Copyright © 2016, All rights reserved. SitePoint Pty Ltd. www.sitepoint.com. Translation copyright © 2016, KADOKAWA CorporationJapanese syndication rights arranged with SitePoint Pty Ltd, Collingwood, Victoria,Australia through Tuttle-Mori Agency, Inc., Tokyo

全文検索エンジンってどれがいい?→ElasticsearchとNode.jsで作るといい感じ」に続き、サイト内検索などに使える全文検索エンジンをElasticsearchとNode.jsで作ります。

検索エンジンの構築

データがインデックス付けされると、検索エンジンを実装する準備が整います。Elasticsearchはクエリを定義するために、Query DSLというJSONに基づいた直感的で完全な検索クエリ構造を提供しています。利用できる検索クエリの種類はたくさんありますが、本記事ではより一般的なものについて触れます。Query DSLの完全なマニュアルはここにあります。

例として説明したすべてのソースコードへのリンクを提供しています。環境を設定し、テストデータをインデックスして、リポジトリをコピーすれば、ローカルマシンのコマンドラインからnode filename.jsとするだけでサンプルを実行できます。

1つまたは複数のインデックスにすべてのドキュメントを返す

検索の実行には、クライアントが提供するさまざまな検索方法を使います。もっとも単純なクエリは、すべての文書を1つまたは複数のインデックスで返すmatch_allです。次の例では、インデックスに格納されているドキュメントを取得する方法を説明しています(ソースはここ)。

//search_all.js

const search = function search(index, body) {
  return esClient.search({index: index, body: body});
};

const test = function test() {
  let body = {
    size: 20,
    from: 0,
    query: {
      match_all: {}
    }
  };

  search('library', body)
  .then(results => {
    console.log(`found ${results.hits.total} items in ${results.took}ms`);
    console.log(`returned article titles:`);
    results.hits.hits.forEach(
      (hit, index) => console.log(
        `\t${body.from + ++index} - ${hit._source.title}`
      )
    )
  })
  .catch(console.error);
};

メインの検索クエリは、queryオブジェクト内にあります。あとで説明しますが、queryオブジェクトに検索クエリのタイプを追加できます。各クエリに、検索オプションを含むオブジェクトとなる値を持つクエリタイプ(本例ではmatch_all)のあるキーを追加します。インデックス内のすべてのドキュメントを返したいので、本例ではオプションはありません。

queryオブジェクトに加え、search bodyにはsizefromなどのほかのプロパティを含められます。sizeプロパティはレスポンスに含むドキュメントの数を決定します。この値が存在しない場合はデフォルトで10のドキュメントが返されます。fromプロパティは返されたドキュメントの開始インデックスを決定します。これはページネーションに役立ちます。

検索APIのレスポンスを理解する

検索API(上の例のresults)からのレスポンスにはたくさんの情報が含まれていて、最初は圧倒されるかもしれません。

{ took: 6,
  timed_out: false,
  _shards: { total: 5, successful: 5, failed: 0 },
  hits:
   { total: 1000,
     max_score: 1,
     hits:
      [ [Object],
        [Object],
    ...
        [Object] ] } }

最高レベルでは、レスポンスには検索にかかったミリ秒数を示すtookプロパティ、最大許容時間内に見つからなかった場合にのみtrueとなるtimed_out、異なるノードのステータスについての情報の_shards(ノードのクラスタとして展開されている場合)、検索結果を含むhitsが含まれます。

hitsプロパティには、以下の特性を持つオブジェクトがあります。

  • total — 一致した項目の合計数
  • max_score — 見つかった項目の最大スコア
  • hits — 見つかった項目を含む配列。hits配列の各ドキュメント内には、インデックス、タイプ、ドキュメントID、スコア、(_source要素内の)ドキュメント自体がある

とても複雑ですが、一度結果を抽出するメソッドを実装すれば、検索クエリに関係なく常に同じフォーマットで結果を得られるのは朗報です。

また、Elasticsearchのメリットの1つは、一致する各ドキュメントにスコアを自動的に割り当てることにも留意してください。デフォルトでは、このスコアはドキュメントの関連性を定量化するために使用され、結果はスコアを降順で返します。match_allを持つすべてのドキュメントを検索する場合、スコアは無意味で、すべてのスコアが1.0として計算されます。

フィールドに特定の値が含まれるドキュメントの一致

それでは、もっと興味深い例を説明します。フィールドの特定の値を含むドキュメントを一致させるには、matchクエリを使用します。matchクエリのあるシンプルなsearch bodyは次のとおりです(ソースはここ)。

// search_match.js

{
  query: {
    match: {
      title: {
        query: 'search terms go here'
      }
    }
  }
}

前にも書いたとおり、検索タイプ(上の例ではmatch)のクエリオブジェクトにエントリーを追加します。検索タイプオブジェクトの内部ではtitleのあるドキュメントフィールドが検索されるように特定します。その内部にはqueryプロパティを含む検索関連のデータを置きます。上の例をテストしたあとは、検索の速さに感心すると思います。

上の検索クエリは、タイトルフィールドがqueryプロパティ内の単語と一致するドキュメントを返します。次のように、一致する用語の最小数を設定できます。

// search_match.js

...
match: {
  title: {
    query: 'search terms go here',
    minimum_should_match: 3
  }
}
...

このクエリは、少なくとも3つの指定した単語をタイトルに使っているドキュメントと一致します。クエリに含まれる単語が3つ未満の場合は、ドキュメントを一致させるためにすべての単語がタイトルで使われている必要があります。

検索クエリに追加する別の便利な機能は、ファジーです。ファジーマッチングが綴りの近い用語を探すので、ユーザーがクエリ記述のタイプミスをした場合に便利です。文字列の場合は、ファジー値は各用語の最大許容レーベンシュタイン距離に基づいています。以下は、ファジーのある例です。

match: {
  title: {
    query: 'search tems go here',
    minimum_should_match: 3,
    fuzziness: 2
  }
}

複数のフィールド内を検索

複数のフィールド内を検索したい場合はmulti_match検索タイプを使います。これはmatchと似ており、検索クエリオブジェクトのキーとしてフィールドを持つ代わりを除いて、検索するフィールドの配列のfieldsキーを追加します。ここではtitleauthors.firstname、およびauthors.lastnameフィールド内を検索します(ソースはここ)。

// search_multi_match

multi_match: {
  query: 'search terms go here',
  fields: ['title', 'authors.firstname',  'authors.lastname']
}

multi_matchクエリはminimum_should_matchfuzzinessなどのほかの検索プロパティをサポートしています。Elasticsearchは複数のフィールドを一致させるためのワイルドカード(たとえば)をサポートするので、上の例は['title', 'authors.name']に短縮できます。

完全に一致するフレーズ

Elasticsearchは、入力したフレーズが用語レベルで一致しなくても、正確に一致させられます。このクエリはmatch_phraseと呼ばれる通常のmatchクエリの拡張機能です。以下はmatch_phraseの一例です(ソースはここ)。

// match_phrase.js

match: {
  title: {
    query: 'search phrase goes here',
    type: 'phrase'
  }
}

複数のクエリを組み合わせる

これまでのところ、実施例ではリクエストごとに単一のクエリを使用してきました。しかしElasticsearchは複数のクエリを組み合わせられます。もっとも一般的な複合クエリはboolです。boolクエリはmustshouldmust_notfilterの4種のキーを受け入れます。名前が示すとおり、結果のドキュメントはmust内のクエリと一致する必要があり、must_not内のクエリとは一致してはならず、should内のクエリが一致する場合は高いスコアを取得します。先に挙げた各要素は、クエリ配列の形で複数の検索クエリを受け取れます。

次の例では、query_stringと呼ばれる新しいクエリタイプと一緒にboolクエリを使用しています。これによってANDORなどのキーワードを使用してより高度なクエリを記述できます。構文query_stringの完全なドキュメントはこちらにあります。さらに、フィールドを与えられた範囲に制限できるrangeクエリ(ドキュメントはこれ)を使用します(ソースはここ)。

// search_bool.js

{
  bool: {
    must: [
      {
        query_string: {
          query: '(authors.firstname:term1 OR authors.lastname:term2) AND (title:term3)'
        }
      }
    ],
    should: [
      {
        match: {
          body: {
            query: 'search phrase goes here',
            type: 'phrase'
          }
        }
      }
    ],
    must_not: [
      {
        range: {
          year: {
            gte: 2011,
            lte: 2013
          }
        }
      }
    ]
  }
}

上の例では、クエリは著者のファーストネームにterm1またはラストネームにterm2が含まれ、タイトルにterm3が含まれ、2011年、2012年、2013年に公開されていないドキュメントを返します。また、bodyに語句を渡しているドキュメントは高いスコアを与えられ結果の上部に表示されます(matchクエリがshould句にあるため)。

フィルター、集計、補完

Elasticsearchは高度な検索機能のほかに別の機能があります。3つの一般的機能を説明します。

フィルター

指定した条件に基づいて検索結果を絞り込みたいことがよくあります。Elasticsearchは、フィルターを使って実現します。記事データでいくつかの記事を返して、そのうち特定の5年間で公開された記事のみを選択したいとします。検索順序を変更することなく、検索結果から条件に一致しないものをすべて除外できます。

フィルターとboolクエリのmust句の違いは、フィルターは検索スコアに影響しませんが、 mustクエリは影響することです。検索結果が返され、ユーザーフィルターが特定の基準にある場合、元の結果の順序を変更する代わりに、結果から無関係なドキュメントを取り除くことを求めます。フィルターは検索と同じ形式になりますが、多くの場合はテキスト文字列ではなく決定的な値を持つフィールドで定義されます。Elasticsearchでは、bool複合検索クエリのfilter句を介してフィルターを追加することを勧めています。

上の例と同じように、検索結果を2011年から2015年の間に公開された記事に制限したいとします。そのためには、元の検索クエリのfilter部分にrangeクエリを追加して、一致しないドキュメントを結果から取り除きます。以下はフィルターしたクエリの一例です(ソースはここ)。

// filter.js

{
  bool: {
    must: [
      {
        match: {
          title: 'search terms go here'
        }
      }
    ],
    filter: [
      {
        range: {
          year: {
            gte: 2011,
            lte: 2015
          }
        }
      }
    ]
  }
}

集計

集計フレームワークは、検索クエリに基づいた各種集計データや統計情報を提供します。主要な集計タイプはメトリックとバケットの2つで、メトリック集計はトラックを保ちドキュメントのセット上でメトリックを計算します。バケット集計はキーおよびドキュメント基準に関連している各バケットでバケットを構築します。メトリック集計の例は、平均、最小値、最大値、合計、値のカウントです。バケット集計の例は、範囲、日付範囲、ヒストグラム、用語です。集計の詳細説明はこちらにあります。

集計は、searchオブジェクト本体に直接配置されているaggregationsオブジェクト内にあります。aggregationsオブジェクト内では、各キーはユーザーが集計に割り当てた名前です。集計タイプとオプションは、該当するキーの値として配置します。以下では、2つの異なる集計、メトリック1つ、バケット1つについて説明します。メトリック集計として、データセット内の最小年の値(もっとも古い記事)を調べ、バケット集計として、各キーワードが登場した回数を調べます(ソースはここ)。

// aggregations.js

{
  aggregations: {
    min_year: {
      min: {field: 'year'}
    },
    keywords: {
      terms: {field: 'keywords'}
    }
  }
}

上の例では、メトリック集計をyearフィールド上のminタイプを意味するmin_yearと名づけました(名前は任意です)。バケット集計はkeywordsフィールドのtermsタイプを意味するkeywordsと名づけています。集計結果はレスポンスのaggregations要素内に含まれており、より深いレベルでは、結果に沿って定義された各集計が含まれています(min_yearkeywordsはここ)。以下は、この例の部分レスポンスです。

{
...
  "aggregations": {
    "keywords": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 2452,
      "buckets": [
        {
          "key": "pariatur",
          "doc_count": 88
        },
        {
          "key": "adipisicing",
          "doc_count": 75
        },
        ...
      ]
    },
    "min_year": {
      "value": 1970
    }
  }
}

デフォルトでは、レスポンスで返されるのは多くて10バケットです。返すバケットの最大数を決定するために、リクエストのfieldの隣にsizeキーを追加できます。すべてのバケットを受け取りたい場合は、この値を0に設定します。

補完

Elasticsearchには、入力された用語の代替または補完を提供する複数タイプのサジェスターがあります(ドキュメントはこちら)。この項ではサジェスターの用語とフレーズについて説明します。用語サジェスターは、フレーズサジェスターが(用語の分解とは対照的に)入力されたテキストの完全なテキストを見つけ、(もしあれば)ほかのフレーズを補完する間に、入力されたテキストの各用語を(もしあれば)補完します。補完APIを使用するには、Node.jsクライアントのsuggestメソッドを呼び出します。以下は用語サジェスターの一例です(ソースはここ)。

// suggest_term.js

esClient.suggest({
  index: 'articles',
  body: {
    text: 'text goes here',
    titleSuggester: {
      term: {
        field: 'title',
        size: 5
      }
    }
  }
}).then(...)

ほかのすべてのクライアントメソッドと一致するリクエスト本体には、検索のためのインデックスを決定するindexフィールドがあります。bodyプロパティでは提案を求めているテキストを追加し、(集約オブジェクトのように)各サジェスターに名前をつけます(このケースではtitleSuggester)。その値はサジェスターのタイプとオプションを決定します。この場合titleフィールドにはtermサジェスターを使用しており、トークンあたりの最大数を5に制限しています(size: 5)。

補完APIからのレスポンスには、textフィールドの用語数と同じサイズの配列で要求したすべてのサジェスターが含まれています。配列内の各オブジェクトには、textフィールドに補完を含むoptionsオブジェクトがあります。以下は、上記リクエストのレスポンスの一部です。

...
"titleSuggester": [
  {
    "text": "term",
    "offset": 0,
    "length": 4,
    "options": [
      {
        "text": "terms",
        "score": 0.75,
        "freq": 120
      },
      {
        "text": "team",
        "score": 0.5,
        "freq": 151
      }
    ]
  },
  ...
]
...

フレーズの補完を取得するには、上と同じ形式に従い、サジェスタータイプをphraseに置き換えます。次の例では、レスポンスは上と同じ形式になっています(ソースはここ)。

// suggest_phrase.js

esClient.suggest({
  index: 'articles',
  body: {
    text: 'phrase goes here',
    bodySuggester: {
      phrase: {
        field: 'body'
      }
    }
  }
}).then(...).catch(...);

参考文献

Elasticsearchには、1つの記事では紹介しきれない幅広い機能があります。本記事では、高いレベルの特徴を説明し、さらに学ぶための適切なリソースを紹介しました。Elasticsearchにはとても高い信頼性と素晴らしい性能があります(サンプルを実行すれば分かるはずです)。Elasticsearchは、コミュニティサポートの成長と相まって、業界での、特にリアルタイムあるいはビッグデータを扱う企業での採用が増えています。

ここにある例で練習したあとは、ドキュメントを読むことを強くおすすめます。ドキュメントには2つの主なソースがあり、1つはElasticsearchとその機能について説明しており、もう1つは実装、使用例、ベストプラクティスに焦点をあてたガイドです。また、Node.jsクライアントの詳細ドキュメントもあります。

※本記事はMark BrownVildan SofticMoritz Krögerが査読を担当しています。最高のコンテンツに仕上げるために尽力してくれたSitePointの査読担当者のみなさんに感謝します。

(原文:Build a Search Engine with Node.js and Elasticsearch

[翻訳:柴田理恵/編集:Livit

Copyright © 2016, Behrooz Kamali All Rights Reserved.

Behrooz Kamali

Behrooz Kamali

MEANスタックに特化したフルスタック開発者です。オペレーションズリサーチ、データ分析、アルゴリズム設計、効率の分野の専門知識を習得して、産業およびシステムエンジニアリングの博士号も取得しました。開発をしていないときは、教えることや、新しいことを学ぶのを楽しんでいます。

Loading...