Skip to main content

ElasticsearchでSnykの全文検索を改善して学んだこと

著者
Headshot of Sergey Vasilkov

Sergey Vasilkov

blog banner snyk preview early access

2021年11月4日

0 分で読めます

Elasticsearchは、広く使われているオープンソースの検索エンジンです。リアルタイムの処理速度と堅牢なAPIを備えているため、プロジェクトに全文検索機能を追加したい開発者に人気があります。広く使われているだけでなく、現在、issuesを対象としたSnykレポートの機能にも、このエンジンを導入しています!issuesでの調整がすべて完了したら、ほかのレポート領域でもElasticsearchを使い始める予定です。

セキュリティ上の脆弱性、ライセンス、スコア、CVE識別子、影響を受けるプロジェクト、悪用の成熟度を一覧表示したSnyk Issuesダッシュボード

Elasticsearchは強力ですが、最初は複雑に感じられることもあります(検索エンジンに詳しい方は別ですが)。私自身、Snykで導入するなかでこのツールについて多くを学んだばかりなので、開発者から開発者へ、知見を共有したいと思います!

標準アナライザーだけでは不十分な場合も

Elasticsearchは、データの種類に応じて処理を変えることで高速性を保っています。さまざまなフィールド型のなかに、テキストフィールドがあります。文字列などのテキストコンテンツを格納する一般的なフィールドです。このフィールドに保存された情報を検索可能にするため、Elasticsearchはデータの取り込み時にテキスト分析を行い、データをトークン(語句)に変換します。そして、トークンや長さ、位置などの関連情報をインデックスに保存します。デフォルトでは、テキスト分析にstandardアナライザーが使われます。

公式ドキュメントには、次のように記載されています。「standardアナライザーは、ほとんどの自然言語やユースケースを、すぐに使える状態でサポートします。standardアナライザーをそのまま使う場合、追加の設定は不要です。」

基本的なケースなら、standardアナライザーで十分なこともあります。次のコマンドを実行すると、指定したテキストがどのように分析されるかを確認できます。

POST _analyze
{
  "analyzer": "standard",
  "text": "Regular Expression Denial of Service (ReDoS)"
}

And the result will look like:

{
  "tokens" : [
    {
      "token" : "regular",
      "start_offset" : 0,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "expression",
      "start_offset" : 8,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "denial",
      "start_offset" : 19,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "of",
      "start_offset" : 26,
      "end_offset" : 28,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "service",
      "start_offset" : 29,
      "end_offset" : 36,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "redos",
      "start_offset" : 38,
      "end_offset" : 43,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

ここでは、standardアナライザーが何をしているのかが分かります。テキストをトークンに分割し、それぞれの部分についてインデックスに保存するための情報を生成しています。

インデックス化したテキストを検索する

インデックス化したら、standardアナライザーで全文検索をテストできます。titleとdescriptionの2つのフィールドを持つサンプルドキュメントを作成します。Elasticsearchがドキュメントを自動的にマッピングし、テキストフィールドを判別します。

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

次に、以下のクエリで「express」を検索してみましょう。

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express"
      }
    }
  }
}

タイトルに「expression」が含まれているにもかかわらず、ドキュメントは返されません。これは想定どおりです(以前にも同じことを経験した私にとっては、ですが!)。上で確認した生成トークンから分かるように、アナライザーが「express」というトークンを作成していないためです。

検索クエリを「expression」に変えると、ドキュメントが返されます。

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "expression"
      }
    }
  }
}

このトークンの不一致を回避するには、ファジークエリやワイルドカードクエリなど、より高コストなクエリを実行する方法もあります。しかし、計算コストが高くなります。問題にCPUサイクルを費やすのではなく、アナライザーを見直す必要があります。

より優れた柔軟な検索結果を得るには、standardアナライザーだけでは不十分な場合があります。目標を実現するために、カスタムアナライザーを設定します。要件は次のとおりです。

  • 短いクエリを使えること

  • 大文字と小文字を区別しない検索

そのために、次の手順で進めます。

  1. カスタムアナライザーを作成する

  2. ユースケースに合った、より優れたトークナイザーを設定する

  3. Elasticsearchインデックスの対象フィールドで、新しいカスタムアナライザーを有効にする

アナライザーを詳しく見る

standardアナライザーを使うことで、その処理内容を詳しく理解する必要がなくなり、手軽に進められました。しかし、今回はカスタムアナライザーを作成するため、テキスト分析で何が起きるのかを理解する必要があります。

  1. 文字フィルター(任意)は、分析対象のテキストに適用され、文字を取り除きます。

  2. トークナイザーは、テキストをトークンまたは語句に分割します。空白や文字など、さまざまな方法でトークンを生成できます。

  3. トークンフィルター(任意)は、小文字への変換や特定のトークンの削除など、トークンに追加の処理を行います。

アナライザーは、トークナイザーとフィルターを組み合わせたものです。テキストを分析し、検索できる状態にします。すべてのアナライザーには、トークナイザーが1つ必須ですが、2種類のフィルターはそれぞれ、数を任意に設定できます。処理の順序は常に変わりません。文字フィルター > トークナイザー > トークンフィルターの順です。

初めてのカスタムアナライザーを作成する

先ほど見たように、デフォルトではすべてのテキストフィールドにstandardアナライザーが使われます。Elasticsearchには、追加設定なしでどのインデックスでも使えるさまざまな組み込みアナライザーが用意されています。また、独自のアナライザーを作成することもできます。今回のユースケースでは、独自のアナライザーを作成する必要があります。

アナライザーは、インデックス、フィールド、クエリの各レベルで設定できます。今回はカスタムアナライザーを作成し、フィールドに指定します。カスタムアナライザーの設定方法は次のとおりです。注:設定はインデックスの設定に保存されます。

PUT text-search-index
{
  "settings":{
     "analysis":{
        "analyzer":{
           "my_analyzer":{
              "type":"custom",
              "tokenizer":"standard"
           }
        }
     }
  },
  "mappings":{
      "properties":{
         "title": {
            "type":"text",
            "analyzer":"my_analyzer"
         },
         "text": {
           "type": "text"
         }
      }
   }
}

ここで行ったことは次のとおりです。

  1. インデックスにmy_analyzerという新しいカスタムアナライザーを指定

  2. my_analyzerでstandardトークナイザーとlowercaseフィルターを使用

  3. インデックスマッピングのtitleプロパティで、my_analyzerをアナライザーとして有効化

この例はとてもシンプルで、standardアナライザーの動作は変わりません。ここから調整を加え、不完全な単語でも大文字と小文字を区別せずに検索できるようにします。

より優れたトークナイザーを選んで設定する

前の例ではstandardトークナイザーを使いました。次はこれを、空白ではなく長さに基づいてトークンを分割するedge_ngramに置き換えます。なぜstemmerではなくedge_ngramを使うのか気になる方は、ぜひ読み進めてください!

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer"
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

ここで行ったことは次のとおりです。

  1. my_tokenizerでedge_ngramトークナイザーを使用

  2. 長さが3~8文字のトークンを生成

  3. トークンには英字と数字のみを含める

より優れたトークンフィルターを選んで設定する

上で作成したアナライザーを使うと、長さが3~8文字の不完全な単語でも検索できます。残るのは、大文字と小文字を区別しない検索への対応です。my_analyzerに適切なlowercaseトークンフィルターを追加すれば実現できます。

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer",
      "filter":[
        "lowercase"
      ]
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

完成形

変更をすべて加えたので、カスタムアナライザーを使った新しいインデックスを作成できます。アナライザーを変更する前に知っておきたいのは、update mapping APIを使っても、既存フィールドの設定は更新できないということです。

基本的なテスト用インデックスのマッピングを含む設定は、次のようになります。

PUT text-search-index
{
  "settings":{
    "analysis":{
      "analyzer":{
        "my_analyzer":{
          "type":"custom",
          "tokenizer":"my_tokenizer",
          "filter":[
            "lowercase"
          ]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "edge_ngram",
          "min": 3,
          "max": 8,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings":{
    "properties":{
      "title": {
        "type":"text",
        "analyzer":"my_analyzer"
      },
      "text": {
        "type": "text"
      }
    }
  }
}

これでドキュメントを追加し、さまざまなクエリを実行して、検索が想定どおりに動作することを確認できます。

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

「express」またはそれに似た、不完全で大文字と小文字を区別しないトークンを含むタイトルを検索します。

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express" // try: EXPRESS, exp, expression
      }
    }
  }
}

インデックスのtextプロパティにはカスタムアナライザーを設定していないため、こちらでは従来どおりstandardアナライザーで検索されます。

次のステップ

この記事では、Elasticsearchでより柔軟なテキスト検索を実現するためにカスタムアナライザーを設定し、基本的な設定や構成をいくつか紹介しました。Elasticsearchには、さまざまな組み込みアナライザー、トークナイザー、フィルター、ノーマライザー、ストップワードの設定など、多くの機能が用意されています。

これらを組み合わせれば、優れたカスタム検索を実現できます。Elasticsearchを使い始めたばかりの方にとって、この記事が参考になれば幸いです。検索への取り組みについて、今後も発信していく予定ですのでお楽しみに。次回は集約について紹介します!(数えられること間違いなし…)

Snykの一員となって、開発者向けの優れたセキュリティツールを開発したい方は、こちらから エンジニア職の募集をご覧ください。